From 7a09167261ca722bbe940863a5c6b56f18784da8 Mon Sep 17 00:00:00 2001 From: Zhao Date: Tue, 21 Apr 2026 12:15:35 +0700 Subject: [PATCH] Initial commit: PastPaper Master full stack Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 12 + 080c1b16be5aa2e1ea87d5175894fb3c.jpg | Bin 0 -> 105047 bytes HANDOFF_COMP2211.md | 328 + TECHNICAL.md | 516 ++ backend/Dockerfile | 16 + backend/add_progress_columns.sql | 4 + backend/app/__init__.py | 0 backend/app/config.py | 36 + backend/app/dependencies/__init__.py | 0 backend/app/dependencies/auth.py | 34 + backend/app/main.py | 59 + backend/app/routers/__init__.py | 0 backend/app/routers/analytics.py | 285 + backend/app/routers/attempts.py | 208 + backend/app/routers/papers.py | 142 + backend/app/routers/questions.py | 325 + backend/app/services/__init__.py | 0 backend/app/services/grader.py | 146 + backend/app/services/llm_clients.py | 74 + backend/app/services/paper_processor.py | 576 ++ backend/app/services/supabase_client.py | 13 + backend/app/services/text_extractor.py | 48 + backend/backfill_ai_trio_with_context.py | 252 + backend/backfill_comp2211_page_y.py | 160 + backend/backfill_comp2211_tags.py | 365 + backend/backfill_null_ai_trio.py | 169 + backend/backfill_similar_questions.py | 135 + backend/backfill_vision.py | 238 + backend/fill_manual_study_aids.py | 29 + backend/import_course_manifest.py | 240 + backend/pyproject.toml | 17 + backend/regen_ai_trio_comp2211.py | 174 + backend/regenerate_analysis.py | 69 + ...split_comp2211_2022_spring_final_part_a.py | 224 + ...split_comp2211_2022_spring_final_part_b.py | 232 + backend/split_comp2211_2022_spring_midterm.py | 233 + backend/split_comp2211_2023_spring_midterm.py | 268 + backend/split_comp2211_2024_spring_final.py | 242 + backend/split_comp2211_2024_spring_midterm.py | 291 + backend/upload_course_library_pdfs.py | 121 + backend/uv.lock | 1969 +++++ deploy.md | 92 + docker-compose.yml | 10 + docs/PAGE_NUMBER_BACKFILL.md | 152 + docs/TAGGING_REQUIREMENTS.md | 243 + frontend/Dockerfile | 12 + frontend/index.html | 13 + frontend/nginx.conf | 27 + frontend/package-lock.json | 3058 +++++++ frontend/package.json | 30 + frontend/public/favicon.jpg | Bin 0 -> 105047 bytes frontend/src/App.tsx | 30 + frontend/src/components/layout/Header.tsx | 69 + .../components/layout/ProcessingBanner.tsx | 183 + .../components/shared/CollapsibleSection.tsx | 65 + .../src/components/shared/KaTeXRenderer.tsx | 86 + .../src/components/shared/StatusBadge.tsx | 15 + .../src/components/upload/FilePickerField.tsx | 63 + frontend/src/components/upload/UploadForm.tsx | 184 + .../src/components/workbench/ActionBar.tsx | 58 + .../src/components/workbench/AiTrioPanel.tsx | 21 + .../src/components/workbench/PdfViewer.tsx | 170 + .../src/components/workbench/PhotoUpload.tsx | 90 + .../components/workbench/QuestionDetail.tsx | 260 + .../src/components/workbench/QuestionNav.tsx | 56 + .../workbench/SimilarHistoryPanel.tsx | 130 + .../components/workbench/VariantDetail.tsx | 148 + .../src/components/workbench/VariantModal.tsx | 189 + frontend/src/contexts/AuthContext.tsx | 49 + frontend/src/hooks/usePaper.ts | 43 + frontend/src/hooks/useQuestions.ts | 33 + frontend/src/lib/api.ts | 190 + frontend/src/lib/questionGroups.ts | 45 + frontend/src/lib/supabase.ts | 6 + frontend/src/main.tsx | 16 + frontend/src/pages/AnalyticsPage.tsx | 521 ++ frontend/src/pages/ErrorBookPage.tsx | 296 + frontend/src/pages/HomePage.tsx | 705 ++ frontend/src/pages/LoginPage.tsx | 90 + frontend/src/pages/UploadPage.tsx | 16 + frontend/src/pages/WorkbenchPage.tsx | 524 ++ frontend/src/styles/globals.css | 79 + frontend/src/types/api.ts | 169 + frontend/src/vite-env.d.ts | 1 + frontend/tsconfig.json | 21 + frontend/vite.config.ts | 22 + index 2.html | 22 + memory/MEMORY.md | 3 + memory/project_pastpaper_master.md | 37 + pastpaper-scraper | 1 + pitch_script.md | 25 + supabase/migrations/001_init_schema.sql | 207 + .../migrations/002_course_library_fields.sql | 38 + .../003_question_taxonomy_fields.sql | 41 + .../004_decouple_course_library_from_auth.sql | 30 + .../005_allow_long_question_format_alias.sql | 27 + .../migrations/006_make_scores_numeric.sql | 17 + supabase/migrations/007_fulltext_search.sql | 36 + supabase/migrations/008_add_page_y_ratio.sql | 2 + .../008_fix_storage_url_placeholder.sql | 27 + ...omp2211_2022_fall_page_number_backfill.sql | 52 + .../seeds/comp2211_course_library_papers.sql | 148 + .../comp2211_problem_level_questions.sql | 7173 +++++++++++++++++ .../comp2211_problem_taxonomy_backfill.sql | 109 + tech_defense.md | 274 + 105 files changed, 24799 insertions(+) create mode 100644 .gitignore create mode 100644 080c1b16be5aa2e1ea87d5175894fb3c.jpg create mode 100644 HANDOFF_COMP2211.md create mode 100644 TECHNICAL.md create mode 100644 backend/Dockerfile create mode 100644 backend/add_progress_columns.sql create mode 100644 backend/app/__init__.py create mode 100644 backend/app/config.py create mode 100644 backend/app/dependencies/__init__.py create mode 100644 backend/app/dependencies/auth.py create mode 100644 backend/app/main.py create mode 100644 backend/app/routers/__init__.py create mode 100644 backend/app/routers/analytics.py create mode 100644 backend/app/routers/attempts.py create mode 100644 backend/app/routers/papers.py create mode 100644 backend/app/routers/questions.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/grader.py create mode 100644 backend/app/services/llm_clients.py create mode 100644 backend/app/services/paper_processor.py create mode 100644 backend/app/services/supabase_client.py create mode 100644 backend/app/services/text_extractor.py create mode 100644 backend/backfill_ai_trio_with_context.py create mode 100644 backend/backfill_comp2211_page_y.py create mode 100644 backend/backfill_comp2211_tags.py create mode 100644 backend/backfill_null_ai_trio.py create mode 100644 backend/backfill_similar_questions.py create mode 100644 backend/backfill_vision.py create mode 100644 backend/fill_manual_study_aids.py create mode 100644 backend/import_course_manifest.py create mode 100644 backend/pyproject.toml create mode 100644 backend/regen_ai_trio_comp2211.py create mode 100644 backend/regenerate_analysis.py create mode 100644 backend/split_comp2211_2022_spring_final_part_a.py create mode 100644 backend/split_comp2211_2022_spring_final_part_b.py create mode 100644 backend/split_comp2211_2022_spring_midterm.py create mode 100644 backend/split_comp2211_2023_spring_midterm.py create mode 100644 backend/split_comp2211_2024_spring_final.py create mode 100644 backend/split_comp2211_2024_spring_midterm.py create mode 100644 backend/upload_course_library_pdfs.py create mode 100644 backend/uv.lock create mode 100644 deploy.md create mode 100644 docker-compose.yml create mode 100644 docs/PAGE_NUMBER_BACKFILL.md create mode 100644 docs/TAGGING_REQUIREMENTS.md create mode 100644 frontend/Dockerfile create mode 100644 frontend/index.html create mode 100644 frontend/nginx.conf create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/public/favicon.jpg create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/components/layout/Header.tsx create mode 100644 frontend/src/components/layout/ProcessingBanner.tsx create mode 100644 frontend/src/components/shared/CollapsibleSection.tsx create mode 100644 frontend/src/components/shared/KaTeXRenderer.tsx create mode 100644 frontend/src/components/shared/StatusBadge.tsx create mode 100644 frontend/src/components/upload/FilePickerField.tsx create mode 100644 frontend/src/components/upload/UploadForm.tsx create mode 100644 frontend/src/components/workbench/ActionBar.tsx create mode 100644 frontend/src/components/workbench/AiTrioPanel.tsx create mode 100644 frontend/src/components/workbench/PdfViewer.tsx create mode 100644 frontend/src/components/workbench/PhotoUpload.tsx create mode 100644 frontend/src/components/workbench/QuestionDetail.tsx create mode 100644 frontend/src/components/workbench/QuestionNav.tsx create mode 100644 frontend/src/components/workbench/SimilarHistoryPanel.tsx create mode 100644 frontend/src/components/workbench/VariantDetail.tsx create mode 100644 frontend/src/components/workbench/VariantModal.tsx create mode 100644 frontend/src/contexts/AuthContext.tsx create mode 100644 frontend/src/hooks/usePaper.ts create mode 100644 frontend/src/hooks/useQuestions.ts create mode 100644 frontend/src/lib/api.ts create mode 100644 frontend/src/lib/questionGroups.ts create mode 100644 frontend/src/lib/supabase.ts create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/AnalyticsPage.tsx create mode 100644 frontend/src/pages/ErrorBookPage.tsx create mode 100644 frontend/src/pages/HomePage.tsx create mode 100644 frontend/src/pages/LoginPage.tsx create mode 100644 frontend/src/pages/UploadPage.tsx create mode 100644 frontend/src/pages/WorkbenchPage.tsx create mode 100644 frontend/src/styles/globals.css create mode 100644 frontend/src/types/api.ts create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts create mode 100644 index 2.html create mode 100644 memory/MEMORY.md create mode 100644 memory/project_pastpaper_master.md create mode 160000 pastpaper-scraper create mode 100644 pitch_script.md create mode 100644 supabase/migrations/001_init_schema.sql create mode 100644 supabase/migrations/002_course_library_fields.sql create mode 100644 supabase/migrations/003_question_taxonomy_fields.sql create mode 100644 supabase/migrations/004_decouple_course_library_from_auth.sql create mode 100644 supabase/migrations/005_allow_long_question_format_alias.sql create mode 100644 supabase/migrations/006_make_scores_numeric.sql create mode 100644 supabase/migrations/007_fulltext_search.sql create mode 100644 supabase/migrations/008_add_page_y_ratio.sql create mode 100644 supabase/migrations/008_fix_storage_url_placeholder.sql create mode 100644 supabase/seeds/comp2211_2022_fall_page_number_backfill.sql create mode 100644 supabase/seeds/comp2211_course_library_papers.sql create mode 100644 supabase/seeds/comp2211_problem_level_questions.sql create mode 100644 supabase/seeds/comp2211_problem_taxonomy_backfill.sql create mode 100644 tech_defense.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..79a89b3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +.env +.env.* +node_modules/ +__pycache__/ +*.pyc +.DS_Store +dist/ +.claude/ +.venv/ +pastpaper-scraper/ +pastpaper/ +*.pdf diff --git a/080c1b16be5aa2e1ea87d5175894fb3c.jpg b/080c1b16be5aa2e1ea87d5175894fb3c.jpg new file mode 100644 index 0000000000000000000000000000000000000000..02adfd80fdd9a3bc5e1cae6b6a299cf233493043 GIT binary patch literal 105047 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 文件: `supabase/migrations/001_init_schema.sql` + +### Table: `papers` — 试卷 + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | UUID PK | 自动生成 | +| user_id | UUID FK → auth.users | 上传者 | +| course_code | TEXT | 课程代码, e.g. "COMP2011" | +| year / term / exam_type | INT/TEXT/TEXT | 元信息 | +| paper_file_url | TEXT | 试卷 PDF (Supabase Storage) | +| answer_file_url | TEXT? | 答案 PDF (可选) | +| status | TEXT | `uploaded` → `processing` → `ready` / `error` | +| paper_extracted_text | TEXT | PyMuPDF 提取的原始文本 (缓存) | +| total_score / question_count | INT | AI 提取的整卷概览 | +| topics_summary | JSONB | `{"Linked List": 40, "Recursion": 30}` | +| difficulty_level | TEXT | easy / medium / hard | + +### Table: `paper_questions` — 逐题数据 + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | UUID PK | | +| paper_id | UUID FK → papers | | +| question_number | TEXT | "1", "1a", "2b" | +| parent_question | TEXT? | 子题父题号: "1a" → "1" | +| display_order | INT | 排序 | +| question_type | TEXT | `mc` / `true_false` / `fill_blank` / `long_question` | +| question_text | TEXT | 题目原文 | +| score / page_number | INT | 分值, PDF 页码 (PDF-题目联动用) | +| options | JSONB | MC 选项: `[{"label":"A","text":"..."}]` | +| correct_option | TEXT | MC 正确选项 | +| correct_answer | TEXT | 填空题正确答案 | +| raw_answer_text | TEXT | 答案 PDF 原始解�� | +| topics | TEXT[] | 知识点标签 | +| difficulty | TEXT | easy / medium / hard | +| knowledge_reminder | TEXT | AI 知识点提醒 (HTML+KaTeX) | +| ai_hint | TEXT | AI 思路提示 (HTML+KaTeX) | +| solution | TEXT | AI 完整解题过程 (HTML+KaTeX) | + +### Table: `user_attempts` — 用户答题记录 + +| 字段 | 类型 | 说明 | +|------|------|------| +| id | UUID PK | | +| user_id / question_id | UUID FK | | +| attempt_type | TEXT | `select` / `input` / `photo` | +| user_answer | TEXT | 用户的选项或输入 | +| photo_url / photo_ocr_text | TEXT | 拍照上传的图片和 OCR 结果 | +| is_correct | BOOL | AI 判定 | +| feedback | TEXT | HTML 逐步错误分析 | +| error_at_step | INT | 第几步出错 | +| in_error_book / mastered | BOOL | 错题本状态 | + +--- + +## 核心功能一:试卷分析管线 + +### 流程概述 + +``` +用户上传 PDF → 后台 BackgroundTask → 5 步管线 → 状态变 ready +``` + +### 文件 + +| 文件 | 作用 | +|------|------| +| `backend/app/routers/papers.py` | 上传接口, 触发后台处理 | +| `backend/app/services/paper_processor.py` | **核心管线**, 5 步处理逻辑 | +| `backend/app/services/text_extractor.py` | PDF → 文本提取 (PyMuPDF) | +| `backend/app/services/llm_clients.py` | GPT-4o / Qwen 客户端单例 | + +### 管线 5 步 (`paper_processor.py: process_paper()`) + +**Step 1 — PDF 文本提取** +- 使用 PyMuPDF (`fitz`) 逐页提取文本 +- 如果某页文本 < 50 字符 (可能是扫描件), 额外保存该页为 base64 图片备用 +- 提取结果缓存到 `papers.paper_extracted_text` + +```python +# text_extractor.py +extract_pdf(file_bytes) → ExtractedContent(pages_text, page_images, total_pages, has_images) +get_full_text(extracted) → "--- Page 1 ---\n{text}\n\n--- Page 2 ---\n..." +``` + +**Step 2 — GPT-4o 结构化拆题** +- Model: `gpt-4o`, temperature=0, response_format=json_object +- 输入: 整卷文本 +- 输出: JSON 包含 total_score, difficulty_level, topics_summary, questions[] +- 每题提取: question_number, parent_question, question_type, question_text, score, page_number, options, topics, difficulty +- 更新 `papers` 表的概览字段 (total_score, question_count, topics_summary, difficulty_level) + +**Step 3 — 答案匹配 (如果有答案 PDF)** +- Model: `gpt-4o`, temperature=0 +- 输入: 题目结构 JSON + 答案文本 +- 输出: 逐题匹配 — correct_option / correct_answer / raw_answer_text +- 选择题 → correct_option, 填空题 → correct_answer, 大题 → raw_answer_text + +**Step 4 — Qwen 生成 AI 三件套 (逐题)** +- Model: `qwen-plus`, temperature=0.3 +- 逐题调用, 输入题目信息 + 标准答案 +- 输出 JSON 三件套: + - `knowledge_reminder`: 前置知识要点 (HTML+KaTeX) + - `ai_hint`: 不给答案的思路引导 (HTML+KaTeX) + - `solution`: 完整逐步解题过程 (HTML+KaTeX) +- 写入 `paper_questions` 表 + +**Step 5 — 标记完成** +- `papers.status` 更新为 `ready` +- 如果任何步骤抛异常, status 设为 `error`, 错误信息写入 `error_message` + +### 关键 Prompt 设计 + +**STRUCTURE_PROMPT** — 结构化拆题 +- 限定 question_type 只能是 mc / true_false / fill_blank / long_question +- 判断题 (True/False) 用 `true_false` 类型,options 为 `[{label:"True",text:"True"},{label:"False",text:"False"}]` +- 选择题必须提取 options 数组 +- 子题通过 parent_question 关联 (e.g. "1a" parent 是 "1") +- 要求推断 page_number, topics, difficulty + +**ANSWER_MATCH_PROMPT** — 答案匹配 +- 输入包含 questions_json (题号+题型) 和 answer_text +- 按题型输出不同字段: MC → correct_option, fill → correct_answer, 大题 → raw_answer_text + +**ANALYSIS_PROMPT** — AI 三件套 +- Solution 要求带完整过程 (Step 1, 2, 3...), 不能只给答案 +- 选择题要解释为什么对、为什么其他选项错 +- 标注常见错误: `
...
` +- KaTeX 规则: 块级 `$$...$$`, 行内 `$...$` + +--- + +## 核心功能二:PDF 滚动 + 题目联动 + +### 文件 + +| 文件 | 作用 | +|------|------| +| `frontend/src/components/workbench/PdfViewer.tsx` | PDF 连续滚动渲染 + 可见页检测 | +| `frontend/src/components/workbench/QuestionNav.tsx` | 题目水平导航栏 | +| `frontend/src/pages/WorkbenchPage.tsx` | 双向联动调度中枢 | + +### 实现方案 + +**布局**: 左侧 60% PDF, 右侧 40% 题目面板 + +**PDF 连续滚动 (`PdfViewer.tsx`)** +- 使用 `react-pdf` 的 `` + `` 组件 +- 所有页面垂直排列在可滚动容器中 (不是单页切换) +- `ResizeObserver` 监听容器宽度, 动态设置 Page width +- 手动跳转: 输入页码 → `scrollIntoView` + +**双向联动:** + +1. **题目 → PDF (点击题目, PDF 滚动到对应页)** + - QuestionNav 点击 → `handleQuestionSelect(index)` → 记录 `lastUserSelectTime = Date.now()` + `setCurrentIndex` + - PdfViewer 收到 `currentPage` prop 变化 → `useEffect` 触发 `el.scrollIntoView({ behavior: "smooth" })` + - 设置 `programmaticScroll.current = true`, 2s 后重置 + +2. **PDF → 题目 (滚动 PDF, 右侧自动切换到当前题)** + - `IntersectionObserver` 监听所有 `` 元素, threshold: `[0, 0.25, 0.5, 0.75, 1]` + - 追踪每页的 `intersectionRatio`, 选出可见占比最高的页码 + - 如果 `programmaticScroll.current === true`, 跳过回调 + - 触发 `onPageChange(bestPage)` → WorkbenchPage `handlePdfPageChange` + - `handlePdfPageChange`: 找到 `page_number <= currentPage` 的最后一题, 更新 `currentIndex` + +**防止跳转抢夺 (双层保护):** +- **WorkbenchPage 层 (核心)**: `lastUserSelectTime` ref — 用户点击题目后 2 秒内, `handlePdfPageChange` 直接 return, 不响应任何 Observer 回调。解决长文档 smooth scroll 经过中间页触发 Observer 导致题目被切走的问题 +- **PdfViewer 层 (辅助)**: `programmaticScroll` ref — scrollIntoView 期间 Observer 回调跳过, 2s 后重置 + +--- + +## 核心功能三:做题交互 (MC / 填空) + +### 文件 + +| 文件 | 作用 | +|------|------| +| `frontend/src/components/workbench/QuestionDetail.tsx` | 题目展示 + 答题交互 | +| `frontend/src/components/workbench/AiTrioPanel.tsx` | 知��点/提示/解析 折叠面板 | +| `frontend/src/components/shared/CollapsibleSection.tsx` | 可折叠区域组件 | +| `frontend/src/components/shared/KaTeXRenderer.tsx` | HTML+KaTeX 渲染器 | + +### QuestionDetail 交互逻辑 + +**选择题 (MC):** +- 状态: `selectedOption`, `checked` +- 点击选项 → 高亮蓝色 (未检查时) +- 点击 "Check Answer" → `checked=true` +- 正确: 选项变绿 + "Correct!" / 错误: 选中项变红, 正确项变绿 + 显示正确答案 +- 切换题目时自动重置状态 (`useEffect` on `question.id`) + +**判断题 (True/False):** +- 状态: `tfAnswers: Record`, `tfChecked` +- 每个 statement 右侧有 T / F 两个按钮, 独立切换 +- 选中高亮蓝色, 全部选完后可点 "Submit Answers" +- 提交后提示查看 solution 对答案 (因为逐条正确答案暂未单独存储) + +**填空题 (Fill Blank):** +- 文本输入框 + "Check" 按钮 +- Enter 键可直接检查 +- 大小写不敏感比较 (`toLowerCase()`) +- 检查后输入框变色: 绿色 (对) / 红色 (错) + +**回调**: `onAnswerResult(isCorrect, userAnswer)` → WorkbenchPage → `recordAttempt` API + +### AiTrioPanel + +- 三个 `CollapsibleSection`: Knowledge Reminder (蓝, 默认展开), AI Hint (琥珀), Solution (绿) +- `CollapsibleSection` 使用 CSS `grid-template-rows: 0fr → 1fr` 动画平滑展开收起 +- 内容通过 `KaTeXRenderer` 渲染 (HTML + KaTeX 公式) + +--- + +## 核心功能四:变体题生成 (Similar Question) + +### 文件 + +| 文件 | 作用 | +|------|------| +| `backend/app/routers/questions.py` | `POST /{question_id}/variant` 端点 | +| `backend/app/services/grader.py` | `generate_variant()` — GPT-4o 生成变体 | +| `frontend/src/components/workbench/ActionBar.tsx` | "Similar Question" 按钮, 异步触发 | +| `frontend/src/pages/WorkbenchPage.tsx` | Variants Tab 状态管理 | +| `frontend/src/components/workbench/VariantDetail.tsx` | 变体题作答界面 | + +### 后端 + +- `POST /api/questions/{question_id}/variant` +- 从 DB 查原题 → 调 `generate_variant(question)` → 附上原题的 `knowledge_reminder` → 返回 +- Model: `gpt-4o`, temperature=0.5, response_format=json_object +- VARIANT_PROMPT 要求: 同知识点, 相似难度, 不同数据/场景, 输出 HTML 格式 (非 markdown) +- 输出字段: question_text, question_type, options (if MC), correct_answer, ai_hint, solution + +### 前端交互 (Tab-based 异步流程) + +**状态管理 (`WorkbenchPage.tsx`):** +```typescript +interface StoredVariant { + id: string; // placeholder ID, e.g. "variant-1" + sourceQuestionNumber: string; // 原题题号 + variant: VariantQuestion; // 生成结果 + status: "generating" | "ready"; +} +``` + +**流程:** +1. 用户点击 "Similar Question" → `ActionBar` 调 `onVariantStart(placeholderId, questionNumber)` +2. WorkbenchPage 创建 `status: "generating"` 的占位项, 用户可继续做题不受阻塞 +3. API 返回后 → `onVariantReady(placeholderId, variant)` → 状态更新为 `ready` +4. 失败 → `onVariantFailed(placeholderId)` → 删除占位项 + +**右侧面板三种视图:** +- **Questions Tab**: 题目导航 + QuestionDetail + AiTrioPanel + ActionBar +- **Variants Tab**: 变体列表 (Generating.../Ready), 每项显示题号和预览文本 +- **Variant Detail**: 点击 "Start" 后整个右侧替换为 VariantDetail 组件 + "Back" 按钮 + +**VariantDetail 组件**: 紫色主题, 包含完整 MC/填空交互 + AI 三件套 (CollapsibleSection) + +--- + +## 核心功能五:拍照批改 + +### 文件 + +| 文件 | 作用 | +|------|------| +| `backend/app/routers/attempts.py` | `POST /photo` — 上传+OCR+批改 | +| `backend/app/services/grader.py` | `ocr_photo()` + `grade_answer()` | +| `frontend/src/components/workbench/PhotoUpload.tsx` | 拍照上传 Modal | +| `frontend/src/components/workbench/ActionBar.tsx` | "Upload handwritten answer" 按钮 | + +### 后端流程 + +1. 接收图片 → 上传到 Supabase Storage `attempt-photos` bucket +2. `ocr_photo(photo_bytes)` — GPT-4o Vision 识别手写内容 + - 输入: base64 图片 + - 输出: 学生答案文本 (含 LaTeX 公式) +3. `grade_answer(question, student_answer)` — Qwen-plus 批改 + - 输入: 题目信息 + 标准答案 + 学生答案 + - 输出: `{ is_correct, score_given, feedback (HTML), error_at_step }` +4. 写入 `user_attempts` 表 (含 photo_url, photo_ocr_text, feedback, is_correct) +5. 答错自动 `in_error_book = true` + +### 前端 + +- PhotoUpload: Modal 弹窗, 支持拖拽/点击选择图片 +- 预览 → 提交 → 显示 OCR 识别结果 + AI 批改反馈 +- 所有题型均可使用 (MC / 填空 / 大题) + +--- + +## 核心功能六:错题本 + +### 文件 + +| 文件 | 作用 | +|------|------| +| `backend/app/routers/attempts.py` | `GET /error-book` + `PATCH /{attempt_id}` | +| `frontend/src/pages/ErrorBookPage.tsx` | 错题本页面 | +| `frontend/src/lib/api.ts` | `getErrorBook()` + `updateAttempt()` | + +### 后端 + +- `GET /api/attempts/error-book?user_id=xxx` + - 查询 `in_error_book=true AND mastered=false` + - JOIN `paper_questions` 返回完整题目信息 +- `PATCH /api/attempts/{attempt_id}` + - 更新 `in_error_book` 或 `mastered` 标记 + +### 前端 + +- 列表展示: 题目信息 + 用户答案 + AI 反馈 +- 操作: "Review in Workbench" (跳转) / "Mastered" (标记掌握) / "Remove" (移出错题本) + +--- + +## 核心功能七:答题记录 + +### 文件 + +| 文件 | 作用 | +|------|------| +| `backend/app/routers/attempts.py` | `POST /` — 记录答题 | +| `frontend/src/components/workbench/ActionBar.tsx` | "Got it right" / "Got it wrong" 按钮 | + +### 流程 + +- "Got it right" → `POST /api/attempts/` with `attempt_type: "select", is_correct: true` +- "Got it wrong" → `POST /api/attempts/` with `attempt_type: "select", is_correct: false` + - 后端自动 `in_error_book = true` +- Toast 提示操作结果 + +--- + +## API 接口汇总 + +### Papers Router (`/api/papers`) + +| Method | Path | 说明 | +|--------|------|------| +| GET | `/` | 列出所有试卷 (可按 user_id 过滤) | +| POST | `/upload` | 上传试卷 PDF + 可选答案 PDF | +| GET | `/{paper_id}` | 获���单份试卷信息 | +| GET | `/{paper_id}/questions` | 获取试卷所有题目 | + +### Attempts Router (`/api/attempts`) + +| Method | Path | 说明 | +|--------|------|------| +| POST | `/` | 记录一次答题 | +| POST | `/photo` | 拍照上传 + OCR + AI 批改 | +| GET | `/error-book?user_id=` | 获取错题本 | +| PATCH | `/{attempt_id}` | 更新错题本/掌握状态 | + +### Questions Router (`/api/questions`) + +| Method | Path | 说明 | +|--------|------|------| +| POST | `/{question_id}/variant` | 生成变体题 | + +--- + +## 前端路由 + +| 路径 | 页面 | 文件 | +|------|------|------| +| `/` | 首页 — 试卷列表 | `src/pages/HomePage.tsx` | +| `/upload` | 上传试卷 | `src/pages/UploadPage.tsx` | +| `/paper/:id` | 做题工作台 | `src/pages/WorkbenchPage.tsx` | +| `/error-book` | 错题本 | `src/pages/ErrorBookPage.tsx` | + +--- + +## 前端组件树 (Workbench) + +``` +WorkbenchPage +├── Header # 顶部导航 (课程+试卷标题) +├── PdfViewer # 左侧 60% — PDF 连续滚动 +└── Right Panel (40%) + ├── [Questions Tab] + │ ├── QuestionNav # 题目水平导航 Q1 Q2 Q3... + │ ├── QuestionDetail # 题目展示 + MC/填空交互 + │ ├── AiTrioPanel # 知识点/提示/解析 (3x CollapsibleSection) + │ └── ActionBar # 底部按钮 (对/错/变体/拍照) + ├── [Variants Tab] + │ └── Variant Cards # 变体列表 (Generating.../Ready) + └── [Variant Detail View] # 替换整个右侧 + ├── Back Button + └── VariantDetail # 变体题作答 + AI 三件套 +``` + +--- + +## LLM 调用模型分工 + +| 任务 | 模型 | Provider | 文件 | +|------|------|----------|------| +| 结构化拆题 | gpt-4o | laozhang API | paper_processor.py | +| 答案匹配 | gpt-4o | laozhang API | paper_processor.py | +| AI 三件套 (knowledge/hint/solution) | qwen-plus | DashScope | paper_processor.py | +| 手写 OCR | gpt-4o (Vision) | laozhang API | grader.py | +| 答案批改 | qwen-plus | DashScope | grader.py | +| 变体题生成 | gpt-4o | laozhang API | grader.py | + +--- + +## 配置与环境变量 + +> 文件: `backend/app/config.py`, `.env` + +| 变量 | 说明 | +|------|------| +| SUPABASE_URL | Supabase 项目 URL | +| SUPABASE_ANON_KEY | 前端用匿名 Key | +| SUPABASE_SERVICE_ROLE_KEY | 后端用 Service Role Key (绕过 RLS) | +| LAOZHANG_BASE_URL | GPT-4o 代理 API 地址 | +| LAOZHANG_API_KEY | GPT-4o 代理 API Key | +| DASHSCOPE_BASE_URL | 阿里 DashScope API | +| DASHSCOPE_API_KEY | DashScope API Key | + +--- + +## 文件完整索引 + +### Backend (`backend/app/`) + +``` +main.py # FastAPI 入口, CORS, 路由注册 +config.py # Pydantic Settings, 环境变量 +routers/ + papers.py # 试卷 CRUD + 上传触发处理 + attempts.py # 答题记录 + 拍照OCR批改 + 错题本 + questions.py # 变体题生成 +services/ + paper_processor.py # 核心5步管线: PDF→结构化→答案匹配→AI三件套 + text_extractor.py # PyMuPDF 文本提取 + grader.py # OCR + 批改 + 变体生成 (Prompt + LLM 调用) + llm_clients.py # GPT-4o / Qwen 客户端单例 + supabase_client.py # Supabase 客户端 +``` + +### Frontend (`frontend/src/`) + +``` +App.tsx # React Router 路由定义 +main.tsx # ReactDOM 入口 +lib/ + api.ts # 所有 API 调用封装 (9 个函数) +types/ + api.ts # TypeScript 类型定义 +hooks/ + usePaper.ts # 轮询获取试卷状态 (3s interval) + useQuestions.ts # 获取题目列表 +pages/ + HomePage.tsx # 首页 — 试卷列表 + UploadPage.tsx # 上传页 + WorkbenchPage.tsx # 做题工作台 — 核心调度组件 + ErrorBookPage.tsx # 错题本 +components/ + layout/ + Header.tsx # 顶部导航栏 + shared/ + KaTeXRenderer.tsx # HTML+KaTeX 公式渲染 + CollapsibleSection.tsx # 折叠面板 (grid动画) + StatusBadge.tsx # 状态标签 + upload/ + UploadForm.tsx # 上传表单 + FilePickerField.tsx # 文件选择器 + workbench/ + PdfViewer.tsx # PDF 连续滚动 + IntersectionObserver + QuestionNav.tsx # 题目导航栏 + QuestionDetail.tsx # 题目展示 + MC/填空交互 + AiTrioPanel.tsx # AI 三件套面板 + ActionBar.tsx # 底部操作按钮 + PhotoUpload.tsx # 拍照上传 Modal + VariantDetail.tsx # 变体题内联作答 + VariantModal.tsx # (已废弃, 被 VariantDetail 替代) +``` diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..acf1a08 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,16 @@ +FROM python:3.12-slim + +WORKDIR /app + +# System deps for PyMuPDF +RUN apt-get update && apt-get install -y --no-install-recommends \ + libmupdf-dev gcc g++ && \ + rm -rf /var/lib/apt/lists/* + +COPY pyproject.toml . +RUN pip install --no-cache-dir . + +COPY app/ app/ + +EXPOSE 8000 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/backend/add_progress_columns.sql b/backend/add_progress_columns.sql new file mode 100644 index 0000000..bf9c883 --- /dev/null +++ b/backend/add_progress_columns.sql @@ -0,0 +1,4 @@ +ALTER TABLE papers +ADD COLUMN IF NOT EXISTS processing_step text DEFAULT NULL, +ADD COLUMN IF NOT EXISTS processing_progress integer DEFAULT 0, +ADD COLUMN IF NOT EXISTS processing_total integer DEFAULT 0; diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..355abbc --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,36 @@ +from pydantic_settings import BaseSettings +from functools import lru_cache +import os + + +class Settings(BaseSettings): + # Supabase + supabase_url: str + supabase_anon_key: str + supabase_service_role_key: str + + # LLM - laozhang (gpt-4o, gpt-4o-mini) + laozhang_base_url: str = "https://api.laozhang.ai/v1" + laozhang_api_key: str = "" + + # LLM - DashScope (qwen-plus) + dashscope_base_url: str = "https://dashscope.aliyuncs.com/compatible-mode/v1" + dashscope_api_key: str = "" + + # LLM - DeepSeek + deepseek_base_url: str = "https://api.deepseek.com/v1" + deepseek_api_key: str = "" + + # Google Gemini (official) + google_gemini_api_key: str = "" + + model_config = { + "env_file": os.path.join(os.path.dirname(__file__), "../../.env"), + "env_file_encoding": "utf-8", + "extra": "ignore", + } + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/backend/app/dependencies/__init__.py b/backend/app/dependencies/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/dependencies/auth.py b/backend/app/dependencies/auth.py new file mode 100644 index 0000000..29af84c --- /dev/null +++ b/backend/app/dependencies/auth.py @@ -0,0 +1,34 @@ +"""Auth dependency: validate Supabase JWT and return user_id""" + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from app.services.supabase_client import get_supabase + +bearer_scheme = HTTPBearer(auto_error=False) + + +async def get_current_user_id( + credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme), +) -> str: + """Extract and validate Bearer token, return user_id.""" + if not credentials: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + ) + token = credentials.credentials + sb = get_supabase() + try: + result = sb.auth.get_user(token) + user = result.user + if not user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid token", + ) + return user.id + except Exception: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired token", + ) diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..a520b41 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,59 @@ +import asyncio +import threading +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from app.routers import analytics, papers, attempts, questions + + +def _resume_stale_papers(): + """启动时检查卡在 processing 的 paper,自动续传 AI trio""" + try: + from app.services.supabase_client import get_supabase + from app.services.paper_processor import process_paper + + sb = get_supabase() + stale = sb.table("papers").select("id").eq("status", "processing").execute().data + if not stale: + return + + for p in stale: + paper_id = p["id"] + print(f"[STARTUP] Resuming processing for paper {paper_id[:8]}...") + + def run(pid=paper_id): + asyncio.run(process_paper(pid, b"", None)) + + threading.Thread(target=run, daemon=True).start() + except Exception as e: + print(f"[STARTUP] Resume skipped: {e}") + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Startup + _resume_stale_papers() + yield + # Shutdown (nothing to do) + + +app = FastAPI(title="PastPaper Master API", version="0.1.0", lifespan=lifespan) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # 开发阶段先放开,上线收紧 + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(papers.router, prefix="/api/papers", tags=["papers"]) +app.include_router(attempts.router, prefix="/api/attempts", tags=["attempts"]) +app.include_router(questions.router, prefix="/api/questions", tags=["questions"]) +app.include_router(analytics.router, prefix="/api/analytics", tags=["analytics"]) + + +@app.get("/health") +def health(): + return {"status": "ok"} diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routers/analytics.py b/backend/app/routers/analytics.py new file mode 100644 index 0000000..4771c04 --- /dev/null +++ b/backend/app/routers/analytics.py @@ -0,0 +1,285 @@ +"""Course-level analytics endpoints.""" + +from __future__ import annotations + +from collections import Counter, defaultdict + +from fastapi import APIRouter + +from app.services.supabase_client import get_supabase + +router = APIRouter() + + +DIFFICULTY_SCORE = {"easy": 1, "medium": 2, "hard": 3} +DIFFICULTY_LABEL = {1: "Easy", 2: "Medium", 3: "Hard"} + +# ── Topic normalization ────────────────────────────────────── +# Map variant spellings to canonical label +_TOPIC_ALIASES: dict[str, str] = { + "numpy": "NumPy", + "naïve bayes": "Naive Bayes", + "naïve bayes classifier": "Naive Bayes", + "naive bayes classifier": "Naive Bayes", + "bayes classifier": "Naive Bayes", + "bayes model": "Naive Bayes", + "bayes' theorem": "Naive Bayes", + "bayes' rule": "Naive Bayes", + "k-nearest neighbors": "K-Nearest Neighbors (KNN)", + "knn": "K-Nearest Neighbors (KNN)", + "k-means clustering": "K-Means Clustering", + "k-means": "K-Means Clustering", + "k means": "K-Means Clustering", + "multilayer perceptron": "Multilayer Perceptron (MLP)", + "multi-layer perceptron": "Multilayer Perceptron (MLP)", + "multi-layer perceptron (mlp)": "Multilayer Perceptron (MLP)", + "mlp": "Multilayer Perceptron (MLP)", + "single layer perceptron": "Perceptron", + "convolutional neural network": "CNN", + "convolutional neural network (cnn)": "CNN", + "convolutional neural networks": "CNN", + "cnn architecture": "CNN", + "cnn properties": "CNN", + "python fundamentals": "Python", + "python programming": "Python", + "python implementation": "Python", + "advanced python programming": "Python", + "python programming: convolutional neural network": "CNN", + "cross-validation": "Cross Validation", + "model evaluation implementation": "Model Evaluation", + "digital image processing": "Image Processing", + "computer vision": "Image Processing", + "array slicing": "Array Slicing", + "slicing": "Array Slicing", + "array indexing": "Array Slicing", + "array reshaping": "Reshape", + "array views": "Array Slicing", + "view vs copy": "Array Slicing", + "boolean indexing": "Array Slicing", + "arange": "NumPy", + "newaxis": "NumPy", + "expand dims": "NumPy", + "transpose": "NumPy", + "type casting": "NumPy", + "element-wise operation": "NumPy", + "array reduction": "NumPy", + "multi-dimensional array": "NumPy", + "dot product": "NumPy", + "vectorization": "NumPy", + "activation functions": "Activation Function", + "linear activation function": "Activation Function", + "neural network architecture": "Neural Networks", + "hidden layer": "Neural Networks", + "deep learning": "Neural Networks", + "deep learning frameworks": "Neural Networks", + "alpha-beta pruning": "Alpha-Beta Pruning", + "minimax algorithm": "Minimax", + "ethics of ai": "AI Ethics", + "ethics": "AI Ethics", + "cosine distance": "Cosine Similarity", + "distance calculation": "Distance Metrics", + "euclidean distance": "Distance Metrics", + "manhattan distance": "Distance Metrics", + "hamming distance": "Distance Metrics", + "precision": "Model Evaluation", + "recall": "Model Evaluation", + "f1 score": "Model Evaluation", + "macro f1 score": "Model Evaluation", + "accuracy": "Model Evaluation", + "classification accuracy": "Model Evaluation", + "confusion matrix": "Model Evaluation", + "convolution operation": "Convolution", + "dilated convolution": "Convolution", + "3d convolution": "Convolution", + "gaussian likelihood": "Probability", + "gaussian distribution": "Probability", + "categorical likelihood": "Probability", + "conditional probability": "Probability", + "total probability theorem": "Probability", + "probability assumptions": "Probability", + "tensorflow": "Keras", + "model summary": "Keras", + "model construction": "Keras", + "trainable parameters": "Parameter Calculation", + "parameter reduction": "Parameter Calculation", + "output shape calculation": "Parameter Calculation", + "shape calculation": "Parameter Calculation", +} + + +def normalize_topic(label: str) -> str: + return _TOPIC_ALIASES.get(label.lower().strip(), label) + + +def extract_topic_labels(question: dict) -> list[str]: + labels: list[str] = [] + raw_labels: list[str] = [] + + analytics_topic = question.get("analytics_topic") + if analytics_topic: + raw_labels.append(analytics_topic) + + for tag in question.get("topic_tags") or []: + if tag and tag not in raw_labels: + raw_labels.append(tag) + + if not raw_labels: + for tag in question.get("topics") or []: + if tag and tag not in raw_labels: + raw_labels.append(tag) + + # Normalize and deduplicate + seen: set[str] = set() + for raw in raw_labels: + norm = normalize_topic(raw) + if norm not in seen: + seen.add(norm) + labels.append(norm) + + return labels + + +def extract_question_family(question: dict) -> str: + return ( + question.get("question_format") + or question.get("question_type") + or "unknown" + ) + + +@router.get("/courses") +async def list_courses(): + """返回所有有 ready 状态试卷的课程列表""" + sb = get_supabase() + rows = ( + sb.table("papers") + .select("course_code") + .eq("status", "ready") + .execute() + .data + ) + codes = sorted({row["course_code"] for row in rows if row.get("course_code")}) + return codes + + +@router.get("/course/{course_code}") +async def get_course_analytics(course_code: str): + sb = get_supabase() + + papers = ( + sb.table("papers") + .select("id, course_code, year, term, exam_type, part_label, status") + .eq("course_code", course_code.upper()) + .eq("status", "ready") + .order("year", desc=True) + .execute() + .data + ) + if not papers: + return { + "course_code": course_code.upper(), + "kpi": {"papers": 0, "questions": 0, "topics": 0, "difficulty": "N/A"}, + "topic_frequency": [], + "question_types": [], + "difficulty_distribution": {"easy": 0, "medium": 0, "hard": 0}, + "high_yield_topics": [], + } + + paper_ids = [paper["id"] for paper in papers] + questions = ( + sb.table("paper_questions") + .select( + "id, paper_id, question_number, question_type, question_format, " + "question_text, score, topics, analytics_topic, topic_tags, difficulty" + ) + .in_("paper_id", paper_ids) + .order("display_order") + .execute() + .data + ) + + papers_by_id = {paper["id"]: paper for paper in papers} + total_questions = len(questions) + topic_counter: Counter[str] = Counter() + type_counter: Counter[str] = Counter() + difficulty_counter: Counter[str] = Counter() + topic_examples: dict[str, list[dict]] = defaultdict(list) + difficulty_scores: list[int] = [] + all_question_items: list[dict] = [] + + for question in questions: + question_type = extract_question_family(question) + type_counter[question_type] += 1 + + difficulty = question.get("difficulty") + if difficulty in DIFFICULTY_SCORE: + difficulty_counter[difficulty] += 1 + difficulty_scores.append(DIFFICULTY_SCORE[difficulty]) + + paper = papers_by_id.get(question["paper_id"], {}) + source_label = ( + f"{paper.get('year', '')} {paper.get('term', '').title()} " + f"{paper.get('exam_type', '').title()}" + ).strip() + if paper.get("part_label"): + source_label = f"{source_label} Part {paper['part_label']}" + + topics = extract_topic_labels(question) + q_item = { + "paper_id": paper.get("id"), + "source": source_label, + "question_number": question["question_number"], + "preview": question["question_text"][:220], + "difficulty": question.get("difficulty"), + "question_type": question_type, + "year": paper.get("year"), + "term": paper.get("term"), + "exam_type": paper.get("exam_type"), + "topics": topics, + } + all_question_items.append(q_item) + + for topic in topics: + topic_counter[topic] += 1 + topic_examples[topic].append(q_item) + + avg_difficulty = "N/A" + if difficulty_scores: + rounded = round(sum(difficulty_scores) / len(difficulty_scores)) + avg_difficulty = DIFFICULTY_LABEL.get(rounded, "Medium") + + topic_frequency = [] + for topic, count in topic_counter.most_common(): + pct = round((count / total_questions) * 100) if total_questions else 0 + topic_frequency.append( + { + "label": topic, + "count": count, + "pct": pct, + "questions": topic_examples[topic], + } + ) + + question_types = [] + for label, count in type_counter.most_common(): + pct = round((count / total_questions) * 100) if total_questions else 0 + question_types.append({"label": label, "count": count, "pct": pct}) + + return { + "course_code": course_code.upper(), + "kpi": { + "papers": len(papers), + "questions": total_questions, + "topics": len(topic_counter), + "difficulty": avg_difficulty, + }, + "topic_frequency": topic_frequency, + "question_types": question_types, + "all_questions": all_question_items, + "difficulty_distribution": { + "easy": difficulty_counter.get("easy", 0), + "medium": difficulty_counter.get("medium", 0), + "hard": difficulty_counter.get("hard", 0), + }, + "high_yield_topics": [topic for topic, _ in topic_counter.most_common(5)], + } diff --git a/backend/app/routers/attempts.py b/backend/app/routers/attempts.py new file mode 100644 index 0000000..291f8a6 --- /dev/null +++ b/backend/app/routers/attempts.py @@ -0,0 +1,208 @@ +"""用户答题记录 + 拍照批改 + 错题本""" + +import asyncio +from fastapi import APIRouter, UploadFile, File, Form, HTTPException, Depends +from pydantic import BaseModel +from app.services.supabase_client import get_supabase +from app.services.grader import ocr_photo, grade_answer +from app.dependencies.auth import get_current_user_id + +router = APIRouter() + + +class AttemptCreate(BaseModel): + question_id: str + attempt_type: str # "select" | "input" | "photo" + user_answer: str | None = None + is_correct: bool | None = None + + +class AttemptUpdate(BaseModel): + in_error_book: bool | None = None + mastered: bool | None = None + + +@router.post("/") +async def create_attempt(data: AttemptCreate, user_id: str = Depends(get_current_user_id)): + """记录一次答题""" + sb = get_supabase() + record = { + "user_id": user_id, + "question_id": data.question_id, + "attempt_type": data.attempt_type, + "user_answer": data.user_answer, + "is_correct": data.is_correct, + } + # Auto add to error book if wrong + if data.is_correct is False: + record["in_error_book"] = True + + result = sb.table("user_attempts").insert(record).execute() + return result.data[0] + + +@router.post("/photo") +async def photo_attempt( + question_id: str = Form(...), + photo: UploadFile = File(...), + user_id: str = Depends(get_current_user_id), +): + """拍照上传 → OCR → AI批改""" + sb = get_supabase() + + # 1. Read photo + photo_bytes = await photo.read() + + # 2. Upload to storage + storage_path = f"attempts/{user_id}/{question_id}/{photo.filename}" + sb.storage.from_("attempt-photos").upload( + storage_path, photo_bytes, + file_options={"content-type": photo.content_type or "image/jpeg", "upsert": "true"}, + ) + photo_url = sb.storage.from_("attempt-photos").get_public_url(storage_path) + + # 3. OCR (run in thread pool to avoid blocking event loop) + ocr_text = await asyncio.to_thread(ocr_photo, photo_bytes) + + # 4. Fetch question for grading context + q_result = sb.table("paper_questions").select("*").eq("id", question_id).execute() + if not q_result.data: + raise HTTPException(status_code=404, detail="Question not found") + question = q_result.data[0] + + # 5. AI grading (run in thread pool) + grade_result = await asyncio.to_thread(grade_answer, question, ocr_text) + + # 6. Save attempt + record = { + "user_id": user_id, + "question_id": question_id, + "attempt_type": "photo", + "photo_url": photo_url, + "photo_ocr_text": ocr_text, + "is_correct": grade_result.get("is_correct", False), + "feedback": grade_result.get("feedback", ""), + "error_at_step": grade_result.get("error_at_step"), + "in_error_book": not grade_result.get("is_correct", False), + } + result = sb.table("user_attempts").insert(record).execute() + + return { + "attempt": result.data[0], + "ocr_text": ocr_text, + "grade": grade_result, + } + + +@router.get("/error-book") +async def get_error_book( + course_code: str | None = None, + user_id: str = Depends(get_current_user_id), +): + """获取错题本""" + sb = get_supabase() + attempts = ( + sb.table("user_attempts") + .select("*") + .eq("user_id", user_id) + .eq("in_error_book", True) + .eq("mastered", False) + .order("created_at", desc=True) + .execute() + .data + ) + if not attempts: + return [] + + question_ids = list({attempt["question_id"] for attempt in attempts}) + questions = ( + sb.table("paper_questions") + .select("*") + .in_("id", question_ids) + .execute() + .data + ) + questions_by_id = {question["id"]: question for question in questions} + + paper_ids = list({question["paper_id"] for question in questions}) + papers = ( + sb.table("papers") + .select("id, course_code, year, term, exam_type, part_label") + .in_("id", paper_ids) + .execute() + .data + ) + papers_by_id = {paper["id"]: paper for paper in papers} + + enriched = [] + for attempt in attempts: + question = questions_by_id.get(attempt["question_id"]) + if not question: + continue + paper = papers_by_id.get(question["paper_id"]) + if course_code and paper and paper.get("course_code") != course_code.upper(): + continue + + enriched.append( + { + **attempt, + "paper_questions": { + **question, + "paper": paper, + }, + } + ) + return enriched + + +@router.get("/by-paper/{paper_id}") +async def get_paper_attempts(paper_id: str, user_id: str = Depends(get_current_user_id)): + """获取某张试卷所有题目的最新判卷记录""" + sb = get_supabase() + attempts = ( + sb.table("user_attempts") + .select("question_id, is_correct, feedback, photo_ocr_text, attempt_type, created_at") + .eq("user_id", user_id) + .order("created_at", desc=True) + .execute() + .data + ) + # 只保留 photo 类型的,且只保留每题最新一条 + question_ids = ( + sb.table("paper_questions") + .select("id") + .eq("paper_id", paper_id) + .execute() + .data + ) + qid_set = {q["id"] for q in question_ids} + seen: set[str] = set() + result = [] + for a in attempts: + if a["question_id"] not in qid_set: + continue + if a["question_id"] in seen: + continue + if a["attempt_type"] != "photo": + continue + seen.add(a["question_id"]) + result.append(a) + return result + + +@router.patch("/{attempt_id}") +async def update_attempt(attempt_id: str, data: AttemptUpdate): + """更新错题状态(标记掌握等)""" + sb = get_supabase() + update = {} + if data.in_error_book is not None: + update["in_error_book"] = data.in_error_book + if data.mastered is not None: + update["mastered"] = data.mastered + if not update: + raise HTTPException(status_code=400, detail="Nothing to update") + + result = sb.table("user_attempts").update(update).eq("id", attempt_id).execute() + if not result.data: + raise HTTPException(status_code=404, detail="Attempt not found") + return result.data[0] diff --git a/backend/app/routers/papers.py b/backend/app/routers/papers.py new file mode 100644 index 0000000..feda573 --- /dev/null +++ b/backend/app/routers/papers.py @@ -0,0 +1,142 @@ +"""试卷上传 + 处理管线""" + +import asyncio +import threading +from fastapi import APIRouter, UploadFile, File, Form, HTTPException, Depends +from app.services.supabase_client import get_supabase +from app.services.text_extractor import extract_pdf, get_full_text +from app.services.paper_processor import process_paper +from app.dependencies.auth import get_current_user_id + +router = APIRouter() + + +def _upload_and_process_sync( + paper_id: str, + storage_path: str, + paper_bytes: bytes, + answer_bytes: bytes | None, +): + """在独立线程中运行:Storage 上传 + AI 处理""" + sb = get_supabase() + try: + paper_storage_path = f"{storage_path}/paper.pdf" + sb.storage.from_("papers").upload( + paper_storage_path, paper_bytes, + file_options={"content-type": "application/pdf", "upsert": "true"}, + ) + paper_url = sb.storage.from_("papers").get_public_url(paper_storage_path) + + update_data: dict = {"paper_file_url": paper_url} + + if answer_bytes: + answer_storage_path = f"{storage_path}/answer.pdf" + sb.storage.from_("papers").upload( + answer_storage_path, answer_bytes, + file_options={"content-type": "application/pdf", "upsert": "true"}, + ) + update_data["answer_file_url"] = sb.storage.from_("papers").get_public_url(answer_storage_path) + + sb.table("papers").update(update_data).eq("id", paper_id).execute() + except Exception: + pass + + # process_paper 是 async,在新事件循环里跑 + asyncio.run(process_paper(paper_id, paper_bytes, answer_bytes)) + + +@router.get("/") +async def list_papers(): + """获取试卷列表(公共资产,所有用户共享)""" + sb = get_supabase() + return ( + sb.table("papers") + .select("id, course_code, year, term, exam_type, status, question_count, total_score, difficulty_level, processing_step, processing_progress, processing_total, created_at") + .order("created_at", desc=True) + .execute() + .data + ) + + +@router.get("/mine") +async def my_papers(user_id: str = Depends(get_current_user_id)): + """当前用户上传的试卷(含 processing 状态)""" + sb = get_supabase() + return ( + sb.table("papers") + .select("id, course_code, year, term, exam_type, part_label, status, question_count, processing_step, processing_progress, processing_total, created_at") + .eq("user_id", user_id) + .order("created_at", desc=True) + .execute() + .data + ) + + +@router.post("/upload") +async def upload_paper( + paper_file: UploadFile = File(...), + answer_file: UploadFile | None = File(None), + course_code: str = Form(...), + year: int = Form(...), + term: str = Form(...), + exam_type: str = Form(...), + user_id: str = Depends(get_current_user_id), +): + """上传试卷 PDF(可选答案 PDF),触发后台处理""" + sb = get_supabase() + + # 1. 读取文件内容(已在内存中,快) + paper_bytes = await paper_file.read() + answer_bytes = await answer_file.read() if answer_file else None + + # 2. 立即创建记录(status=processing),马上返回 + storage_path = f"{course_code.upper()}/{year}_{term}_{exam_type}" + paper_record = sb.table("papers").insert({ + "user_id": user_id, + "course_code": course_code.upper(), + "year": year, + "term": term, + "exam_type": exam_type, + "paper_file_url": "", # 后台上传后更新 + "answer_file_url": None, + "status": "processing", + }).execute() + + paper_id = paper_record.data[0]["id"] + + # 3. 在独立线程中运行,完全不阻塞事件循环 + threading.Thread( + target=_upload_and_process_sync, + args=(paper_id, storage_path, paper_bytes, answer_bytes), + daemon=True, + ).start() + + return { + "paper_id": paper_id, + "status": "processing", + "message": "试卷已上传,正在处理中...", + } + + +@router.get("/{paper_id}") +async def get_paper(paper_id: str): + """获取试卷信息 + 处理状态""" + sb = get_supabase() + result = sb.table("papers").select("*").eq("id", paper_id).execute() + if not result.data: + raise HTTPException(status_code=404, detail="Paper not found") + return result.data[0] + + +@router.get("/{paper_id}/questions") +async def get_questions(paper_id: str): + """获取试卷的所有题目(含 AI 三件套)""" + sb = get_supabase() + result = ( + sb.table("paper_questions") + .select("*") + .eq("paper_id", paper_id) + .order("display_order") + .execute() + ) + return result.data diff --git a/backend/app/routers/questions.py b/backend/app/routers/questions.py new file mode 100644 index 0000000..b61d288 --- /dev/null +++ b/backend/app/routers/questions.py @@ -0,0 +1,325 @@ +"""题目相关:变式题生成 + 相似题召回""" + +from __future__ import annotations + +import asyncio +import time +from fastapi import APIRouter, HTTPException, Depends +from pydantic import BaseModel +from app.services.supabase_client import get_supabase +from app.services.grader import generate_variant +from app.dependencies.auth import get_current_user_id + +# Simple in-memory cache: question_id → (timestamp, result) +_similar_cache: dict[str, tuple[float, list]] = {} +_CACHE_TTL = 300 # 5 minutes + + +class VariantUpdate(BaseModel): + favorited: bool | None = None + +router = APIRouter() + + +def normalized_labels(values: list[str] | None) -> dict[str, str]: + labels: dict[str, str] = {} + for value in values or []: + if value: + labels[value.lower()] = value + return labels + + +def question_family(question: dict) -> str: + return question.get("question_format") or question.get("question_type") or "unknown" + + +def display_topics(question: dict) -> list[str]: + labels: list[str] = [] + analytics_topic = question.get("analytics_topic") + if analytics_topic: + labels.append(analytics_topic) + for topic in question.get("topic_tags") or []: + if topic and topic not in labels: + labels.append(topic) + if labels: + return labels + for topic in question.get("topics") or []: + if topic and topic not in labels: + labels.append(topic) + return labels + + +def similarity_score( + target: dict, + candidate: dict, + text_score: float = 0.0, +) -> tuple[int, list[str]]: + score = 0 + reasons: list[str] = [] + + # Primary topic bucket: 40 pts + target_topic = target.get("analytics_topic") + candidate_topic = candidate.get("analytics_topic") + if target_topic and target_topic == candidate_topic: + score += 40 + reasons.append(f"Same topic: {target_topic}") + + # Concept overlap: up to 20 pts + target_topics = normalized_labels(target.get("topic_tags")) + candidate_topics = normalized_labels(candidate.get("topic_tags")) + shared_topics = sorted(set(target_topics) & set(candidate_topics)) + if shared_topics: + score += min(len(shared_topics) * 10, 20) + # Only show concept reason if analytics_topic didn't already match (avoid redundancy) + if not (target_topic and target_topic == candidate_topic): + reasons.append( + "Shared concept: " + + ", ".join(target_topics[key] for key in shared_topics[:2]) + ) + + # Skill overlap: up to 20 pts + target_skills = normalized_labels(target.get("skill_tags")) + candidate_skills = normalized_labels(candidate.get("skill_tags")) + shared_skills = sorted(set(target_skills) & set(candidate_skills)) + if shared_skills: + score += min(len(shared_skills) * 10, 20) + reasons.append( + "Shared skill: " + + ", ".join(target_skills[key] for key in shared_skills[:2]) + ) + + # Same question format: 10 pts + if question_family(candidate) == question_family(target): + score += 10 + reasons.append("Same format") + + # Same difficulty: 5 pts + if candidate.get("difficulty") and candidate.get("difficulty") == target.get("difficulty"): + score += 5 + reasons.append("Same difficulty") + + # Full-text similarity from PostgreSQL ts_rank_cd: up to 20 pts + if text_score > 0: + text_pts = min(round(text_score * 60), 20) + score += text_pts + if text_pts >= 4: + reasons.append("Similar wording") + + return min(score, 99), reasons + + +@router.get("/variants/favorited") +async def get_favorited_variants(user_id: str = Depends(get_current_user_id)): + """获取用户收藏的所有 variant(用于 Error Book)""" + sb = get_supabase() + rows = ( + sb.table("question_variants") + .select("*, paper_questions(question_number, paper_id, papers(id, course_code, year, term, exam_type, part_label))") + .eq("user_id", user_id) + .eq("favorited", True) + .order("created_at", desc=True) + .execute() + .data + ) + return rows + + +@router.post("/{question_id}/variant") +async def create_variant(question_id: str, user_id: str = Depends(get_current_user_id)): + """生成变式题并入库""" + sb = get_supabase() + result = sb.table("paper_questions").select("*").eq("id", question_id).execute() + if not result.data: + raise HTTPException(status_code=404, detail="Question not found") + + question = result.data[0] + variant_data = await asyncio.to_thread(generate_variant, question) + variant_data["knowledge_reminder"] = question.get("knowledge_reminder", "") + + saved = sb.table("question_variants").insert({ + "user_id": user_id, + "source_question_id": question_id, + "variant_data": variant_data, + "favorited": False, + }).execute() + + row = saved.data[0] + row["source_question_number"] = question["question_number"] + return row + + +@router.get("/{question_id}/variants") +async def list_variants(question_id: str, user_id: str = Depends(get_current_user_id)): + """获取某道题的用户所有 variant""" + sb = get_supabase() + q_result = sb.table("paper_questions").select("question_number").eq("id", question_id).execute() + question_number = q_result.data[0]["question_number"] if q_result.data else "" + + rows = ( + sb.table("question_variants") + .select("*") + .eq("user_id", user_id) + .eq("source_question_id", question_id) + .order("created_at", desc=True) + .execute() + .data + ) + for row in rows: + row["source_question_number"] = question_number + return rows + + +@router.patch("/variant/{variant_id}") +async def update_variant(variant_id: str, data: VariantUpdate, user_id: str = Depends(get_current_user_id)): + """更新 variant(收藏/取消收藏)""" + sb = get_supabase() + update: dict = {} + if data.favorited is not None: + update["favorited"] = data.favorited + if not update: + raise HTTPException(status_code=400, detail="Nothing to update") + + result = ( + sb.table("question_variants") + .update(update) + .eq("id", variant_id) + .eq("user_id", user_id) + .execute() + ) + if not result.data: + raise HTTPException(status_code=404, detail="Variant not found") + return result.data[0] + + +@router.delete("/variant/{variant_id}", status_code=204) +async def delete_variant(variant_id: str, user_id: str = Depends(get_current_user_id)): + """删除 variant""" + sb = get_supabase() + sb.table("question_variants").delete().eq("id", variant_id).eq("user_id", user_id).execute() + + +@router.get("/{question_id}/similar") +async def get_similar_questions(question_id: str, limit: int = 6): + """Retrieve similar questions from the same course.""" + # Cache hit + cached = _similar_cache.get(question_id) + if cached and (time.time() - cached[0]) < _CACHE_TTL: + return cached[1][:max(1, min(limit, 12))] + + sb = get_supabase() + result = sb.table("paper_questions").select("*, similar_questions").eq("id", question_id).execute() + if not result.data: + raise HTTPException(status_code=404, detail="Question not found") + + target = result.data[0] + + # Return pre-computed immediately; schedule background refresh + if target.get("similar_questions"): + precomputed = target["similar_questions"] + _similar_cache[question_id] = (time.time(), precomputed) + return precomputed[:max(1, min(limit, 12))] + + paper_result = sb.table("papers").select("id, course_code").eq("id", target["paper_id"]).execute() + # (fallback: compute on-the-fly for questions not yet backfilled) + if not paper_result.data: + raise HTTPException(status_code=404, detail="Paper not found") + + course_code = paper_result.data[0]["course_code"] + papers = ( + sb.table("papers") + .select("id, course_code, year, term, exam_type, part_label") + .eq("course_code", course_code) + .eq("status", "ready") + .execute() + .data + ) + paper_ids = [paper["id"] for paper in papers if paper["id"] != target["paper_id"]] + if not paper_ids: + return [] + + papers_by_id = {paper["id"]: paper for paper in papers} + + # Pre-filter by analytics_topic in DB when possible (cuts candidates from ~250 to ~30) + candidates_query = ( + sb.table("paper_questions") + .select( + "id, paper_id, question_number, question_type, question_format, " + "question_text, score, topics, analytics_topic, topic_tags, skill_tags, " + "difficulty, knowledge_reminder, ai_hint, solution" + ) + .in_("paper_id", paper_ids) + ) + target_topic = target.get("analytics_topic") + if target_topic: + candidates_query = candidates_query.eq("analytics_topic", target_topic) + + candidates = candidates_query.execute().data + if not candidates: + return [] + + # Batch full-text scores from PostgreSQL (skip if too many candidates — slow) + text_scores: dict[str, float] = {} + if len(candidates) <= 50: + try: + rpc_result = sb.rpc( + "text_similarity_scores", + { + "query_text": target.get("question_text") or "", + "candidate_ids": [c["id"] for c in candidates], + }, + ).execute() + for row in rpc_result.data or []: + text_scores[row["question_id"]] = float(row["text_score"] or 0) + except Exception: + pass + + ranked = [] + for candidate in candidates: + text_score = text_scores.get(candidate["id"], 0.0) + match_percent, reasons = similarity_score(target, candidate, text_score) + if match_percent < 20: + continue + paper = papers_by_id.get(candidate["paper_id"], {}) + source = ( + f"{paper.get('year', '')} {paper.get('term', '').title()} " + f"{paper.get('exam_type', '').title()}" + ).strip() + if paper.get("part_label"): + source = f"{source} Part {paper['part_label']}" + ranked.append( + { + "id": candidate["id"], + "paper_id": candidate["paper_id"], + "source": source, + "question_number": candidate["question_number"], + "match_percent": match_percent, + "match_reasons": reasons, + "question_type": question_family(candidate), + "question_text": candidate["question_text"], + "topics": display_topics(candidate), + "difficulty": candidate.get("difficulty"), + "knowledge_reminder": candidate.get("knowledge_reminder", ""), + "ai_hint": candidate.get("ai_hint", ""), + "solution": candidate.get("solution", ""), + } + ) + + ranked.sort(key=lambda item: (-item["match_percent"], item["source"], item["question_number"])) + + # Keep only the best-scoring question per paper + seen_papers: set[str] = set() + deduped = [] + for item in ranked: + if item["paper_id"] not in seen_papers: + seen_papers.add(item["paper_id"]) + deduped.append(item) + + _similar_cache[question_id] = (time.time(), deduped) + + # Persist to DB so future requests are instant + try: + sb.table("paper_questions").update({"similar_questions": deduped}).eq("id", question_id).execute() + except Exception: + pass + + return deduped[:max(1, min(limit, 12))] diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/grader.py b/backend/app/services/grader.py new file mode 100644 index 0000000..68e58f4 --- /dev/null +++ b/backend/app/services/grader.py @@ -0,0 +1,146 @@ +"""OCR, grading, and variant generation prompts""" + +import json +import base64 +from app.services.llm_clients import get_vision_client, get_deepseek_client + +OCR_PROMPT = """You are an expert at recognizing handwritten answers. Analyze this photo of a student's handwritten answer and extract the text and mathematical formulas. + +Requirements: +- Faithfully extract what the student wrote, do not modify or correct +- Use LaTeX format for math formulas (e.g. $x^2 + 1$) +- If there are multiple steps, list them in original order +- If some handwriting is unclear, mark with [unclear] + +Return only the extracted text, no additional explanation.""" + +GRADING_PROMPT = """You are an expert academic grader. Grade the following student answer. ALL output must be in English. + +Question info: +- Number: {question_number} +- Type: {question_type} +- Question: {question_text} +- Score: {score} + +Reference answer / solution: +{reference_answer} + +Student answer: +{student_answer} + +Grade and return JSON: +{{ + "is_correct": true/false, + "score_given": 0-{score}, + "feedback": " Step-by-step analysis of the student's answer, pointing out correct parts and errors, using KaTeX formulas ", + "error_at_step": null or the step number where errors begin (integer) +}} + +Grading rules: +- MC / fill-blank: only correct if answer matches exactly +- Long questions: give partial credit for correct steps even if the final answer is wrong +- feedback in HTML format, supports KaTeX ($..$ inline, $$...$$ block) +- Mark errors with
...
+- Identify exactly which step the error starts""" + +VARIANT_PROMPT = """You are an expert exam question creator. Generate a similar but different variant question based on the original below. ALL output must be in English. + +Original question info: +- Type: {question_type} +- Question: {question_text} +- Topics: {topics} +- Difficulty: {difficulty} +- Reference answer: {answer} + +Requirements: +- Variant must test the same knowledge points at similar difficulty +- Data/scenario/wording must differ — don't just change numbers +- Must provide a complete correct answer + +Format requirements (CRITICAL): +- All text in HTML format, absolutely NO markdown syntax +- Code:
...
, NOT ``` +- Math: $...$ (inline) or $$...$$ (block), KaTeX compatible +- Line breaks:
, paragraphs:

+ +Return JSON: +{{ + "question_text": "HTML formatted variant question", + "question_type": "{question_type}", + "options": [MC only, format {{"label":"A","text":"..."}}, ...] or null, + "correct_answer": "Correct answer (plain text)", + "ai_hint": "HTML formatted hint that guides thinking WITHOUT giving the answer", + "solution": "HTML formatted complete step-by-step solution" +}}""" + + +def ocr_photo(photo_bytes: bytes) -> str: + """Gemini Vision OCR for handwritten answers""" + client = get_vision_client() + b64 = base64.b64encode(photo_bytes).decode("utf-8") + + resp = client.chat.completions.create( + model="gemini-2.5-flash", + messages=[ + {"role": "system", "content": OCR_PROMPT}, + {"role": "user", "content": [ + {"type": "image_url", "image_url": { + "url": f"data:image/jpeg;base64,{b64}", + }}, + ]}, + ], + temperature=0, + max_tokens=2000, + ) + return resp.choices[0].message.content or "" + + +def grade_answer(question: dict, student_answer: str) -> dict: + """Qwen grades student answer""" + reference = question.get("raw_answer_text") or question.get("solution") or "No reference answer" + score = question.get("score") or "unknown" + + ds = get_deepseek_client() + resp = ds.chat.completions.create( + model="deepseek-chat", + messages=[ + {"role": "system", "content": GRADING_PROMPT.format( + question_number=question["question_number"], + question_type=question["question_type"], + question_text=question["question_text"], + score=score, + reference_answer=reference, + student_answer=student_answer, + )}, + ], + temperature=0.2, + response_format={"type": "json_object"}, + ) + return json.loads(resp.choices[0].message.content) + + +def generate_variant(question: dict) -> dict: + """Gemini generates a variant question""" + answer = ( + question.get("correct_option") + or question.get("correct_answer") + or question.get("raw_answer_text") + or "N/A" + ) + + ds = get_deepseek_client() + resp = ds.chat.completions.create( + model="deepseek-chat", + messages=[ + {"role": "system", "content": VARIANT_PROMPT.format( + question_type=question["question_type"], + question_text=question["question_text"], + topics=", ".join(question.get("topics", [])), + difficulty=question.get("difficulty", "medium"), + answer=answer, + )}, + ], + temperature=0.5, + response_format={"type": "json_object"}, + ) + return json.loads(resp.choices[0].message.content) diff --git a/backend/app/services/llm_clients.py b/backend/app/services/llm_clients.py new file mode 100644 index 0000000..690637b --- /dev/null +++ b/backend/app/services/llm_clients.py @@ -0,0 +1,74 @@ +import httpx +from openai import OpenAI +from app.config import get_settings + +_TIMEOUT = httpx.Timeout(connect=10, read=300, write=60, pool=10) + +_gpt_client: OpenAI | None = None +_qwen_client: OpenAI | None = None +_gemini_flash_client: OpenAI | None = None +_gemini_lite_client: OpenAI | None = None +_deepseek_client: OpenAI | None = None + + +def get_gpt_client() -> OpenAI: + """laozhang API — gpt-4o / gpt-4o-mini""" + global _gpt_client + if _gpt_client is None: + s = get_settings() + _gpt_client = OpenAI( + base_url=s.laozhang_base_url, + api_key=s.laozhang_api_key, + ) + return _gpt_client + + +def get_qwen_client() -> OpenAI: + """DashScope — qwen-plus""" + global _qwen_client + if _qwen_client is None: + s = get_settings() + _qwen_client = OpenAI( + base_url=s.dashscope_base_url, + api_key=s.dashscope_api_key, + ) + return _qwen_client + + +def get_vision_client() -> OpenAI: + """Google Gemini 官方 API(视觉,用于拆题+OCR)— 部署在新加坡可用""" + global _gemini_flash_client + if _gemini_flash_client is None: + s = get_settings() + _gemini_flash_client = OpenAI( + base_url="https://generativelanguage.googleapis.com/v1beta/openai/", + api_key=s.google_gemini_api_key, + timeout=_TIMEOUT, + ) + return _gemini_flash_client + + +def get_gemini_lite_client() -> OpenAI: + """laozhang — gemini-3.1-flash-lite-preview(轻量,用于 AI trio)""" + global _gemini_lite_client + if _gemini_lite_client is None: + s = get_settings() + _gemini_lite_client = OpenAI( + base_url=s.laozhang_base_url, + api_key=s.laozhang_api_key, + timeout=_TIMEOUT, + ) + return _gemini_lite_client + + +def get_deepseek_client() -> OpenAI: + """DeepSeek — deepseek-chat(用于 AI trio)""" + global _deepseek_client + if _deepseek_client is None: + s = get_settings() + _deepseek_client = OpenAI( + base_url=s.deepseek_base_url, + api_key=s.deepseek_api_key, + timeout=_TIMEOUT, + ) + return _deepseek_client diff --git a/backend/app/services/paper_processor.py b/backend/app/services/paper_processor.py new file mode 100644 index 0000000..86dc4e2 --- /dev/null +++ b/backend/app/services/paper_processor.py @@ -0,0 +1,576 @@ +"""试卷处理管线:PDF → 结构化题目 → AI 三件套(Vision 模式)""" + +import asyncio +import base64 +import io +import json +import re +import traceback +from contextlib import redirect_stdout +import fitz # pymupdf +from app.services.supabase_client import get_supabase +from app.services.llm_clients import get_vision_client, get_deepseek_client + + +def strip_nulls(obj): + """Recursively remove \\u0000 null bytes from strings (PostgreSQL rejects them).""" + if isinstance(obj, str): + return obj.replace("\u0000", "") + if isinstance(obj, dict): + return {k: strip_nulls(v) for k, v in obj.items()} + if isinstance(obj, list): + return [strip_nulls(i) for i in obj] + return obj + + +# ============================================ +# Prompts +# ============================================ + +STRUCTURE_PROMPT = """You are an expert exam paper structure analyst. You are given images of a past exam paper. Analyze every page carefully and extract all questions into structured JSON. +All generated values must be in English. Do not output Chinese. + +CRITICAL RULES for question_text: +- Each question's question_text must be FULLY SELF-CONTAINED. Include ALL context needed to solve it. +- For sub-questions (e.g. (a)(i)), copy the ENTIRE parent question setup (variable definitions, code blocks, problem description) into the question_text, then append the specific sub-question. +- For Python/code questions: include ALL variable definitions and import statements verbatim, exactly as they appear in the exam, preserving multi-line arrays and data structures completely. +- Never truncate code. If a variable is defined across multiple lines (e.g. a numpy array), include every line. + +Output JSON format (strictly follow): +{ + "total_score": 100, + "difficulty_level": "medium", + "topics_summary": {"Topic A": 40, "Topic B": 30, "Topic C": 30}, + "questions": [ + { + "question_number": "1a", + "parent_question": "1", + "question_type": "mc", + "question_text": "Original question text...", + "score": 5, + "page_number": 1, + "options": [{"label": "A", "text": "Option content"}, {"label": "B", "text": "..."}], + "topics": ["Linked List", "Pointer"], + "difficulty": "easy" + }, + { + "question_number": "2", + "parent_question": null, + "question_type": "long_question", + "question_text": "Original question text...", + "score": 15, + "page_number": 2, + "options": null, + "topics": ["Recursion"], + "difficulty": "hard" + } + ] +} + +Rules: +- question_type must be one of: "mc" (multiple choice), "true_false" (true/false), "fill_blank" (fill in blank), "long_question" (long question) +- True/False questions MUST use "true_false" type, with options set to [{"label":"True","text":"True"},{"label":"False","text":"False"}], correct_option as "True" or "False" +- Multiple choice must extract the options array +- Sub-questions use parent_question to link to parent: "1a" parent is "1" +- Independent questions without sub-questions set parent_question to null +- page_number inferred from where the question appears +- topics inferred from the question content +- difficulty: "easy" | "medium" | "hard" +- Extract ALL questions, do not miss any +- Keep topic labels in English only +""" + +ANSWER_MATCH_PROMPT = """You are an expert exam answer matching specialist. Below is the answer text for an exam paper. Extract and match answers to their corresponding question numbers. +All generated values must be in English. Do not output Chinese. + +Question structure: +{questions_json} + +Answer text: +{answer_text} + +Output JSON format: +{{ + "answers": [ + {{ + "question_number": "1a", + "correct_option": "B", + "correct_answer": null, + "raw_answer_text": "Original answer text..." + }}, + {{ + "question_number": "2", + "correct_option": null, + "correct_answer": null, + "raw_answer_text": "Complete solution process and answer..." + }} + ] +}} + +Rules: +- For MC questions, fill correct_option (e.g. "B") +- For fill-blank questions, fill correct_answer (e.g. "O(n log n)") +- For long questions, only fill raw_answer_text (complete solution process) +- Match all questions where answers can be found +- Keep raw_answer_text faithful to the source answer, but do not add Chinese commentary +""" + +ANALYSIS_PROMPT = """You are an expert academic answer analyst. Generate three sections for the following exam question. ALL output must be in English. + +Question info: +- Number: {question_number} +- Type: {question_type} +- Score: {score} +- Question: {question_text} +- Topics: {topics} +{answer_section} + +Generate THREE sections in HTML format (supports KaTeX: block $$ ... $$ inline $ ... $): + +Output JSON: +{{ + "knowledge_reminder": " Prerequisite knowledge points needed for this question, as a concise bullet list ", + "ai_hint": " A hint that guides thinking direction WITHOUT giving away the answer ", + "solution": " Complete step-by-step solution (Step 1, Step 2, ...) with derivations, formulas, and common mistake warnings " +}} + +Solution requirements: +- Must include complete working process, not just the answer +- Each step must have an explanation +- If a reference answer is provided, derive the solution based on it +- If no reference answer, work out the complete solution independently +- For MC questions, explain why the correct option is right AND why others are wrong +- Use

    or numbered steps +- Mark common mistakes with
    ...
    + +KaTeX formula rules: +- Block formula: $$ on its own line, with blank lines before and after +- Inline formula: $x^2$ no line break +- Matrix: \\begin{{bmatrix}} ... \\end{{bmatrix}} +- Fraction: \\frac{{a}}{{b}} +""" + +BATCH_ANALYSIS_PROMPT = """You are an expert academic answer analyst. Generate three study sections for each question below. ALL output must be in English. + +For every question, return: +- knowledge_reminder: concise prerequisite bullets in HTML +- ai_hint: a helpful hint in HTML without revealing the final answer +- solution: a complete step-by-step solution in HTML + +Return JSON in this exact format: +{{ + "analyses": [ + {{ + "question_number": "1a", + "knowledge_reminder": "...", + "ai_hint": "...", + "solution": "..." + }} + ] +}} + +Rules: +- Return one item for every provided question_number +- Keep each item matched to the same question_number +- All text must be in English +- HTML only, KaTeX compatible +- For MC questions, explain why the correct option is right and why the others are wrong +- For long questions, show a complete derivation or reasoning chain +- Use
      or numbered steps in solution when appropriate +- Mark common mistakes with
      ...
      +- CRITICAL: When a question_text contains "[Context from parent question X]" followed by "[Sub-question Y]", the parent section is background context only. You MUST solve ONLY the specific sub-question labeled [Sub-question Y]. Do NOT solve other sub-questions listed in the parent context. Give one precise answer for that single sub-question only. + +Questions: +{questions_payload} +""" + + +# ============================================ +# 处理管线 +# ============================================ + +RETRYABLE_ERROR_MARKERS = ( + "429", + "rate limit", + "rate_limit", + "too many requests", + "timeout", + "timed out", + "connection", +) + + +def is_retryable_error(exc: Exception) -> bool: + message = str(exc).lower() + return any(marker in message for marker in RETRYABLE_ERROR_MARKERS) + + +def pdf_to_images(pdf_bytes: bytes, dpi: int = 96) -> list[str]: + """将 PDF 每页渲染为 base64 PNG 图片列表(96dpi 平衡清晰度与成本)""" + doc = fitz.open(stream=pdf_bytes, filetype="pdf") + images = [] + mat = fitz.Matrix(dpi / 72, dpi / 72) + for page in doc: + pix = page.get_pixmap(matrix=mat, colorspace=fitz.csRGB) + img_bytes = pix.tobytes("png") + images.append(base64.b64encode(img_bytes).decode()) + doc.close() + return images + + +def parse_json_response(text: str) -> dict: + """解析模型返回的 JSON,兼容 markdown 代码块包装""" + text = text.strip() + # 去掉 ```json ... ``` 包装 + if text.startswith("```"): + lines = text.splitlines() + text = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:]) + # 移除 JSON 字符串中的非法控制字符(0x00-0x1F 除了 \t \n \r) + text = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f]', '', text) + # 修复模型返回的无效 JSON 转义序列:只修奇数个反斜杠后的非法字符 + text = re.sub(r'(? dict: + """发送图片 + prompt 给 Gemini vision 模型,返回 JSON""" + client = get_vision_client() + delay_seconds = 2 + + content: list = [] + for b64 in images: + content.append({"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}}) + if user_text: + content.append({"type": "text", "text": user_text}) + + for attempt in range(1, max_attempts + 1): + try: + response = client.chat.completions.create( + model="gemini-2.5-flash", + messages=[ + {"role": "system", "content": system_prompt + "\n\nIMPORTANT: Your entire response must be valid JSON only. No markdown, no code fences, no extra text."}, + {"role": "user", "content": content}, + ], + temperature=temperature, + max_tokens=16384, + ) + return parse_json_response(response.choices[0].message.content) + except Exception as exc: + if attempt == max_attempts or not is_retryable_error(exc): + raise + await asyncio.sleep(delay_seconds) + delay_seconds = min(delay_seconds * 2, 30) + + +async def deepseek_json_completion( + *, + system_prompt: str, + user_prompt: str | None = None, + temperature: float = 0, + max_attempts: int = 6, +) -> dict: + """DeepSeek 纯文本 JSON completion(用于 AI trio 生成)""" + client = get_deepseek_client() + delay_seconds = 2 + + for attempt in range(1, max_attempts + 1): + try: + messages = [{"role": "system", "content": system_prompt}] + if user_prompt: + messages.append({"role": "user", "content": user_prompt}) + + response = client.chat.completions.create( + model="deepseek-chat", + messages=messages, + temperature=temperature, + max_tokens=8192, + response_format={"type": "json_object"}, + ) + raw = response.choices[0].message.content + raw = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f]', '', raw) + raw = re.sub(r'(? list[list[dict]]: + return [items[i:i + size] for i in range(0, len(items), size)] + + +def _question_sort_key(qnum: str) -> tuple: + """自然排序题号:1a < 1b < ... < 1i < 1j < 2ai < 2aii < 10a""" + parts = re.findall(r'(\d+|[a-zA-Z]+|[()]+)', qnum) + key = [] + for idx, p in enumerate(parts): + if p.isdigit(): + key.append((0, int(p), '')) + elif p in ('(', ')'): + continue + else: + # Single letter (a-z): always sort alphabetically (a=1, b=2, ..., j=10) + if len(p) == 1 and p.isalpha(): + key.append((1, ord(p.lower()) - ord('a') + 1, p)) + else: + # Multi-letter: roman numerals for sub-sub-questions (i=1, ii=2, iii=3, ...) + romans = {'i':1,'ii':2,'iii':3,'iv':4,'v':5,'vi':6,'vii':7,'viii':8,'ix':9,'x':10,'xi':11,'xii':12,'xiii':13} + if p.lower() in romans: + key.append((2, romans[p.lower()], p)) + else: + key.append((1, 0, p)) + return tuple(key) + + +def sort_questions(questions: list[dict]) -> list[dict]: + """按题号自然排序""" + return sorted(questions, key=lambda q: _question_sort_key(q.get("question_number", ""))) + + +def extract_code_block(text: str) -> str: + """ + 从题目文本中提取 Python 代码块。 + 策略:找到第一个明确的代码起始行(import/赋值/print), + 然后把后续所有缩进或延续行一并带上,直到明显的非代码段落。 + """ + lines = text.splitlines() + result = [] + in_code = False + open_brackets = 0 + + CODE_START = re.compile(r"^\s*(import |from \w|[A-Za-z_]\w*\s*=|print\()") + + for line in lines: + stripped = line.strip() + + # 已在代码块内:括号未闭合时继续收集 + if in_code and open_brackets > 0: + result.append(stripped) + open_brackets += stripped.count("(") + stripped.count("[") + stripped.count("{") + open_brackets -= stripped.count(")") + stripped.count("]") + stripped.count("}") + continue + + # 检测新的代码起始行 + if CODE_START.match(line): + in_code = True + result.append(stripped) + open_brackets += stripped.count("(") + stripped.count("[") + stripped.count("{") + open_brackets -= stripped.count(")") + stripped.count("]") + stripped.count("}") + continue + + # 非代码行:重置(但保留 in_code=True 以便继续接后续代码行) + in_code = False + + return "\n".join(result) + + +# 保持向后兼容 +extract_code_lines = extract_code_block + + +def try_exec_python(code: str, shared_ns: dict) -> str | None: + """ + 在 shared_ns 命名空间中执行 code,捕获 stdout。 + 返回输出字符串,失败返回 None。 + """ + buf = io.StringIO() + try: + with redirect_stdout(buf): + exec(code, shared_ns) # noqa: S102 + output = buf.getvalue().strip() + return output if output else None + except Exception: + return None + +async def _resume_ai_trio(sb, paper_id: str, questions: list[dict]): + """为缺 solution 的题目生成 AI trio,逐条写回 DB。支持断点续传。""" + need = [q for q in questions if not q.get("solution")] + if not need: + # 全部已有 solution,直接标记完成 + sb.table("papers").update({"status": "ready", "processing_step": None}).eq("id", paper_id).execute() + return + + total_q = len(questions) + done_q = total_q - len(need) + + # 构建 payload + id_map = {q["question_number"]: q["id"] for q in need} + # 需要完整的 question_text 来生成 AI trio + full_data = sb.table("paper_questions").select( + "id, question_number, question_type, question_text, score, correct_option, correct_answer, raw_answer_text" + ).eq("paper_id", paper_id).in_("id", [q["id"] for q in need]).execute().data + + payloads = [] + for q in full_data: + answer_section = q.get("raw_answer_text") or "" + if not answer_section and q.get("correct_option"): + answer_section = f"Correct option: {q['correct_option']}" + elif not answer_section and q.get("correct_answer"): + answer_section = f"Correct answer: {q['correct_answer']}" + payloads.append({ + "question_number": q["question_number"], + "question_type": q["question_type"] or "long_question", + "score": q.get("score") or "unknown", + "question_text": q["question_text"] or "", + "reference_answer": answer_section, + }) + + batches = chunked(payloads, 3) + for batch_idx, batch in enumerate(batches, 1): + current = done_q + batch_idx * 3 + _update_progress(sb, paper_id, f"Generating solutions ({min(current, total_q)}/{total_q} questions)", batch_idx, len(batches)) + try: + result = await deepseek_json_completion( + system_prompt=BATCH_ANALYSIS_PROMPT.format( + questions_payload=json.dumps(batch, ensure_ascii=False), + ), + temperature=0.3, + ) + for item in result.get("analyses", []): + qnum = item.get("question_number") + qid = id_map.get(qnum) + if qid: + sb.table("paper_questions").update({ + "knowledge_reminder": item.get("knowledge_reminder", ""), + "ai_hint": item.get("ai_hint", ""), + "solution": item.get("solution", ""), + }).eq("id", qid).execute() + except Exception: + pass # 单批失败不影响其他批 + await asyncio.sleep(1) + + # 标记完成 + sb.table("papers").update({"status": "ready", "processing_step": None}).eq("id", paper_id).execute() + + +def _update_progress(sb, paper_id: str, step: str, progress: int = 0, total: int = 0): + """更新处理进度到 DB""" + sb.table("papers").update({ + "processing_step": step, + "processing_progress": progress, + "processing_total": total, + }).eq("id", paper_id).execute() + + +async def process_paper(paper_id: str, paper_bytes: bytes, answer_bytes: bytes | None): + """后台处理管线: PDF pages → Vision 结构化 → AI 三件套 + + 设计原则:每个步骤完成后立即持久化到 DB,支持断点续传。 + """ + sb = get_supabase() + + try: + # 检查是否已有题目(断点续传场景) + existing = sb.table("paper_questions").select("id, question_number, solution").eq("paper_id", paper_id).execute().data + + if existing: + # 已有题目 → 跳过提取,直接补 AI trio + await _resume_ai_trio(sb, paper_id, existing) + return + + # ── Step 1: PDF → 图片 ── + _update_progress(sb, paper_id, "Rendering PDF pages...") + paper_images = pdf_to_images(paper_bytes) + + # ── Step 2: Vision 结构化拆题 ── + PAGE_BATCH = 8 + all_questions: list = [] + meta: dict = {} + num_page_batches = -(-len(paper_images) // PAGE_BATCH) + for i in range(0, len(paper_images), PAGE_BATCH): + batch_imgs = paper_images[i:i + PAGE_BATCH] + batch_idx = i // PAGE_BATCH + 1 + _update_progress(sb, paper_id, f"Reading pages {i+1}-{i+len(batch_imgs)}...", batch_idx, num_page_batches) + batch_result = await gemini_vision_json( + system_prompt=STRUCTURE_PROMPT, + images=batch_imgs, + user_text=f"Pages {i+1}-{i+len(batch_imgs)} of the exam paper. Extract all questions visible on these pages.", + temperature=0, + ) + if not meta: + meta = {k: batch_result.get(k) for k in ("total_score", "difficulty_level", "topics_summary")} + all_questions.extend(batch_result.get("questions", [])) + + all_questions = sort_questions(all_questions) + questions = all_questions + + # 更新 paper 概览 + sb.table("papers").update({ + "total_score": meta.get("total_score"), + "question_count": len(questions), + "topics_summary": meta.get("topics_summary"), + "difficulty_level": meta.get("difficulty_level"), + }).eq("id", paper_id).execute() + + # ── Step 3: 答案匹配(分批,失败跳过)── + answers_map = {} + if answer_bytes: + _update_progress(sb, paper_id, "Matching answers...") + try: + answer_images = pdf_to_images(answer_bytes) + questions_json = json.dumps( + [{"question_number": q["question_number"], "question_type": q["question_type"]} + for q in questions], ensure_ascii=False, + ) + all_answers: list = [] + for ai in range(0, len(answer_images), 8): + batch_ans_imgs = answer_images[ai:ai + 8] + try: + match_result = await gemini_vision_json( + system_prompt=ANSWER_MATCH_PROMPT.format( + questions_json=questions_json, answer_text="(See images)", + ), + images=batch_ans_imgs, + user_text=f"Match answers to these questions: {questions_json}", + temperature=0, + ) + all_answers.extend(match_result.get("answers", [])) + except Exception: + pass + answers_map = {a["question_number"]: a for a in all_answers} + except Exception: + pass + + # ── Step 4: 立即写入题目到 DB(先不含 AI trio)── + _update_progress(sb, paper_id, "Saving questions...") + for i, q in enumerate(questions): + qnum = q["question_number"] + answer = answers_map.get(qnum, {}) + sb.table("paper_questions").insert(strip_nulls({ + "paper_id": paper_id, + "question_number": qnum, + "parent_question": q.get("parent_question"), + "display_order": i, + "question_type": q["question_type"], + "question_text": q["question_text"], + "score": q.get("score"), + "page_number": q.get("page_number"), + "options": q.get("options"), + "correct_option": answer.get("correct_option"), + "correct_answer": answer.get("correct_answer"), + "raw_answer_text": answer.get("raw_answer_text"), + "topics": q.get("topics", []), + "analytics_topic": q.get("topics", [None])[0], + "topic_tags": q.get("topics", []), + "difficulty": q.get("difficulty"), + })).execute() + + # ── Step 5: AI trio(逐条更新,支持断点续传)── + saved = sb.table("paper_questions").select("id, question_number, solution").eq("paper_id", paper_id).execute().data + await _resume_ai_trio(sb, paper_id, saved) + + except Exception as e: + sb.table("papers").update({ + "status": "error", + "error_message": f"{type(e).__name__}: {str(e)}\n{traceback.format_exc()[-500:]}", + }).eq("id", paper_id).execute() + raise diff --git a/backend/app/services/supabase_client.py b/backend/app/services/supabase_client.py new file mode 100644 index 0000000..26a1b78 --- /dev/null +++ b/backend/app/services/supabase_client.py @@ -0,0 +1,13 @@ +from supabase import create_client, Client +from app.config import get_settings + +_client: Client | None = None + + +def get_supabase() -> Client: + """获取 Supabase client (service_role,绕过 RLS)""" + global _client + if _client is None: + s = get_settings() + _client = create_client(s.supabase_url, s.supabase_service_role_key) + return _client diff --git a/backend/app/services/text_extractor.py b/backend/app/services/text_extractor.py new file mode 100644 index 0000000..f808d50 --- /dev/null +++ b/backend/app/services/text_extractor.py @@ -0,0 +1,48 @@ +"""PDF 文本提取 — 复用 SOS 的 text_extractor 逻辑""" + +import base64 +import fitz # PyMuPDF +from dataclasses import dataclass + + +@dataclass +class ExtractedContent: + pages_text: list[str] # 每页文本 + page_images: dict[int, str] # 页码 → base64 图片(图片密集型页面) + total_pages: int + has_images: bool + + +def extract_pdf(file_bytes: bytes) -> ExtractedContent: + """从 PDF 提取文本和图片""" + doc = fitz.open(stream=file_bytes, filetype="pdf") + pages_text = [] + page_images = {} + + for i, page in enumerate(doc): + text = page.get_text("text") + pages_text.append(text) + + # 如果某页文本很少但有图片,可能是扫描件 → 保存为图片用于 Vision OCR + if len(text.strip()) < 50: + pix = page.get_pixmap(dpi=200) + img_bytes = pix.tobytes("png") + page_images[i] = base64.b64encode(img_bytes).decode("utf-8") + + doc.close() + + return ExtractedContent( + pages_text=pages_text, + page_images=page_images, + total_pages=len(pages_text), + has_images=len(page_images) > 0, + ) + + +def get_full_text(extracted: ExtractedContent) -> str: + """合并所有页面文本""" + return "\n\n".join( + f"--- Page {i+1} ---\n{text}" + for i, text in enumerate(extracted.pages_text) + if text.strip() + ) diff --git a/backend/backfill_ai_trio_with_context.py b/backend/backfill_ai_trio_with_context.py new file mode 100644 index 0000000..0ad7059 --- /dev/null +++ b/backend/backfill_ai_trio_with_context.py @@ -0,0 +1,252 @@ +""" +重新生成所有题目的 AI trio,子题带父题上下文。 +用法: python backfill_ai_trio_with_context.py [--paper-id ] [--course ] +""" + +import asyncio +import io +import json +import re +import sys +import time +import argparse +from contextlib import redirect_stdout +from app.services.supabase_client import get_supabase +from app.services.llm_clients import get_deepseek_client + + +def extract_code_lines(text: str) -> str: + lines = (text or "").splitlines() + result = [] + in_code = False + open_brackets = 0 + CODE_START = re.compile(r"^\s*(import |from \w|[A-Za-z_]\w*\s*=|print\()") + for line in lines: + stripped = line.strip() + if in_code and open_brackets > 0: + result.append(stripped) + open_brackets += stripped.count("(") + stripped.count("[") + stripped.count("{") + open_brackets -= stripped.count(")") + stripped.count("]") + stripped.count("}") + continue + if CODE_START.match(line): + in_code = True + result.append(stripped) + open_brackets += stripped.count("(") + stripped.count("[") + stripped.count("{") + open_brackets -= stripped.count(")") + stripped.count("]") + stripped.count("}") + continue + in_code = False + return "\n".join(result) + + +def try_exec_python(code: str, shared_ns: dict) -> str | None: + buf = io.StringIO() + try: + with redirect_stdout(buf): + exec(code, shared_ns) # noqa: S102 + output = buf.getvalue().strip() + return output if output else None + except Exception: + return None + +BATCH_ANALYSIS_PROMPT = """You are an expert academic answer analyst. Generate three study sections for each question below. ALL output must be in English. + +For every question, return: +- knowledge_reminder: concise prerequisite bullets in HTML +- ai_hint: a helpful hint in HTML without revealing the final answer +- solution: a complete step-by-step solution in HTML + +Return JSON in this exact format: +{{ + "analyses": [ + {{ + "question_number": "1a", + "knowledge_reminder": "...", + "ai_hint": "...", + "solution": "..." + }} + ] +}} + +Rules: +- Return one item for every provided question_number +- All text must be in English +- HTML only, KaTeX compatible (block $$ ... $$ inline $ ... $) +- For MC questions, explain why the correct option is right and why others are wrong +- For long questions, show a complete derivation or reasoning chain +- Use
        or numbered steps in solution when appropriate +- Mark common mistakes with
        ...
        +- CRITICAL: When a question_text contains "[Context from parent question X]" followed by "[Sub-question Y]", the parent section is background context only. You MUST solve ONLY the specific sub-question labeled [Sub-question Y]. Do NOT solve other sub-questions listed in the parent context. Give one precise answer for that single sub-question only. + +Questions: +{questions_payload} +""" + + +def chunked(lst, size): + return [lst[i:i+size] for i in range(0, len(lst), size)] + + +async def deepseek_batch(batch: list[dict]) -> list[dict]: + client = get_deepseek_client() + for attempt in range(5): + try: + resp = client.chat.completions.create( + model="deepseek-chat", + messages=[{ + "role": "system", + "content": BATCH_ANALYSIS_PROMPT.format( + questions_payload=json.dumps(batch, ensure_ascii=False) + ) + }], + temperature=0.3, + max_tokens=8192, + response_format={"type": "json_object"}, + ) + raw = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f]', '', resp.choices[0].message.content) + raw = re.sub(r'(? list[str]: + if "_" in question_number: + left, right = question_number.split("_", 1) + tokens: list[str] = [] + m = re.fullmatch(r"(\d+)([a-z])", left) + if m: + tokens.append(f"({m.group(2)})") + elif re.fullmatch(r"\d+[a-z]+", left): + tokens.append(f"({re.sub(r'^\\d+', '', left)})") + tokens.append(f"({right})") + return tokens[::-1] + + m = re.fullmatch(r"(\d+)([a-z])", question_number) + if m: + return [f"({m.group(2)})", f"Problem {m.group(1)}"] + + if question_number.isdigit(): + return [f"Problem {question_number}"] + + return [question_number] + + +def line_matches(line_text: str, marker: str) -> bool: + text = re.sub(r"\s+", " ", line_text.strip()) + if not text: + return False + if marker.startswith("("): + return text.startswith(marker) + return marker.lower() in text.lower() + + +def line_y_ratio(page: fitz.Page, marker: str) -> float | None: + data = page.get_text("dict") + hits: list[float] = [] + for block in data.get("blocks", []): + if block.get("type") != 0: + continue + for line in block.get("lines", []): + line_text = "".join( + span.get("text", "") + for span in line.get("spans", []) + ) + if line_matches(line_text, marker): + bbox = line.get("bbox") + if bbox: + hits.append(float(bbox[1])) + if not hits: + return None + y = min(hits) + return max(0.0, min((y - page.rect.y0) / page.rect.height, 0.98)) + + +def search_y_ratio(page: fitz.Page, marker: str) -> float | None: + ratios: list[float] = [] + for rect in page.search_for(marker): + ratios.append(max(0.0, min((rect.y0 - page.rect.y0) / page.rect.height, 0.98))) + return min(ratios) if ratios else None + + +def infer_y_ratio(page: fitz.Page, question_number: str) -> float: + for marker in marker_candidates(question_number): + ratio = line_y_ratio(page, marker) + if ratio is not None: + return ratio + ratio = search_y_ratio(page, marker) + if ratio is not None: + return ratio + return 0.05 + + +def main() -> None: + sb = get_supabase() + papers = ( + sb.table("papers") + .select("id, source_exam_key") + .eq("course_code", "COMP2211") + .eq("source_kind", "course_library") + .execute() + .data + or [] + ) + + updates: list[tuple[str, float]] = [] + for paper in papers: + exam_key = paper["source_exam_key"] + pdf_name = PDF_BY_EXAM_KEY.get(exam_key) + if not pdf_name: + continue + pdf_path = PAPERS_DIR / pdf_name + doc = fitz.open(pdf_path) + try: + questions = ( + sb.table("paper_questions") + .select("id, question_number, page_number") + .eq("paper_id", paper["id"]) + .order("display_order") + .execute() + .data + or [] + ) + for question in questions: + page_number = question.get("page_number") or 1 + page = doc[page_number - 1] + ratio = infer_y_ratio(page, question["question_number"]) + updates.append((question["id"], round(ratio, 4))) + finally: + doc.close() + + def apply_update(payload: tuple[str, float]) -> None: + question_id, ratio = payload + attempts = 0 + while True: + try: + sb.table("paper_questions").update({"page_y_ratio": ratio}).eq("id", question_id).execute() + return + except httpx.HTTPError: + attempts += 1 + if attempts >= 5: + raise + time.sleep(0.4 * attempts) + + with ThreadPoolExecutor(max_workers=3) as executor: + futures = [executor.submit(apply_update, payload) for payload in updates] + for future in as_completed(futures): + future.result() + + print(f"Backfilled page_y_ratio for {len(updates)} COMP2211 questions.") + + +if __name__ == "__main__": + main() diff --git a/backend/backfill_comp2211_tags.py b/backend/backfill_comp2211_tags.py new file mode 100644 index 0000000..3f1fb57 --- /dev/null +++ b/backend/backfill_comp2211_tags.py @@ -0,0 +1,365 @@ +"""Backfill COMP2211 tags to the revised retrieval schema.""" + +from __future__ import annotations + +import re +from collections import OrderedDict + +from app.services.supabase_client import get_supabase + + +SKILL_LABELS = { + "concept_check": "Concept Check", + "code_tracing": "Code Tracing", + "algorithm_tracing": "Algorithm Tracing", + "distance_calculation": "Distance Calculation", + "centroid_update": "Centroid Update", + "weight_update": "Weight Update", + "decision_boundary": "Decision Boundary", + "implementation": "Implementation", + "debugging": "Debugging", + "model_selection": "Model Selection", + "concept_explanation": "Concept Explanation", + "architecture_reasoning": "Architecture Reasoning", + "convergence_reasoning": "Convergence Reasoning", + "generalization_reasoning": "Generalization Reasoning", + "classification_decision": "Classification Decision", +} + +ACRONYMS = { + "ai": "AI", + "cnn": "CNN", + "knn": "KNN", + "mlp": "MLP", + "nb": "NB", + "numpy": "NumPy", +} + + +def title_case_with_acronyms(value: str) -> str: + words = re.split(r"[\s_]+", value.strip()) + parts: list[str] = [] + for word in words: + if not word: + continue + lowered = word.lower() + parts.append(ACRONYMS.get(lowered, lowered.capitalize())) + return " ".join(parts) + + +def normalize_skill_tag(tag: str) -> str: + if tag in SKILL_LABELS: + return SKILL_LABELS[tag] + return title_case_with_acronyms(tag) + + +def text_blob(question: dict) -> str: + parts = [ + question.get("question_text") or "", + question.get("raw_answer_text") or "", + " ".join(question.get("topic_tags") or []), + " ".join(question.get("skill_tags") or []), + question.get("analytics_topic") or "", + ] + return " ".join(parts).lower() + + +def has_any(text: str, phrases: list[str]) -> bool: + return any(phrase in text for phrase in phrases) + + +def infer_analytics_topic(question: dict) -> str: + text = text_blob(question) + broad = question.get("analytics_topic") or "" + skills = {normalize_skill_tag(tag) for tag in (question.get("skill_tags") or [])} + + if has_any(text, ["ethics", "bias", "privacy", "autonomous vehicle", "informed consent", "human participants", "ethically"]): + return "Ethics of AI" + if has_any(text, ["minimax", "alpha-beta", "alpha beta", "game tree", "tic-tac-toe", "tic tac toe"]): + return "Game Trees" + if has_any(text, ["search algorithm", "best-first", "breadth-first", "depth-first", "a* search", "a star"]): + return "Search Algorithms" + if has_any(text, ["cross validation", "d-fold", "k-fold", "train/val", "validation set", "fold "]) or broad == "Cross Validation": + return "Cross Validation" + if has_any(text, ["confusion matrix", "precision", "recall", "macro f1", "f1 score", "accuracy score", "evaluation metric"]): + return "Evaluation Metrics" + if has_any(text, ["naive bayes", "gaussian distribution", "laplace smoothing", "likelihood", "posterior probability"]) or broad == "Naive Bayes": + return "Naive Bayes" + if has_any(text, ["bayes classifier", "conditional probability", "bayesian inference", "prior probability", "posterior"]) or broad == "Bayesian Inference": + return "Bayesian Inference" + if has_any(text, ["leader clustering", "k-means", "k means", "centroid", "elbow method", "silhouette", "cluster assignments", "closest centroid", "new cluster"]): + return "K-Means" + if has_any(text, ["k-nearest", "nearest neighbors", "weighted knn", "cosine distance", "euclidean distance", "manhattan distance", "6-cross-validation error for k", "class for cosine distance"]): + return "KNN" + if has_any(text, ["multilayer perceptron", "mlp", "back propagation", "backpropagation", "hidden layer", "output layer", "dropout", "softmax", "sigmoid function", "relu as the activation"]) or broad == "MLP": + return "MLP" + if has_any(text, ["perceptron", "decision boundary", "single neuron", "weight update", "activation function f(z)", "linearly separable"]) or broad == "Perceptron": + return "Perceptron" + if has_any(text, ["convolutional neural network", "cnn", "kernel", "padding", "stride", "pooling", "dilated convolution", "3d convolution", "otsu", "histogram", "image processing", "grayscale image"]): + return "CNN" + if has_any(text, ["numpy", "python", "np.", "broadcasting", "reshape", "transpose", "mask", "vectorized", "np.arange", "np.mean", "np.dot", "np.convolve"]): + return "Python and NumPy" + + if broad == "KNN and Clustering": + if ( + has_any(text, ["k-means", "k means", "centroid", "leader clustering", "elbow", "silhouette"]) + or "Centroid Update" in skills + or "Convergence Reasoning" in skills + or "Algorithm Tracing" in skills + or "Model Selection" in skills + ): + return "K-Means" + return "KNN" + + if broad == "Perceptron and MLP": + if ( + has_any(text, ["hidden layer", "backprop", "activation function", "softmax", "relu", "sigmoid", "multilayer perceptron", "mlp"]) + or "Architecture Reasoning" in skills + ): + return "MLP" + return "Perceptron" + + if broad == "Probabilistic Models": + if has_any(text, ["naive bayes", "gaussian", "laplace", "likelihood"]): + return "Naive Bayes" + return "Bayesian Inference" + + if broad == "Evaluation and Validation": + if has_any(text, ["cross validation", "cross-validation", "k-fold", "d-fold", "validation set", "train/val"]): + return "Cross Validation" + return "Evaluation Metrics" + + if broad == "Search and Games": + if has_any(text, ["minimax", "alpha-beta", "alpha beta", "game tree"]): + return "Game Trees" + return "Search Algorithms" + + broad_map = { + "Vision and CNN": "CNN", + "Python Fundamentals": "Python and NumPy", + "Ethics of AI": "Ethics of AI", + } + return broad_map.get(broad, "Python and NumPy") + + +TOPIC_CONCEPTS = { + "Naive Bayes": [ + ("Naive Bayes", ["naive bayes"]), + ("Prior", ["prior"]), + ("Likelihood", ["likelihood"]), + ("Posterior", ["posterior"]), + ("Gaussian", ["gaussian"]), + ("Laplace Smoothing", ["laplace"]), + ("Missing Data", ["missing data", "missing value"]), + ], + "Bayesian Inference": [ + ("Bayesian Inference", ["bayes", "conditional probability", "posterior"]), + ("Conditional Probability", ["conditional probability"]), + ("Bayes Rule", ["bayes rule", "posterior"]), + ("Prior", ["prior"]), + ("Posterior", ["posterior"]), + ], + "KNN": [ + ("KNN", ["k-nearest", "nearest neighbors", "knn"]), + ("Euclidean Distance", ["euclidean distance"]), + ("Manhattan Distance", ["manhattan distance"]), + ("Cosine Distance", ["cosine distance"]), + ("Weighted KNN", ["weighted k-nearest", "weighted knn", "inverse of the distance"]), + ("Classification", ["class label", "predict", "classification"]), + ("Cross Validation", ["cross-validation", "cross validation"]), + ("Test Error", ["test error"]), + ], + "K-Means": [ + ("K-Means", ["k-means", "k means"]), + ("Centroid Update", ["centroid"]), + ("Convergence", ["converged", "convergence"]), + ("Leader Clustering", ["leader clustering"]), + ("Outliers", ["outlier"]), + ("Model Selection", ["elbow method", "silhouette", "suitable k"]), + ], + "Perceptron": [ + ("Perceptron", ["perceptron"]), + ("Decision Boundary", ["decision boundary", "linearly separable"]), + ("Weight Update", ["weight update", "∆w", "deltaw", "backward propagation"]), + ("Convergence", ["converged", "convergence"]), + ("Activation Function", ["activation function"]), + ], + "MLP": [ + ("MLP", ["mlp", "multilayer perceptron"]), + ("Backpropagation", ["back propagation", "backpropagation", "backward propagation"]), + ("Activation Function", ["activation function", "relu", "sigmoid", "softmax"]), + ("Hidden Layer", ["hidden layer"]), + ("Output Layer", ["output layer"]), + ("Parameter Count", ["number of parameters", "parameter"]), + ("Overfitting", ["overfitting", "dropout"]), + ], + "CNN": [ + ("CNN", ["cnn", "convolutional neural network"]), + ("Convolution", ["convolution", "kernel"]), + ("Padding", ["padding", "reflection padding", "zero padding"]), + ("Stride", ["stride"]), + ("Pooling", ["pooling", "max pooling", "average pooling"]), + ("Image Processing", ["image processing", "grayscale image"]), + ("Histogram", ["histogram"]), + ("Otsu Thresholding", ["otsu"]), + ("Dilated Convolution", ["dilated convolution"]), + ("3D Convolution", ["3d convolution"]), + ("Dropout", ["dropout"]), + ], + "Evaluation Metrics": [ + ("Evaluation Metrics", ["evaluation", "metric"]), + ("Confusion Matrix", ["confusion matrix"]), + ("Accuracy", ["accuracy"]), + ("Precision", ["precision"]), + ("Recall", ["recall"]), + ("F1 Score", ["f1"]), + ("Macro F1", ["macro f1"]), + ], + "Cross Validation": [ + ("Cross Validation", ["cross validation", "cross-validation", "d-fold", "k-fold"]), + ("Train Validation Split", ["validation set", "train", "test fold"]), + ("Model Selection", ["choose k", "which k", "fold"]), + ("Data Shuffling", ["shuffle", "shuffling"]), + ], + "Python and NumPy": [ + ("Python and NumPy", ["numpy", "python"]), + ("NumPy", ["numpy", "np."]), + ("Broadcasting", ["broadcast"]), + ("Array Indexing", ["index", "slice"]), + ("Vectorization", ["no explicit loops", "vectorized"]), + ("Matrix Multiplication", ["matmul", "matrix multiplication", "@"]), + ("Reshape", ["reshape"]), + ("Transpose", ["transpose"]), + ("Masking", ["mask"]), + ("Convolution", ["convolve"]), + ], + "Search Algorithms": [ + ("Search Algorithms", ["search"]), + ("Breadth-First Search", ["breadth-first", "breadth first", "bfs"]), + ("Depth-First Search", ["depth-first", "depth first", "dfs"]), + ("Best-First Search", ["best-first", "best first"]), + ("A* Search", ["a* search", "a star", "astar"]), + ("Heuristic", ["heuristic"]), + ], + "Game Trees": [ + ("Game Trees", ["game tree", "minimax", "alpha-beta", "alpha beta"]), + ("Minimax", ["minimax"]), + ("Alpha-Beta Pruning", ["alpha-beta", "alpha beta", "pruned"]), + ("Utility", ["utility"]), + ], + "Ethics of AI": [ + ("Ethics of AI", ["ethics", "ethical"]), + ("Bias", ["bias"]), + ("Privacy", ["privacy"]), + ("Fairness", ["fair"]), + ("Research Ethics", ["informed consent", "human participants"]), + ("Governance", ["monitoring", "production", "organizations"]), + ("Autonomous Vehicles", ["autonomous vehicle"]), + ], +} + + +TOPIC_DEFAULTS = { + "Naive Bayes": ["Likelihood", "Posterior"], + "Bayesian Inference": ["Conditional Probability", "Bayes Rule"], + "KNN": ["Classification", "Distance Calculation"], + "K-Means": ["Centroid Update", "Convergence"], + "Perceptron": ["Decision Boundary", "Weight Update"], + "MLP": ["Activation Function", "Hidden Layer"], + "CNN": ["Convolution", "Padding"], + "Evaluation Metrics": ["Confusion Matrix", "F1 Score"], + "Cross Validation": ["Train Validation Split", "Model Selection"], + "Python and NumPy": ["NumPy", "Vectorization"], + "Search Algorithms": ["Breadth-First Search", "Heuristic"], + "Game Trees": ["Minimax", "Alpha-Beta Pruning"], + "Ethics of AI": ["Bias", "Fairness"], +} + +DEFAULT_SKILLS = { + "Naive Bayes": ["Probability Reasoning"], + "Bayesian Inference": ["Probability Reasoning"], + "KNN": ["Classification Decision"], + "K-Means": ["Centroid Update"], + "Perceptron": ["Decision Boundary"], + "MLP": ["Concept Explanation"], + "CNN": ["Concept Explanation"], + "Evaluation Metrics": ["Metric Reasoning"], + "Cross Validation": ["Model Selection"], + "Python and NumPy": ["Code Tracing"], + "Search Algorithms": ["Algorithm Tracing"], + "Game Trees": ["Game Reasoning"], + "Ethics of AI": ["Ethical Reasoning"], +} + + +def unique_keep_order(values: list[str]) -> list[str]: + return list(OrderedDict((value, None) for value in values if value).keys()) + + +def build_topic_tags(question: dict, analytics_topic: str) -> list[str]: + text = text_blob(question) + tags: list[str] = [analytics_topic] + for label, keywords in TOPIC_CONCEPTS.get(analytics_topic, []): + if label == analytics_topic: + continue + if has_any(text, keywords): + tags.append(label) + for default in TOPIC_DEFAULTS.get(analytics_topic, []): + if len(unique_keep_order(tags)) >= 2: + break + tags.append(default) + tags = unique_keep_order(tags) + return tags[:5] + + +def build_skill_tags(question: dict, analytics_topic: str) -> list[str]: + raw = question.get("skill_tags") or [] + converted = unique_keep_order([normalize_skill_tag(tag) for tag in raw]) + if not converted: + converted = DEFAULT_SKILLS.get(analytics_topic, ["Concept Check"]) + return converted[:3] + + +def main() -> None: + sb = get_supabase() + papers = ( + sb.table("papers") + .select("id") + .eq("course_code", "COMP2211") + .eq("source_kind", "course_library") + .execute() + .data + ) + paper_ids = [paper["id"] for paper in papers] + if not paper_ids: + print("No COMP2211 course-library papers found.") + return + + questions = ( + sb.table("paper_questions") + .select("id, paper_id, question_number, question_text, raw_answer_text, analytics_topic, topic_tags, skill_tags, topics") + .in_("paper_id", paper_ids) + .order("paper_id") + .order("display_order") + .execute() + .data + ) + + for question in questions: + analytics_topic = infer_analytics_topic(question) + topic_tags = build_topic_tags(question, analytics_topic) + skill_tags = build_skill_tags(question, analytics_topic) + payload = { + "analytics_topic": analytics_topic, + "topic_primary": analytics_topic, + "topic_tags": topic_tags, + "topics": topic_tags, + "skill_tags": skill_tags, + } + sb.table("paper_questions").update(payload).eq("id", question["id"]).execute() + + print(f"Backfilled {len(questions)} COMP2211 questions.") + + +if __name__ == "__main__": + main() diff --git a/backend/backfill_null_ai_trio.py b/backend/backfill_null_ai_trio.py new file mode 100644 index 0000000..4baba5d --- /dev/null +++ b/backend/backfill_null_ai_trio.py @@ -0,0 +1,169 @@ +"""Backfill AI trio for questions where knowledge_reminder IS NULL. + +For each question, generates fields in two separate LLM calls to avoid token truncation: + Call 1 → knowledge_reminder + ai_hint (short, ~500 tokens output) + Call 2 → solution (long, up to 4096 tokens output) + +Run from the backend directory: + uv run python backfill_null_ai_trio.py [--dry-run] +""" + +from __future__ import annotations + +import asyncio +import json +import sys +from app.services.supabase_client import get_supabase +from app.services.paper_processor import qwen_json_completion + + +KNOWLEDGE_HINT_PROMPT = """\ +You are an expert tutor. Given a past-paper question, produce two short study aids in English. + +Return JSON exactly: +{{ + "knowledge_reminder": "2-4 sentences summarising the key concept or formula the student must recall.", + "ai_hint": "1-3 sentence nudge that guides WITHOUT giving the answer away." +}} + +Question: +{payload} +""" + +SOLUTION_PROMPT = """\ +You are an expert tutor. Given a past-paper question and its reference answer, write a clear, \ +step-by-step model solution in English. Show all working. Be thorough but stop when the answer \ +is complete — do not pad. + +Return JSON exactly: +{{ + "solution": "" +}} + +Question: +{payload} +""" + + +def build_payload(q: dict) -> dict: + ref = "" + if q.get("raw_answer_text"): + ref = q["raw_answer_text"] + elif q.get("correct_option"): + ref = f"Correct option: {q['correct_option']}" + elif q.get("correct_answer"): + ref = f"Correct answer: {q['correct_answer']}" + + return { + "question_number": q["question_number"], + "question_type": q["question_type"] or "long_question", + "score": q.get("score") or "unknown", + "question_text": q.get("question_text") or "", + "topics": q.get("topics") or [], + "reference_answer": ref, + } + + +async def process_one(sb, q: dict, dry_run: bool) -> bool: + payload_str = json.dumps(build_payload(q), ensure_ascii=False) + row_id = q["id"] + qnum = q["question_number"] + + if dry_run: + print(f" [dry-run] would process {qnum}") + return True + + update: dict = {} + + # ── Call 1: knowledge_reminder + ai_hint ───────────────────────── + try: + r1 = await qwen_json_completion( + system_prompt=KNOWLEDGE_HINT_PROMPT.format(payload=payload_str), + temperature=0.3, + max_tokens=1024, + ) + if r1.get("knowledge_reminder"): + update["knowledge_reminder"] = r1["knowledge_reminder"] + if r1.get("ai_hint"): + update["ai_hint"] = r1["ai_hint"] + except Exception as e: + print(f" WARN call-1 failed for {qnum}: {e}") + + await asyncio.sleep(1) + + # ── Call 2: solution ────────────────────────────────────────────── + try: + r2 = await qwen_json_completion( + system_prompt=SOLUTION_PROMPT.format(payload=payload_str), + temperature=0.3, + max_tokens=4096, + ) + if r2.get("solution"): + update["solution"] = r2["solution"] + except Exception as e: + print(f" WARN call-2 failed for {qnum}: {e}") + + if not update: + print(f" SKIP {qnum}: both calls returned nothing") + return False + + sb.table("paper_questions").update(update).eq("id", row_id).execute() + return True + + +async def backfill(dry_run: bool = False) -> None: + sb = get_supabase() + + papers = ( + sb.table("papers") + .select("id") + .eq("course_code", "COMP2211") + .eq("source_kind", "course_library") + .execute() + .data + ) + paper_ids = [p["id"] for p in papers] + if not paper_ids: + print("No COMP2211 course-library papers found.") + return + + questions = ( + sb.table("paper_questions") + .select("id, paper_id, question_number, question_type, score, question_text, topics, raw_answer_text, correct_option, correct_answer") + .in_("paper_id", paper_ids) + .is_("knowledge_reminder", "null") + .order("paper_id") + .order("display_order") + .execute() + .data + ) + + if not questions: + print("No NULL questions found — all done!") + return + + print(f"Found {len(questions)} questions with NULL knowledge_reminder.") + + # Group by paper for cleaner output + from collections import defaultdict + by_paper: dict[str, list] = defaultdict(list) + for q in questions: + by_paper[q["paper_id"]].append(q) + + total_updated = 0 + for paper_idx, (paper_id, qs) in enumerate(by_paper.items(), 1): + print(f"\n[{paper_idx}/{len(by_paper)}] paper_id={paper_id} — {len(qs)} NULL questions") + for q in qs: + print(f" Processing {q['question_number']}...", end=" ", flush=True) + ok = await process_one(sb, q, dry_run) + if ok: + total_updated += 1 + print("done") + await asyncio.sleep(1.5) + + print(f"\nDone. {total_updated}/{len(questions)} questions updated.") + + +if __name__ == "__main__": + dry_run = "--dry-run" in sys.argv + asyncio.run(backfill(dry_run=dry_run)) diff --git a/backend/backfill_similar_questions.py b/backend/backfill_similar_questions.py new file mode 100644 index 0000000..11747a2 --- /dev/null +++ b/backend/backfill_similar_questions.py @@ -0,0 +1,135 @@ +"""Pre-compute similar_questions for all COMP2211 course-library questions. + +For each question, runs the same similarity logic as the API and writes the result +into paper_questions.similar_questions (JSONB). The API will then return this +pre-computed value directly with no computation overhead. + +Run from the backend directory: + uv run python backfill_similar_questions.py [--dry-run] +""" + +from __future__ import annotations + +import sys +from collections import Counter +from app.services.supabase_client import get_supabase +from app.routers.questions import ( + similarity_score, + question_family, + display_topics, +) + + +def run(dry_run: bool = False) -> None: + sb = get_supabase() + + # Fetch all ready COMP2211 papers + papers = ( + sb.table("papers") + .select("id, year, term, exam_type, part_label") + .eq("course_code", "COMP2211") + .eq("status", "ready") + .execute() + .data + ) + if not papers: + print("No ready COMP2211 papers found.") + return + + papers_by_id = {p["id"]: p for p in papers} + paper_ids = list(papers_by_id.keys()) + + # Fetch all questions for these papers + all_questions = ( + sb.table("paper_questions") + .select( + "id, paper_id, question_number, question_type, question_format, " + "question_text, score, topics, analytics_topic, topic_tags, skill_tags, " + "difficulty, knowledge_reminder, ai_hint, solution" + ) + .in_("paper_id", paper_ids) + .execute() + .data + ) + print(f"Found {len(all_questions)} questions across {len(papers)} papers.") + + # Batch full-text scores not practical here; skip RPC, rely on tag/topic scoring + # (text_score = 0 for all, still produces good tag-based results) + + updated = 0 + skipped = 0 + + for i, target in enumerate(all_questions, 1): + target_paper_id = target["paper_id"] + target_topic = target.get("analytics_topic") + + # Candidates: same course, different paper + candidates = [ + q for q in all_questions + if q["paper_id"] != target_paper_id + ] + + # Pre-filter by analytics_topic if available + if target_topic: + candidates = [c for c in candidates if c.get("analytics_topic") == target_topic] + + if not candidates: + skipped += 1 + print(f" [{i}/{len(all_questions)}] {target['question_number']} — no candidates, skip") + continue + + ranked = [] + for candidate in candidates: + match_percent, reasons = similarity_score(target, candidate, text_score=0.0) + if match_percent < 20: + continue + paper = papers_by_id.get(candidate["paper_id"], {}) + source = ( + f"{paper.get('year', '')} {paper.get('term', '').title()} " + f"{paper.get('exam_type', '').title()}" + ).strip() + if paper.get("part_label"): + source = f"{source} Part {paper['part_label']}" + ranked.append({ + "id": candidate["id"], + "paper_id": candidate["paper_id"], + "source": source, + "question_number": candidate["question_number"], + "match_percent": match_percent, + "match_reasons": reasons, + "question_type": question_family(candidate), + "question_text": candidate["question_text"], + "topics": display_topics(candidate), + "difficulty": candidate.get("difficulty"), + "knowledge_reminder": candidate.get("knowledge_reminder", ""), + "ai_hint": candidate.get("ai_hint", ""), + "solution": candidate.get("solution", ""), + }) + + ranked.sort(key=lambda item: (-item["match_percent"], item["source"], item["question_number"])) + + # Deduplicate: best per paper + seen_papers: set[str] = set() + deduped = [] + for item in ranked: + if item["paper_id"] not in seen_papers: + seen_papers.add(item["paper_id"]) + deduped.append(item) + deduped = deduped[:12] + + print(f" [{i}/{len(all_questions)}] {target['question_number']} → {len(deduped)} similar", end="") + + if dry_run: + print(" [dry-run]") + continue + + sb.table("paper_questions").update({"similar_questions": deduped}).eq("id", target["id"]).execute() + updated += 1 + print() + + print(f"\nDone. {updated} updated, {skipped} skipped (no candidates).") + + +if __name__ == "__main__": + dry_run = "--dry-run" in sys.argv + run(dry_run=dry_run) diff --git a/backend/backfill_vision.py b/backend/backfill_vision.py new file mode 100644 index 0000000..ec4c637 --- /dev/null +++ b/backend/backfill_vision.py @@ -0,0 +1,238 @@ +""" +用 Vision 模式重新处理所有已 ready 的试卷: +- 从 Supabase Storage 拉 PDF → 图片 → Vision 拆题 → exec → AI trio → 更新 DB + +用法: + python backfill_vision.py --course COMP2211 + python backfill_vision.py --paper-id +""" + +import asyncio +import argparse +import requests +from app.services.supabase_client import get_supabase +from app.services.paper_processor import ( + process_paper, + strip_nulls, + pdf_to_images, + gemini_vision_json, + deepseek_json_completion, + parse_json_response, + extract_code_lines, + try_exec_python, + chunked, + sort_questions, + STRUCTURE_PROMPT, + ANSWER_MATCH_PROMPT, + BATCH_ANALYSIS_PROMPT, +) +import json +import traceback + + +async def reprocess_paper(paper: dict): + """重新处理单张试卷(Vision 模式)""" + sb = get_supabase() + paper_id = paper["id"] + label = f"{paper['course_code']} {paper['year']} {paper['term']} {paper['exam_type']}" + print(f"\n=== {label} ({paper_id[:8]}) ===") + + # 1. 拉 PDF + try: + pdf_bytes = requests.get(paper["paper_file_url"], timeout=60).content + except Exception as e: + print(f" SKIP: failed to fetch PDF: {e}") + return + + answer_bytes = None + if paper.get("answer_file_url"): + try: + answer_bytes = requests.get(paper["answer_file_url"], timeout=60).content + except Exception: + pass + + # 2. PDF → 图片 + print(f" Rendering {len(pdf_to_images(pdf_bytes))} pages...", end=" ", flush=True) + paper_images = pdf_to_images(pdf_bytes) + print("done") + + # 3. Vision 拆题(分批,每批 8 页) + PAGE_BATCH = 8 + all_questions: list = [] + meta: dict = {} + print(f" Vision extraction ({len(paper_images)} pages, {-(-len(paper_images)//PAGE_BATCH)} batches)...") + for i in range(0, len(paper_images), PAGE_BATCH): + batch_imgs = paper_images[i:i + PAGE_BATCH] + print(f" Pages {i+1}-{i+len(batch_imgs)}...", end=" ", flush=True) + try: + batch_result = await gemini_vision_json( + system_prompt=STRUCTURE_PROMPT, + images=batch_imgs, + user_text=f"Pages {i+1}-{i+len(batch_imgs)} of the exam paper. Extract all questions visible on these pages.", + temperature=0, + ) + if not meta: + meta = {k: batch_result.get(k) for k in ("total_score", "difficulty_level", "topics_summary")} + qs = batch_result.get("questions", []) + all_questions.extend(qs) + print(f"done ({len(qs)} questions)") + except Exception as e: + print(f"FAILED: {e}") + structure = {**meta, "questions": all_questions} + questions = sort_questions(all_questions) + print(f" Total: {len(questions)} questions extracted") + + # 4. 答案匹配 + answers_map = {} + if answer_bytes: + print(" Vision answer matching...", end=" ", flush=True) + answer_images = pdf_to_images(answer_bytes) + questions_json = json.dumps( + [{"question_number": q["question_number"], "question_type": q["question_type"]} + for q in questions], ensure_ascii=False + ) + try: + match_result = await gemini_vision_json( + system_prompt=ANSWER_MATCH_PROMPT.format( + questions_json=questions_json, answer_text="(See images)" + ), + images=answer_images, + user_text=f"Match answers to these questions: {questions_json}", + temperature=0, + ) + answers_map = {a["question_number"]: a for a in match_result.get("answers", [])} + print(f"done ({len(answers_map)} matched)") + except Exception as e: + print(f"FAILED: {e}") + + # 5. 构建 payloads(exec Python) + import numpy as np + exec_namespaces: dict = {} + batched_payloads = [] + + for q in questions: + qnum = q["question_number"] + answer = answers_map.get(qnum, {}) + full_text = q["question_text"] or "" + + answer_section = "" + if answer.get("raw_answer_text"): + answer_section = answer["raw_answer_text"] + elif answer.get("correct_option"): + answer_section = f"Correct option: {answer['correct_option']}" + elif answer.get("correct_answer"): + answer_section = f"Correct answer: {answer['correct_answer']}" + + if not answer_section: + parent_q = q.get("parent_question") + group_key = parent_q or qnum + if group_key not in exec_namespaces: + ns: dict = {"np": np} + setup = extract_code_lines(full_text) + try_exec_python(setup, ns) + exec_namespaces[group_key] = ns + ns = exec_namespaces[group_key] + print_lines = [l.strip() for l in full_text.splitlines() if l.strip().startswith("print(")] + if print_lines: + out = try_exec_python(print_lines[-1], ns) + if out is not None: + answer_section = f"Executed output: {out}" + print(f" [exec] {qnum}: {out[:60]}") + + batched_payloads.append({ + "question_number": qnum, + "question_type": q["question_type"], + "score": q.get("score", "unknown"), + "question_text": full_text, + "topics": q.get("topics", []), + "reference_answer": answer_section, + }) + + # 6. AI trio + print(f" Generating AI trio ({len(batched_payloads)} questions, {len(list(chunked(batched_payloads, 3)))} batches)...") + analyses: dict = {} + for batch in chunked(batched_payloads, 3): + nums = [p["question_number"] for p in batch] + print(f" Batch {nums}...", end=" ", flush=True) + try: + result = await deepseek_json_completion( + system_prompt=BATCH_ANALYSIS_PROMPT.format( + questions_payload=json.dumps(batch, ensure_ascii=False) + ), + temperature=0.3, + ) + for item in result.get("analyses", []): + if item.get("question_number"): + analyses[item["question_number"]] = item + print(f"done ({len(result.get('analyses', []))})") + except Exception as e: + print(f"FAILED: {e}") + await asyncio.sleep(1) + + # 7. 删除旧题目,写入新题目 + print(" Writing to DB...", end=" ", flush=True) + sb.table("paper_questions").delete().eq("paper_id", paper_id).execute() + + for i, q in enumerate(questions): + qnum = q["question_number"] + answer = answers_map.get(qnum, {}) + analysis = analyses.get(qnum, {}) + sb.table("paper_questions").insert(strip_nulls({ + "paper_id": paper_id, + "question_number": qnum, + "parent_question": q.get("parent_question"), + "display_order": i, + "question_type": q["question_type"], + "question_text": q["question_text"], + "score": q.get("score"), + "page_number": q.get("page_number"), + "options": q.get("options"), + "correct_option": answer.get("correct_option"), + "correct_answer": answer.get("correct_answer"), + "raw_answer_text": answer.get("raw_answer_text"), + "topics": q.get("topics", []), + "analytics_topic": q.get("topics", [None])[0], + "topic_tags": q.get("topics", []), + "difficulty": q.get("difficulty"), + "knowledge_reminder": analysis.get("knowledge_reminder", ""), + "ai_hint": analysis.get("ai_hint", ""), + "solution": analysis.get("solution", ""), + })).execute() + + sb.table("papers").update({ + "question_count": len(questions), + "total_score": structure.get("total_score"), + "topics_summary": structure.get("topics_summary"), + "difficulty_level": structure.get("difficulty_level"), + }).eq("id", paper_id).execute() + + print(f"done ({len(questions)} questions written)") + + +async def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--course", help="Course code") + parser.add_argument("--paper-id", help="Single paper ID") + args = parser.parse_args() + + sb = get_supabase() + query = sb.table("papers").select("*").eq("status", "ready") + if args.paper_id: + query = query.eq("id", args.paper_id) + elif args.course: + query = query.eq("course_code", args.course.upper()) + papers = query.order("created_at").execute().data + + print(f"Papers to reprocess: {len(papers)}") + for paper in papers: + try: + await reprocess_paper(paper) + except Exception as e: + print(f" ERROR: {e}") + traceback.print_exc() + + print("\nAll done.") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/fill_manual_study_aids.py b/backend/fill_manual_study_aids.py new file mode 100644 index 0000000..4e116dd --- /dev/null +++ b/backend/fill_manual_study_aids.py @@ -0,0 +1,29 @@ +"""Deprecated: study aids must come from LLM output, not template fillers.""" + +from __future__ import annotations + +import sys + + +MESSAGE = """ +fill_manual_study_aids.py is intentionally disabled. + +Reason: +- knowledge_reminder / ai_hint / solution must be generated by LLM +- template-based filler content polluted the COMP2211 course library + +Use one of these paths instead: +1. Regenerate study aids through the real LLM pipeline in app/services/paper_processor.py +2. Rebuild paper_questions from a reviewed source and then run LLM generation + +This script must not be used to backfill production study aids. +""".strip() + + +def main() -> None: + print(MESSAGE, file=sys.stderr) + raise SystemExit(1) + + +if __name__ == "__main__": + main() diff --git a/backend/import_course_manifest.py b/backend/import_course_manifest.py new file mode 100644 index 0000000..8d646e7 --- /dev/null +++ b/backend/import_course_manifest.py @@ -0,0 +1,240 @@ +"""Import a canonical course manifest into Supabase-backed papers.""" + +from __future__ import annotations + +import argparse +import asyncio +import json +from pathlib import Path +from typing import Any + +from app.services.paper_processor import process_paper +from app.services.supabase_client import get_supabase + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Import a canonical course paper manifest into Supabase." + ) + parser.add_argument( + "--manifest", + type=Path, + required=True, + help="Path to the manifest JSON file.", + ) + parser.add_argument( + "--papers-root", + type=Path, + required=True, + help="Root folder that contains the course PDF files referenced by the manifest.", + ) + parser.add_argument( + "--user-id", + required=False, + help="Existing auth.users UUID used as the owner of imported course-library rows.", + ) + parser.add_argument( + "--course-code", + help="Optional filter to only import entries from one course.", + ) + parser.add_argument( + "--exam-key", + action="append", + dest="exam_keys", + default=[], + help="Optional exam_key filter. Repeat the flag to import multiple entries.", + ) + parser.add_argument( + "--process", + action="store_true", + help="Run the full paper processing pipeline after the files are uploaded.", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Print what would be imported without uploading or writing database rows.", + ) + return parser.parse_args() + + +def load_manifest(path: Path) -> list[dict[str, Any]]: + with path.open("r", encoding="utf-8") as f: + data = json.load(f) + if not isinstance(data, list): + raise ValueError("Manifest must be a JSON array.") + return data + + +def should_import(entry: dict[str, Any], args: argparse.Namespace) -> bool: + if args.course_code and entry.get("course_code") != args.course_code: + return False + if args.exam_keys and entry.get("exam_key") not in set(args.exam_keys): + return False + return bool(entry.get("importable")) + + +def resolve_file_path(root: Path, filename: str | None) -> Path | None: + if not filename: + return None + + direct = root / filename + if direct.exists(): + return direct + + all_files = [candidate for candidate in root.iterdir() if candidate.is_file()] + + def normalize(name: str) -> str: + return name.replace(" (1)", "") + + target_name = normalize(filename) + normalized = [candidate for candidate in all_files if normalize(candidate.name) == target_name] + if len(normalized) == 1: + return normalized[0] + + path = Path(filename) + normalized_stem = normalize(path.stem) + suffix = path.suffix + stem_matches = [ + candidate + for candidate in all_files + if candidate.suffix == suffix and normalize(candidate.stem) == normalized_stem + ] + if len(stem_matches) == 1: + return stem_matches[0] + + return None + + +def read_file_bytes(root: Path, filename: str | None) -> bytes | None: + if not filename: + return None + path = resolve_file_path(root, filename) + if path is None or not path.exists(): + raise FileNotFoundError(f"Referenced file does not exist under {root}: {filename}") + return path.read_bytes() + + +def build_storage_path(entry: dict[str, Any], kind: str) -> str: + exam_key = entry["exam_key"] + return f"course-library/{entry['course_code']}/{exam_key}/{kind}.pdf" + + +def upsert_paper_record( + entry: dict[str, Any], + user_id: str | None, + paper_url: str, + answer_url: str | None, +) -> str: + sb = get_supabase() + payload = { + "user_id": user_id, + "course_code": entry["course_code"], + "year": entry["year"], + "term": entry["term"], + "exam_type": entry["exam_type"], + "part_label": entry.get("part_label"), + "paper_file_url": paper_url, + "answer_file_url": answer_url, + "status": "processing", + "source_kind": "course_library", + "source_exam_key": entry["exam_key"], + "source_question_filename": entry.get("question_pdf"), + "source_answer_filename": entry.get("primary_answer_pdf"), + } + + existing = ( + sb.table("papers") + .select("id") + .eq("source_kind", "course_library") + .eq("source_exam_key", entry["exam_key"]) + .limit(1) + .execute() + .data + ) + if existing: + paper_id = existing[0]["id"] + sb.table("papers").update(payload).eq("id", paper_id).execute() + return paper_id + + created = sb.table("papers").insert(payload).execute().data + return created[0]["id"] + + +def reset_existing_processed_data(paper_id: str) -> None: + sb = get_supabase() + sb.table("paper_questions").delete().eq("paper_id", paper_id).execute() + sb.table("papers").update( + { + "status": "processing", + "error_message": None, + "paper_extracted_text": None, + "answer_extracted_text": None, + "total_score": None, + "question_count": None, + "topics_summary": None, + "difficulty_level": None, + } + ).eq("id", paper_id).execute() + + +async def import_entry( + entry: dict[str, Any], + args: argparse.Namespace, +) -> None: + paper_bytes = read_file_bytes(args.papers_root, entry.get("question_pdf")) + answer_bytes = read_file_bytes(args.papers_root, entry.get("primary_answer_pdf")) + + if paper_bytes is None: + raise ValueError(f"Importable entry is missing question PDF: {entry['exam_key']}") + + if args.dry_run: + print( + f"[dry-run] {entry['exam_key']}: " + f"question={entry.get('question_pdf')} answer={entry.get('primary_answer_pdf')}" + ) + return + + sb = get_supabase() + paper_path = build_storage_path(entry, "paper") + sb.storage.from_("papers").upload( + paper_path, + paper_bytes, + file_options={"content-type": "application/pdf", "upsert": "true"}, + ) + paper_url = sb.storage.from_("papers").get_public_url(paper_path) + + answer_url = None + if answer_bytes: + answer_path = build_storage_path(entry, "answer") + sb.storage.from_("papers").upload( + answer_path, + answer_bytes, + file_options={"content-type": "application/pdf", "upsert": "true"}, + ) + answer_url = sb.storage.from_("papers").get_public_url(answer_path) + + paper_id = upsert_paper_record(entry, args.user_id, paper_url, answer_url) + print(f"Imported metadata for {entry['exam_key']} -> paper_id={paper_id}") + + if args.process: + reset_existing_processed_data(paper_id) + await process_paper(paper_id, paper_bytes, answer_bytes) + print(f"Processed {entry['exam_key']}") + + +async def main() -> None: + args = parse_args() + manifest = load_manifest(args.manifest) + entries = [entry for entry in manifest if should_import(entry, args)] + + if not entries: + print("No manifest entries matched the provided filters.") + return + + print(f"Preparing to import {len(entries)} manifest entries.") + for entry in entries: + await import_entry(entry, args) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..51babf3 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,17 @@ +[project] +name = "pastpaper-master-backend" +version = "0.1.0" +requires-python = ">=3.11" +dependencies = [ + "fastapi>=0.115.0", + "uvicorn[standard]>=0.30.0", + "python-dotenv>=1.0.0", + "python-multipart>=0.0.9", + "supabase>=2.0.0", + "openai>=1.50.0", + "PyMuPDF>=1.24.0", + "pydantic>=2.0.0", + "pydantic-settings>=2.0.0", + "httpx>=0.27.0", + "numpy>=2.4.4", +] diff --git a/backend/regen_ai_trio_comp2211.py b/backend/regen_ai_trio_comp2211.py new file mode 100644 index 0000000..7650f6f --- /dev/null +++ b/backend/regen_ai_trio_comp2211.py @@ -0,0 +1,174 @@ +"""Regenerate AI trio (knowledge_reminder, ai_hint, solution) for all COMP2211 course-library questions. + +Reads existing paper_questions rows and runs the same BATCH_ANALYSIS_PROMPT used by +paper_processor.py — but does UPDATE instead of INSERT, so question structure is untouched. + +Run from the backend directory: + uv run python regen_ai_trio_comp2211.py + +Pass --dry-run to print batches without calling the LLM or writing to the database. +""" + +from __future__ import annotations + +import asyncio +import json +import sys +from app.services.supabase_client import get_supabase +from app.services.paper_processor import BATCH_ANALYSIS_PROMPT, qwen_json_completion, chunked + + +def build_reference_answer(q: dict) -> str: + if q.get("raw_answer_text"): + return q["raw_answer_text"] + if q.get("correct_option"): + return f"Correct option: {q['correct_option']}" + if q.get("correct_answer"): + return f"Correct answer: {q['correct_answer']}" + return "" + + +async def regen(dry_run: bool = False) -> None: + sb = get_supabase() + + papers = ( + sb.table("papers") + .select("id") + .eq("course_code", "COMP2211") + .eq("source_kind", "course_library") + .execute() + .data + ) + paper_ids = [p["id"] for p in papers] + if not paper_ids: + print("No COMP2211 course-library papers found.") + return + + questions = ( + sb.table("paper_questions") + .select("id, paper_id, question_number, question_type, score, question_text, topics, raw_answer_text, correct_option, correct_answer") + .in_("paper_id", paper_ids) + .order("paper_id") + .order("display_order") + .execute() + .data + ) + print(f"Found {len(questions)} questions across {len(paper_ids)} papers.") + + payloads = [ + { + "question_number": q["question_number"], + "question_type": q["question_type"] or "long_question", + "score": q.get("score") or "unknown", + "question_text": q.get("question_text") or "", + "topics": q.get("topics") or [], + "reference_answer": build_reference_answer(q), + } + for q in questions + ] + + id_by_qnum_paper: dict[tuple[str, str], str] = { + (q["paper_id"], q["question_number"]): q["id"] + for q in questions + } + paper_id_by_qnum: dict[str, str] = { + q["question_number"]: q["paper_id"] for q in questions + } + + # Group payloads by paper so batches don't mix papers (cleaner context for LLM) + from collections import defaultdict + payloads_by_paper: dict[str, list[dict]] = defaultdict(list) + for q, payload in zip(questions, payloads): + payloads_by_paper[q["paper_id"]].append((q["id"], payload)) + + total_updated = 0 + total_papers = len(payloads_by_paper) + + for paper_idx, (paper_id, items) in enumerate(payloads_by_paper.items(), 1): + ids = [item[0] for item in items] + batch_payloads = [item[1] for item in items] + + print(f"\n[{paper_idx}/{total_papers}] paper_id={paper_id} — {len(batch_payloads)} questions") + + for batch_idx, batch in enumerate(chunked(batch_payloads, 3), 1): + print(f" Batch {batch_idx}: questions {[b['question_number'] for b in batch]}", end="", flush=True) + + if dry_run: + print(" [dry-run, skipped]") + continue + + batch_start = (batch_idx - 1) * 3 + batch_ids = ids[batch_start: batch_start + 3] + + async def run_single(row_id: str, payload: dict) -> bool: + try: + r = await qwen_json_completion( + system_prompt=BATCH_ANALYSIS_PROMPT.format( + questions_payload=json.dumps([payload], ensure_ascii=False), + ), + temperature=0.3, + max_tokens=8192, + ) + items = r.get("analyses", []) + if not items: + return False + analysis = items[0] + sb.table("paper_questions").update({ + "knowledge_reminder": analysis.get("knowledge_reminder", ""), + "ai_hint": analysis.get("ai_hint", ""), + "solution": analysis.get("solution", ""), + }).eq("id", row_id).execute() + return True + except Exception: + return False + + try: + result = await qwen_json_completion( + system_prompt=BATCH_ANALYSIS_PROMPT.format( + questions_payload=json.dumps(batch, ensure_ascii=False), + ), + temperature=0.3, + max_tokens=8192, + ) + analyses = {item["question_number"]: item for item in result.get("analyses", [])} + written = 0 + for row_id, payload in zip(batch_ids, batch): + qnum = payload["question_number"] + analysis = analyses.get(qnum) + if not analysis: + # fallback: retry this single question alone + ok = await run_single(row_id, payload) + if ok: + written += 1 + total_updated += 1 + else: + print(f"\n SKIP: {qnum}") + else: + sb.table("paper_questions").update({ + "knowledge_reminder": analysis.get("knowledge_reminder", ""), + "ai_hint": analysis.get("ai_hint", ""), + "solution": analysis.get("solution", ""), + }).eq("id", row_id).execute() + written += 1 + total_updated += 1 + print(f" → {written} written") + except Exception as exc: + # batch failed entirely — retry each question individually + print(f" [batch error, retrying 1-by-1]") + written = 0 + for row_id, payload in zip(batch_ids, batch): + ok = await run_single(row_id, payload) + if ok: + written += 1 + total_updated += 1 + await asyncio.sleep(1) + print(f" → {written}/{len(batch)} written") + + await asyncio.sleep(2.5) + + print(f"\nDone. {total_updated} questions updated.") + + +if __name__ == "__main__": + dry_run = "--dry-run" in sys.argv + asyncio.run(regen(dry_run=dry_run)) diff --git a/backend/regenerate_analysis.py b/backend/regenerate_analysis.py new file mode 100644 index 0000000..7ba3eaf --- /dev/null +++ b/backend/regenerate_analysis.py @@ -0,0 +1,69 @@ +"""Re-generate AI trio (knowledge_reminder, ai_hint, solution) in English for existing questions.""" + +import json +import asyncio +from app.services.supabase_client import get_supabase +from app.services.llm_clients import get_qwen_client +from app.services.paper_processor import ANALYSIS_PROMPT + + +async def regenerate_for_paper(paper_id: str): + sb = get_supabase() + qwen = get_qwen_client() + + questions = sb.table("paper_questions").select("*").eq("paper_id", paper_id).order("display_order").execute().data + print(f"Found {len(questions)} questions for paper {paper_id[:8]}") + + for q in questions: + qnum = q["question_number"] + print(f" Regenerating Q{qnum}...", end=" ", flush=True) + + answer_section = "" + if q.get("raw_answer_text"): + answer_section = f"- Reference answer: {q['raw_answer_text']}" + elif q.get("correct_option"): + answer_section = f"- Correct option: {q['correct_option']}" + elif q.get("correct_answer"): + answer_section = f"- Correct answer: {q['correct_answer']}" + + resp = qwen.chat.completions.create( + model="qwen-plus", + messages=[ + {"role": "system", "content": ANALYSIS_PROMPT.format( + question_number=qnum, + question_type=q["question_type"], + score=q.get("score", "unknown"), + question_text=q["question_text"], + topics=", ".join(q.get("topics", [])), + answer_section=answer_section, + )}, + ], + temperature=0.3, + response_format={"type": "json_object"}, + ) + analysis = json.loads(resp.choices[0].message.content) + + sb.table("paper_questions").update({ + "knowledge_reminder": analysis.get("knowledge_reminder", ""), + "ai_hint": analysis.get("ai_hint", ""), + "solution": analysis.get("solution", ""), + }).eq("id", q["id"]).execute() + + print("done") + + print(f"All questions regenerated for paper {paper_id[:8]}") + + +async def main(): + sb = get_supabase() + papers = sb.table("papers").select("id,course_code,year,term").eq("status", "ready").order("created_at", desc=True).execute().data + + for p in papers: + print(f"\n=== {p['course_code']} {p['year']} {p['term']} ===") + await regenerate_for_paper(p["id"]) + + print("\nAll done!") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/split_comp2211_2022_spring_final_part_a.py b/backend/split_comp2211_2022_spring_final_part_a.py new file mode 100644 index 0000000..93b3fef --- /dev/null +++ b/backend/split_comp2211_2022_spring_final_part_a.py @@ -0,0 +1,224 @@ +"""Split COMP2211 Spring 2022 final part A into subquestions.""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass +from pathlib import Path + +from app.services.supabase_client import get_supabase + + +EXAM_KEY = "COMP2211-2022-spring-final-part-a" +TRUE_FALSE_OPTIONS = [{"label": "True", "text": "True"}, {"label": "False", "text": "False"}] +PROBLEM_SEED_PATH = ( + Path(__file__).resolve().parent.parent + / "pastpaper-scraper" + / "reviews" + / "COMP2211" + / "problem_seed.json" +) + + +@dataclass(frozen=True) +class ChildSpec: + question_number: str + parent_question: str + top_level_number: str + path: tuple[str, ...] + score: float + question_type: str + question_format: str | None = None + analytics_topic: str | None = None + topic_primary: str | None = None + topic_tags: tuple[str, ...] | None = None + skill_tags: tuple[str, ...] | None = None + page_number: int = 1 + + +def short_answer( + question_number: str, + parent_question: str, + top_level_number: str, + path: tuple[str, ...], + score: float, + *, + analytics_topic: str | None = None, + topic_primary: str | None = None, + topic_tags: tuple[str, ...] | None = None, + skill_tags: tuple[str, ...] | None = None, + page_number: int, +) -> ChildSpec: + return ChildSpec( + question_number=question_number, + parent_question=parent_question, + top_level_number=top_level_number, + path=path, + score=score, + question_type="long_question", + question_format="short_answer", + analytics_topic=analytics_topic, + topic_primary=topic_primary, + topic_tags=topic_tags, + skill_tags=skill_tags, + page_number=page_number, + ) + + +CHILDREN: list[ChildSpec] = [ + ChildSpec("1a", "1", "1", ("a",), 1, "true_false", "true_false", "KNN and Clustering", "KNN and Clustering", ("KNN and Clustering",), ("concept_check", "algorithm_property"), page_number=2), + ChildSpec("1b", "1", "1", ("b",), 1, "true_false", "true_false", "Perceptron and MLP", "Perceptron and MLP", ("Perceptron and MLP",), ("concept_check", "architecture_reasoning"), page_number=2), + ChildSpec("1c", "1", "1", ("c",), 1, "true_false", "true_false", "Perceptron and MLP", "Perceptron and MLP", ("Perceptron and MLP",), ("concept_check", "activation_selection"), page_number=2), + ChildSpec("1d", "1", "1", ("d",), 1, "true_false", "true_false", "Evaluation and Validation", "Evaluation and Validation", ("Evaluation and Validation",), ("concept_check", "metric_reasoning"), page_number=2), + ChildSpec("1e", "1", "1", ("e",), 1, "true_false", "true_false", "Vision and CNN", "Vision and CNN", ("Vision and CNN",), ("concept_check", "hardware_reasoning"), page_number=2), + ChildSpec("1f", "1", "1", ("f",), 1, "true_false", "true_false", "Vision and CNN", "Vision and CNN", ("Vision and CNN",), ("concept_check", "image_processing"), page_number=2), + ChildSpec("1g", "1", "1", ("g",), 1, "true_false", "true_false", "Vision and CNN", "Vision and CNN", ("Vision and CNN",), ("concept_check", "cnn_architecture"), page_number=2), + ChildSpec("1h", "1", "1", ("h",), 1, "true_false", "true_false", "Perceptron and MLP", "Perceptron and MLP", ("Perceptron and MLP",), ("concept_check", "regularization"), page_number=2), + ChildSpec("1i", "1", "1", ("i",), 1, "true_false", "true_false", "Search and Games", "Search and Games", ("Search and Games",), ("concept_check", "game_reasoning"), page_number=2), + ChildSpec("1j", "1", "1", ("j",), 1, "true_false", "true_false", "Search and Games", "Search and Games", ("Search and Games",), ("concept_check", "pruning_reasoning"), page_number=2), + ChildSpec("2a", "2", "2", ("a",), 6.5, "long_question", "long_answer", "Probabilistic Models", "Probabilistic Models", ("Probabilistic Models",), ("manual_computation", "probability_reasoning", "classification_decision"), page_number=4), + ChildSpec("2b", "2", "2", ("b",), 7.5, "long_question", "long_answer", "KNN and Clustering", "KNN and Clustering", ("KNN and Clustering",), ("distance_calculation", "algorithm_tracing", "classification_decision"), page_number=4), + short_answer("3a", "3", "3", ("a",), 3, analytics_topic="Evaluation and Validation", topic_primary="Evaluation and Validation", topic_tags=("Evaluation and Validation",), skill_tags=("concept_explanation", "metric_reasoning"), page_number=6), + short_answer("3b", "3", "3", ("b",), 2, analytics_topic="Perceptron and MLP", topic_primary="Perceptron and MLP", topic_tags=("Perceptron and MLP",), skill_tags=("concept_explanation", "activation_selection"), page_number=6), + short_answer("3c", "3", "3", ("c",), 2, analytics_topic="Perceptron and MLP", topic_primary="Perceptron and MLP", topic_tags=("Perceptron and MLP",), skill_tags=("architecture_reasoning", "output_layer_design"), page_number=6), + short_answer("3d", "3", "3", ("d",), 3, analytics_topic="Perceptron and MLP", topic_primary="Perceptron and MLP", topic_tags=("Perceptron and MLP",), skill_tags=("concept_explanation", "optimization_reasoning"), page_number=6), + short_answer("3e_i", "3e", "3", ("e", "i"), 1, analytics_topic="Perceptron and MLP", topic_primary="Perceptron and MLP", topic_tags=("Perceptron and MLP",), skill_tags=("optimization_reasoning",), page_number=6), + short_answer("3e_ii", "3e", "3", ("e", "ii"), 1, analytics_topic="Perceptron and MLP", topic_primary="Perceptron and MLP", topic_tags=("Perceptron and MLP",), skill_tags=("optimization_reasoning",), page_number=6), + short_answer("3f", "3", "3", ("f",), 2, analytics_topic="Perceptron and MLP", topic_primary="Perceptron and MLP", topic_tags=("Perceptron and MLP",), skill_tags=("regularization", "concept_explanation"), page_number=6), + ChildSpec("4a_i", "4a", "4", ("a", "i"), 2, "fill_blank", "fill_blank", page_number=7), + ChildSpec("4a_ii", "4a", "4", ("a", "ii"), 2, "long_question", "long_answer", page_number=7), + ChildSpec("4b_i", "4b", "4", ("b", "i"), 3, "fill_blank", "fill_blank", page_number=7), + ChildSpec("4b_ii", "4b", "4", ("b", "ii"), 4, "fill_blank", "fill_blank", page_number=7), + ChildSpec("4b_iii", "4b", "4", ("b", "iii"), 4, "long_question", "long_answer", page_number=7), +] + + +MARKER_RE = re.compile(r"(?m)^\(([a-z]+|[ivx]+)\)\s*") + + +def split_sections(text: str) -> tuple[str, dict[str, str]]: + matches = list(MARKER_RE.finditer(text)) + if not matches: + return text.strip(), {} + intro = text[: matches[0].start()].strip() + sections: dict[str, str] = {} + for idx, match in enumerate(matches): + marker = match.group(1) + end = matches[idx + 1].start() if idx + 1 < len(matches) else len(text) + sections[marker] = text[match.start() : end].strip() + return intro, sections + + +def extract_segment(text: str, path: tuple[str, ...]) -> str: + current = text.strip() + carried_intro: list[str] = [] + for depth, marker in enumerate(path): + intro, sections = split_sections(current) + if depth == 0 and intro: + carried_intro.append(intro) + current = sections.get(marker, current) + return "\n".join(part for part in [*carried_intro, current] if part).strip() + + +def extract_true_false_answers(answer_text: str) -> dict[str, str]: + answers: dict[str, str] = {} + matches = list(re.finditer(r"(?m)^\(([a-j])\)\s*\n?([TF])\b", answer_text)) + for match in matches: + answers[match.group(1)] = match.group(2) + return answers + + +def derive_correct_answer(answer_text: str) -> str | None: + if not answer_text: + return None + tail = answer_text.split("Answer:", 1)[1] if "Answer:" in answer_text else answer_text + lines = [line.strip() for line in tail.splitlines() if line.strip()] + if not lines: + return None + first = lines[0] + if first.lower().startswith("marking scheme"): + return None + if len(first) <= 240: + return first + return None + + +def load_seed_rows() -> dict[str, dict]: + data = json.loads(PROBLEM_SEED_PATH.read_text()) + return { + row["question_number"]: row + for row in data + if row["source_exam_key"] == EXAM_KEY + } + + +def main() -> None: + sb = get_supabase() + paper = sb.table("papers").select("id").eq("source_exam_key", EXAM_KEY).execute().data[0] + paper_id = paper["id"] + + current_rows = ( + sb.table("paper_questions") + .select("*") + .eq("paper_id", paper_id) + .order("display_order") + .execute() + .data + ) + existing_by_number = {row["question_number"]: row for row in current_rows} + parent_rows = load_seed_rows() + tf_answers = extract_true_false_answers(parent_rows["1"]["raw_answer_text"] or "") + + inserts = [] + for display_order, child in enumerate(CHILDREN, start=1): + parent = parent_rows[child.top_level_number] + existing = existing_by_number.get(child.question_number, {}) + question_text = extract_segment(parent["question_text"] or "", child.path) + raw_answer_text = extract_segment(parent["raw_answer_text"] or "", child.path) + + correct_option = None + correct_answer = None + options = None + if child.question_type == "true_false": + correct_option = tf_answers.get(child.path[0]) + options = TRUE_FALSE_OPTIONS + elif child.question_type == "fill_blank": + correct_answer = derive_correct_answer(raw_answer_text) + + inserts.append( + { + "paper_id": paper_id, + "question_number": child.question_number, + "parent_question": child.parent_question, + "display_order": display_order, + "question_type": child.question_type, + "question_format": child.question_format, + "question_text": question_text, + "score": child.score, + "page_number": child.page_number, + "page_y_ratio": existing.get("page_y_ratio"), + "options": options, + "correct_option": correct_option, + "correct_answer": correct_answer, + "raw_answer_text": raw_answer_text, + "topics": existing.get("topics") or (list(child.topic_tags) if child.topic_tags else parent.get("topics")), + "topic_primary": existing.get("topic_primary") or child.topic_primary or parent.get("topic_primary"), + "analytics_topic": existing.get("analytics_topic") or child.analytics_topic or parent.get("analytics_topic"), + "topic_tags": existing.get("topic_tags") or (list(child.topic_tags) if child.topic_tags else parent.get("topic_tags")), + "skill_tags": existing.get("skill_tags") or (list(child.skill_tags) if child.skill_tags else parent.get("skill_tags")), + "difficulty": existing.get("difficulty") or parent.get("difficulty"), + "knowledge_reminder": existing.get("knowledge_reminder", ""), + "ai_hint": existing.get("ai_hint", ""), + "solution": existing.get("solution", ""), + } + ) + + sb.table("paper_questions").delete().eq("paper_id", paper_id).execute() + sb.table("paper_questions").insert(inserts).execute() + sb.table("papers").update({"question_count": len(inserts), "status": "processing"}).eq("id", paper_id).execute() + print(f"Inserted {len(inserts)} rows for {EXAM_KEY}.") + + +if __name__ == "__main__": + main() diff --git a/backend/split_comp2211_2022_spring_final_part_b.py b/backend/split_comp2211_2022_spring_final_part_b.py new file mode 100644 index 0000000..be30ada --- /dev/null +++ b/backend/split_comp2211_2022_spring_final_part_b.py @@ -0,0 +1,232 @@ +"""Split COMP2211 Spring 2022 final part B into subquestions.""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass +from pathlib import Path + +from app.services.supabase_client import get_supabase + + +EXAM_KEY = "COMP2211-2022-spring-final-part-b" +PROBLEM_SEED_PATH = ( + Path(__file__).resolve().parent.parent + / "pastpaper-scraper" + / "reviews" + / "COMP2211" + / "problem_seed.json" +) + + +@dataclass(frozen=True) +class ChildSpec: + question_number: str + parent_question: str + top_level_number: str + path: tuple[str, ...] + score: float + question_type: str + question_format: str | None = None + analytics_topic: str | None = None + topic_primary: str | None = None + topic_tags: tuple[str, ...] | None = None + skill_tags: tuple[str, ...] | None = None + options: tuple[tuple[str, str], ...] | None = None + correct_option: str | None = None + correct_answer: str | None = None + page_number: int = 1 + + +def short_answer( + question_number: str, + parent_question: str, + top_level_number: str, + path: tuple[str, ...], + score: float, + *, + analytics_topic: str | None = None, + topic_primary: str | None = None, + topic_tags: tuple[str, ...] | None = None, + skill_tags: tuple[str, ...] | None = None, + correct_answer: str | None = None, + page_number: int, +) -> ChildSpec: + return ChildSpec( + question_number=question_number, + parent_question=parent_question, + top_level_number=top_level_number, + path=path, + score=score, + question_type="long_question", + question_format="short_answer", + analytics_topic=analytics_topic, + topic_primary=topic_primary, + topic_tags=topic_tags, + skill_tags=skill_tags, + correct_answer=correct_answer, + page_number=page_number, + ) + + +def mc( + question_number: str, + parent_question: str, + top_level_number: str, + path: tuple[str, ...], + score: float, + *, + options: tuple[tuple[str, str], ...], + correct_option: str, + analytics_topic: str, + skill_tags: tuple[str, ...], + page_number: int, +) -> ChildSpec: + return ChildSpec( + question_number=question_number, + parent_question=parent_question, + top_level_number=top_level_number, + path=path, + score=score, + question_type="mc", + question_format="mc", + analytics_topic=analytics_topic, + topic_primary=analytics_topic, + topic_tags=(analytics_topic,), + skill_tags=skill_tags, + options=options, + correct_option=correct_option, + page_number=page_number, + ) + + +ETHICS_ABCD = ( + ("A", "A"), + ("B", "B"), + ("C", "C"), + ("D", "D"), +) + + +CHILDREN: list[ChildSpec] = [ + ChildSpec("1a", "1", "1", ("a",), 1.5, "long_question", "long_answer", page_number=2), + short_answer("1b", "1", "1", ("b",), 1.5, analytics_topic="Vision and CNN", topic_primary="Vision and CNN", topic_tags=("Vision and CNN",), skill_tags=("concept_explanation", "data_augmentation"), page_number=2), + ChildSpec("1c", "1", "1", ("c",), 4.5, "long_question", "long_answer", page_number=2), + short_answer("1d", "1", "1", ("d",), 2, analytics_topic="Vision and CNN", topic_primary="Vision and CNN", topic_tags=("Vision and CNN",), skill_tags=("architecture_reasoning", "parameter_reduction"), page_number=3), + ChildSpec("1e", "1", "1", ("e",), 2.5, "fill_blank", "fill_blank", correct_answer="1558656", page_number=3), + ChildSpec("1f_i", "1f", "1", ("f", "i"), 2.5, "fill_blank", "fill_blank", correct_answer="2071656", page_number=3), + ChildSpec("1f_ii", "1f", "1", ("f", "ii"), 2.5, "fill_blank", "fill_blank", correct_answer="150529000", page_number=4), + short_answer("1g", "1", "1", ("g",), 2, analytics_topic="Vision and CNN", topic_primary="Vision and CNN", topic_tags=("Vision and CNN",), skill_tags=("architecture_reasoning", "comparison"), page_number=4), + ChildSpec("2a", "2", "2", ("a",), 9, "long_question", "coding", page_number=5), + short_answer("2b", "2", "2", ("b",), 4, analytics_topic="Vision and CNN", topic_primary="Vision and CNN", topic_tags=("Vision and CNN",), skill_tags=("architecture_reasoning", "regression_reasoning"), page_number=6), + ChildSpec("3a", "3", "3", ("a",), 3.5, "long_question", "long_answer", page_number=9), + short_answer("3b", "3", "3", ("b",), 0.5, analytics_topic="Search and Games", topic_primary="Search and Games", topic_tags=("Search and Games",), skill_tags=("game_reasoning",), correct_answer="E-a", page_number=9), + short_answer("3c", "3", "3", ("c",), 1.5, analytics_topic="Search and Games", topic_primary="Search and Games", topic_tags=("Search and Games",), skill_tags=("concept_explanation", "game_reasoning"), page_number=9), + short_answer("3d", "3", "3", ("d",), 2.5, analytics_topic="Search and Games", topic_primary="Search and Games", topic_tags=("Search and Games",), skill_tags=("pruning_reasoning",), correct_answer="E-j and E-f", page_number=9), + mc("4a", "4", "4", ("a",), 1, options=ETHICS_ABCD, correct_option="C", analytics_topic="Ethics of AI", skill_tags=("concept_check", "ethical_reasoning"), page_number=10), + mc("4b", "4", "4", ("b",), 1, options=ETHICS_ABCD, correct_option="A", analytics_topic="Ethics of AI", skill_tags=("concept_check", "bias_reasoning"), page_number=10), + mc("4c", "4", "4", ("c",), 1, options=ETHICS_ABCD, correct_option="C", analytics_topic="Ethics of AI", skill_tags=("concept_check", "ethical_reasoning"), page_number=10), + mc("4d", "4", "4", ("d",), 1, options=ETHICS_ABCD, correct_option="B", analytics_topic="Ethics of AI", skill_tags=("concept_check", "bias_reasoning"), page_number=10), + short_answer("4e", "4", "4", ("e",), 3, analytics_topic="Ethics of AI", topic_primary="Ethics of AI", topic_tags=("Ethics of AI",), skill_tags=("argumentation", "concept_explanation"), page_number=11), +] + + +MARKER_RE = re.compile(r"(?m)^\(([a-z]+|[ivx]+)\)\s*") + + +def split_sections(text: str) -> tuple[str, dict[str, str]]: + matches = list(MARKER_RE.finditer(text)) + if not matches: + return text.strip(), {} + intro = text[: matches[0].start()].strip() + sections: dict[str, str] = {} + for idx, match in enumerate(matches): + marker = match.group(1) + end = matches[idx + 1].start() if idx + 1 < len(matches) else len(text) + sections[marker] = text[match.start() : end].strip() + return intro, sections + + +def extract_segment(text: str, path: tuple[str, ...]) -> str: + current = text.strip() + carried_intro: list[str] = [] + for depth, marker in enumerate(path): + intro, sections = split_sections(current) + if depth == 0 and intro: + carried_intro.append(intro) + current = sections.get(marker, current) + return "\n".join(part for part in [*carried_intro, current] if part).strip() + + +def load_seed_rows() -> dict[str, dict]: + data = json.loads(PROBLEM_SEED_PATH.read_text()) + return { + row["question_number"]: row + for row in data + if row["source_exam_key"] == EXAM_KEY + } + + +def main() -> None: + sb = get_supabase() + paper = sb.table("papers").select("id").eq("source_exam_key", EXAM_KEY).execute().data[0] + paper_id = paper["id"] + + current_rows = ( + sb.table("paper_questions") + .select("*") + .eq("paper_id", paper_id) + .order("display_order") + .execute() + .data + ) + existing_by_number = {row["question_number"]: row for row in current_rows} + parent_rows = load_seed_rows() + + inserts = [] + for display_order, child in enumerate(CHILDREN, start=1): + parent = parent_rows[child.top_level_number] + existing = existing_by_number.get(child.question_number, {}) + question_text = extract_segment(parent["question_text"] or "", child.path) + raw_answer_text = extract_segment(parent["raw_answer_text"] or "", child.path) + options = None + if child.options: + options = [{"label": label, "text": text} for label, text in child.options] + + inserts.append( + { + "paper_id": paper_id, + "question_number": child.question_number, + "parent_question": child.parent_question, + "display_order": display_order, + "question_type": child.question_type, + "question_format": child.question_format, + "question_text": question_text, + "score": child.score, + "page_number": child.page_number, + "page_y_ratio": existing.get("page_y_ratio"), + "options": options, + "correct_option": child.correct_option, + "correct_answer": child.correct_answer, + "raw_answer_text": raw_answer_text, + "topics": existing.get("topics") or (list(child.topic_tags) if child.topic_tags else parent.get("topics")), + "topic_primary": existing.get("topic_primary") or child.topic_primary or parent.get("topic_primary"), + "analytics_topic": existing.get("analytics_topic") or child.analytics_topic or parent.get("analytics_topic"), + "topic_tags": existing.get("topic_tags") or (list(child.topic_tags) if child.topic_tags else parent.get("topic_tags")), + "skill_tags": existing.get("skill_tags") or (list(child.skill_tags) if child.skill_tags else parent.get("skill_tags")), + "difficulty": existing.get("difficulty") or parent.get("difficulty"), + "knowledge_reminder": existing.get("knowledge_reminder", ""), + "ai_hint": existing.get("ai_hint", ""), + "solution": existing.get("solution", ""), + } + ) + + sb.table("paper_questions").delete().eq("paper_id", paper_id).execute() + sb.table("paper_questions").insert(inserts).execute() + sb.table("papers").update({"question_count": len(inserts), "status": "processing"}).eq("id", paper_id).execute() + print(f"Inserted {len(inserts)} rows for {EXAM_KEY}.") + + +if __name__ == "__main__": + main() diff --git a/backend/split_comp2211_2022_spring_midterm.py b/backend/split_comp2211_2022_spring_midterm.py new file mode 100644 index 0000000..2372c8b --- /dev/null +++ b/backend/split_comp2211_2022_spring_midterm.py @@ -0,0 +1,233 @@ +"""Split COMP2211 Spring 2022 midterm top-level problems into subquestions.""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass +from pathlib import Path + +from app.services.supabase_client import get_supabase + + +EXAM_KEY = "COMP2211-2022-spring-midterm" +TRUE_FALSE_OPTIONS = [{"label": "True", "text": "True"}, {"label": "False", "text": "False"}] + + +@dataclass(frozen=True) +class ChildSpec: + question_number: str + parent_question: str + top_level_number: str + path: tuple[str, ...] + score: float + question_type: str + question_format: str | None = None + page_number: int = 1 + + +def short_answer( + question_number: str, + parent_question: str, + top_level_number: str, + path: tuple[str, ...], + score: float, + *, + page_number: int, +) -> ChildSpec: + return ChildSpec( + question_number=question_number, + parent_question=parent_question, + top_level_number=top_level_number, + path=path, + score=score, + question_type="long_question", + question_format="short_answer", + page_number=page_number, + ) + + +CHILDREN: list[ChildSpec] = [ + *[ + ChildSpec(f"1{letter}", "1", "1", (letter,), 1.5, "true_false", page_number=2) + for letter in "abcdefghij" + ], + ChildSpec("2a_i", "2a", "2", ("a", "i"), 1, "fill_blank", page_number=4), + ChildSpec("2a_ii", "2a", "2", ("a", "ii"), 1, "fill_blank", page_number=4), + ChildSpec("2a_iii", "2a", "2", ("a", "iii"), 1, "fill_blank", page_number=4), + ChildSpec("2a_iv", "2a", "2", ("a", "iv"), 1, "fill_blank", page_number=4), + ChildSpec("2a_v", "2a", "2", ("a", "v"), 1, "fill_blank", page_number=4), + ChildSpec("2b", "2", "2", ("b",), 2, "fill_blank", page_number=4), + ChildSpec("2c", "2", "2", ("c",), 9, "long_question", "coding", page_number=5), + ChildSpec("3a", "3", "3", ("a",), 2, "fill_blank", page_number=7), + ChildSpec("3b_i", "3b", "3", ("b", "i"), 1.75, "fill_blank", page_number=7), + ChildSpec("3b_ii", "3b", "3", ("b", "ii"), 1.75, "fill_blank", page_number=7), + ChildSpec("3b_iii", "3b", "3", ("b", "iii"), 1.75, "fill_blank", page_number=7), + ChildSpec("3b_iv", "3b", "3", ("b", "iv"), 1.75, "fill_blank", page_number=7), + short_answer("3c", "3", "3", ("c",), 2, page_number=8), + ChildSpec("4a", "4", "4", ("a",), 3, "long_question", "long_answer", page_number=9), + short_answer("4b_i", "4b", "4", ("b", "i"), 3, page_number=9), + short_answer("4b_ii", "4b", "4", ("b", "ii"), 3, page_number=9), + ChildSpec("4c_i", "4c", "4", ("c", "i"), 2, "long_question", "long_answer", page_number=10), + ChildSpec("4c_ii", "4c", "4", ("c", "ii"), 3, "long_question", "long_answer", page_number=10), + ChildSpec("5a", "5", "5", ("a",), 4.5, "long_question", "long_answer", page_number=11), + ChildSpec("5b", "5", "5", ("b",), 1.5, "fill_blank", page_number=11), + ChildSpec("5c", "5", "5", ("c",), 4.5, "long_question", "long_answer", page_number=11), + short_answer("5d", "5", "5", ("d",), 1.5, page_number=11), + ChildSpec("6a", "6", "6", ("a",), 8, "long_question", "long_answer", page_number=12), + short_answer("6b", "6", "6", ("b",), 2, page_number=13), + ChildSpec("6c", "6", "6", ("c",), 10, "long_question", "coding", page_number=13), + short_answer("7a", "7", "7", ("a",), 4, page_number=14), + short_answer("7b", "7", "7", ("b",), 6, page_number=14), + ChildSpec("7c", "7", "7", ("c",), 2, "fill_blank", page_number=15), +] + + +MARKER_RE = re.compile(r"(?m)^\(([a-z]+)\)\s*") +PROBLEM_SEED_PATH = ( + Path(__file__).resolve().parent.parent + / "pastpaper-scraper" + / "reviews" + / "COMP2211" + / "problem_seed.json" +) + + +def split_sections(text: str) -> tuple[str, dict[str, str]]: + matches = list(MARKER_RE.finditer(text)) + if not matches: + return text.strip(), {} + intro = text[: matches[0].start()].strip() + sections: dict[str, str] = {} + for idx, match in enumerate(matches): + marker = match.group(1) + end = matches[idx + 1].start() if idx + 1 < len(matches) else len(text) + sections[marker] = text[match.start() : end].strip() + return intro, sections + + +def extract_segment(text: str, path: tuple[str, ...]) -> str: + intro, sections = split_sections(text) + if not path: + return text.strip() + first = sections.get(path[0], "") + if not first: + return text.strip() + if len(path) == 1: + return "\n".join(part for part in [intro, first] if part).strip() + child_intro, child_sections = split_sections(first) + second = child_sections.get(path[1], "") + return "\n".join(part for part in [intro, child_intro, second] if part).strip() + + +def extract_true_false_answers(answer_text: str) -> dict[str, str]: + answers: dict[str, str] = {} + matches = list(re.finditer(r"(?m)^\(([a-j])\)\s*\n?([TF])\b", answer_text)) + for match in matches: + answers[match.group(1)] = match.group(2) + return answers + + +def derive_correct_answer(answer_text: str) -> str | None: + if not answer_text: + return None + if "Answer:" in answer_text: + tail = answer_text.split("Answer:", 1)[1] + else: + tail = answer_text + lines = [line.strip() for line in tail.splitlines() if line.strip()] + if not lines: + return None + first = lines[0] + if first.lower().startswith("marking scheme"): + return None + if len(first) <= 240: + return first + return None + + +def load_seed_rows() -> dict[str, dict]: + data = json.loads(PROBLEM_SEED_PATH.read_text()) + return { + row["question_number"]: row + for row in data + if row["source_exam_key"] == EXAM_KEY + } + + +def main() -> None: + sb = get_supabase() + paper = ( + sb.table("papers") + .select("id") + .eq("source_exam_key", EXAM_KEY) + .execute() + .data[0] + ) + paper_id = paper["id"] + + current_rows = ( + sb.table("paper_questions") + .select("*") + .eq("paper_id", paper_id) + .order("display_order") + .execute() + .data + ) + existing_by_number = {row["question_number"]: row for row in current_rows} + parent_rows = load_seed_rows() + tf_answers = extract_true_false_answers(parent_rows["1"]["raw_answer_text"] or "") + + inserts = [] + for display_order, child in enumerate(CHILDREN, start=1): + parent = parent_rows[child.top_level_number] + existing = existing_by_number.get(child.question_number, {}) + question_text = extract_segment(parent["question_text"] or "", child.path) + raw_answer_text = extract_segment(parent["raw_answer_text"] or "", child.path) + + correct_option = None + correct_answer = None + options = None + if child.question_type == "true_false": + marker = child.path[0] + correct_option = tf_answers.get(marker) + options = TRUE_FALSE_OPTIONS + elif child.question_type == "fill_blank": + correct_answer = derive_correct_answer(raw_answer_text) + + inserts.append( + { + "paper_id": paper_id, + "question_number": child.question_number, + "parent_question": child.parent_question, + "display_order": display_order, + "question_type": child.question_type, + "question_format": child.question_format, + "question_text": question_text, + "score": child.score, + "page_number": child.page_number, + "page_y_ratio": existing.get("page_y_ratio"), + "options": options, + "correct_option": correct_option, + "correct_answer": correct_answer, + "raw_answer_text": raw_answer_text, + "topics": existing.get("topics") or parent.get("topics"), + "topic_primary": existing.get("topic_primary") or parent.get("topic_primary"), + "analytics_topic": existing.get("analytics_topic") or parent.get("analytics_topic"), + "topic_tags": existing.get("topic_tags") or parent.get("topic_tags"), + "skill_tags": existing.get("skill_tags") or parent.get("skill_tags"), + "difficulty": existing.get("difficulty") or parent.get("difficulty"), + "knowledge_reminder": existing.get("knowledge_reminder", ""), + "ai_hint": existing.get("ai_hint", ""), + "solution": existing.get("solution", ""), + } + ) + + sb.table("paper_questions").delete().eq("paper_id", paper_id).execute() + sb.table("paper_questions").insert(inserts).execute() + sb.table("papers").update({"question_count": len(inserts), "status": "processing"}).eq("id", paper_id).execute() + print(f"Inserted {len(inserts)} rows for {EXAM_KEY}.") + + +if __name__ == "__main__": + main() diff --git a/backend/split_comp2211_2023_spring_midterm.py b/backend/split_comp2211_2023_spring_midterm.py new file mode 100644 index 0000000..4e13c29 --- /dev/null +++ b/backend/split_comp2211_2023_spring_midterm.py @@ -0,0 +1,268 @@ +"""Split COMP2211 Spring 2023 midterm into subquestions.""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass +from pathlib import Path + +from app.services.supabase_client import get_supabase + + +EXAM_KEY = "COMP2211-2023-spring-midterm" +PROBLEM_SEED_PATH = ( + Path(__file__).resolve().parent.parent + / "pastpaper-scraper" + / "reviews" + / "COMP2211" + / "problem_seed.json" +) +TRUE_FALSE_OPTIONS = [{"label": "True", "text": "True"}, {"label": "False", "text": "False"}] + + +@dataclass(frozen=True) +class ChildSpec: + question_number: str + parent_question: str + top_level_number: str + path: tuple[str, ...] + score: float + question_type: str + question_format: str | None = None + analytics_topic: str | None = None + topic_primary: str | None = None + topic_tags: tuple[str, ...] | None = None + skill_tags: tuple[str, ...] | None = None + options: tuple[tuple[str, str], ...] | None = None + correct_option: str | None = None + correct_answer: str | None = None + page_number: int = 1 + + +def short_answer( + question_number: str, + parent_question: str, + top_level_number: str, + path: tuple[str, ...], + score: float, + *, + analytics_topic: str | None = None, + topic_primary: str | None = None, + topic_tags: tuple[str, ...] | None = None, + skill_tags: tuple[str, ...] | None = None, + correct_answer: str | None = None, + page_number: int, +) -> ChildSpec: + return ChildSpec( + question_number=question_number, + parent_question=parent_question, + top_level_number=top_level_number, + path=path, + score=score, + question_type="long_question", + question_format="short_answer", + analytics_topic=analytics_topic, + topic_primary=topic_primary, + topic_tags=topic_tags, + skill_tags=skill_tags, + correct_answer=correct_answer, + page_number=page_number, + ) + + +def mc( + question_number: str, + parent_question: str, + top_level_number: str, + path: tuple[str, ...], + score: float, + *, + options: tuple[tuple[str, str], ...], + correct_option: str, + analytics_topic: str, + skill_tags: tuple[str, ...], + page_number: int, +) -> ChildSpec: + return ChildSpec( + question_number=question_number, + parent_question=parent_question, + top_level_number=top_level_number, + path=path, + score=score, + question_type="mc", + question_format="mc", + analytics_topic=analytics_topic, + topic_primary=analytics_topic, + topic_tags=(analytics_topic,), + skill_tags=skill_tags, + options=options, + correct_option=correct_option, + page_number=page_number, + ) + + +ABCDE = (("A", "A"), ("B", "B"), ("C", "C"), ("D", "D"), ("E", "E")) + + +CHILDREN: list[ChildSpec] = [ + ChildSpec("1a", "1", "1", ("a",), 1, "true_false", "true_false", "Probabilistic Models", "Probabilistic Models", ("Probabilistic Models",), ("concept_check", "classification_decision"), page_number=3), + ChildSpec("1b", "1", "1", ("b",), 1, "true_false", "true_false", "Probabilistic Models", "Probabilistic Models", ("Probabilistic Models",), ("concept_check", "classification_decision"), page_number=3), + ChildSpec("1c", "1", "1", ("c",), 1, "true_false", "true_false", "KNN and Clustering", "KNN and Clustering", ("KNN and Clustering",), ("concept_check", "algorithm_property"), page_number=3), + ChildSpec("1d", "1", "1", ("d",), 1, "true_false", "true_false", "KNN and Clustering", "KNN and Clustering", ("KNN and Clustering",), ("concept_check", "distance_reasoning"), page_number=3), + ChildSpec("1e", "1", "1", ("e",), 1, "true_false", "true_false", "Evaluation and Validation", "Evaluation and Validation", ("Evaluation and Validation",), ("concept_check", "validation_reasoning"), page_number=3), + ChildSpec("1f", "1", "1", ("f",), 1, "true_false", "true_false", "KNN and Clustering", "KNN and Clustering", ("KNN and Clustering",), ("concept_check", "algorithm_property"), page_number=3), + ChildSpec("1g", "1", "1", ("g",), 1, "true_false", "true_false", "KNN and Clustering", "KNN and Clustering", ("KNN and Clustering",), ("concept_check", "robustness_reasoning"), page_number=3), + ChildSpec("1h", "1", "1", ("h",), 1, "true_false", "true_false", "Perceptron and MLP", "Perceptron and MLP", ("Perceptron and MLP",), ("concept_check", "decision_boundary"), page_number=3), + ChildSpec("1i", "1", "1", ("i",), 1, "true_false", "true_false", "Perceptron and MLP", "Perceptron and MLP", ("Perceptron and MLP",), ("concept_check", "optimization_reasoning"), page_number=3), + ChildSpec("1j", "1", "1", ("j",), 1, "true_false", "true_false", "Perceptron and MLP", "Perceptron and MLP", ("Perceptron and MLP",), ("concept_check", "expressiveness_reasoning"), page_number=3), + short_answer("2a_i", "2a", "2", ("a", "i"), 1, analytics_topic="Python Fundamentals", topic_primary="Python Fundamentals", topic_tags=("Python Fundamentals",), skill_tags=("code_tracing",), page_number=4), + short_answer("2a_ii", "2a", "2", ("a", "ii"), 1, analytics_topic="Python Fundamentals", topic_primary="Python Fundamentals", topic_tags=("Python Fundamentals",), skill_tags=("code_tracing",), page_number=4), + short_answer("2a_iii", "2a", "2", ("a", "iii"), 1, analytics_topic="Python Fundamentals", topic_primary="Python Fundamentals", topic_tags=("Python Fundamentals",), skill_tags=("code_tracing",), page_number=4), + short_answer("2a_iv", "2a", "2", ("a", "iv"), 1, analytics_topic="Python Fundamentals", topic_primary="Python Fundamentals", topic_tags=("Python Fundamentals",), skill_tags=("code_tracing",), page_number=4), + short_answer("2a_v", "2a", "2", ("a", "v"), 1, analytics_topic="Python Fundamentals", topic_primary="Python Fundamentals", topic_tags=("Python Fundamentals",), skill_tags=("indexing", "code_tracing"), page_number=4), + short_answer("2a_vi", "2a", "2", ("a", "vi"), 1, analytics_topic="Python Fundamentals", topic_primary="Python Fundamentals", topic_tags=("Python Fundamentals",), skill_tags=("indexing", "error_reasoning"), page_number=5), + short_answer("2a_vii", "2a", "2", ("a", "vii"), 1, analytics_topic="Python Fundamentals", topic_primary="Python Fundamentals", topic_tags=("Python Fundamentals",), skill_tags=("masking", "code_tracing"), page_number=5), + short_answer("2a_viii", "2a", "2", ("a", "viii"), 1, analytics_topic="Python Fundamentals", topic_primary="Python Fundamentals", topic_tags=("Python Fundamentals",), skill_tags=("aggregation", "code_tracing"), page_number=5), + short_answer("2a_ix", "2a", "2", ("a", "ix"), 1, analytics_topic="Python Fundamentals", topic_primary="Python Fundamentals", topic_tags=("Python Fundamentals",), skill_tags=("transpose", "code_tracing"), page_number=5), + short_answer("2b_i", "2b", "2", ("b", "i"), 2, analytics_topic="Python Fundamentals", topic_primary="Python Fundamentals", topic_tags=("Python Fundamentals",), skill_tags=("broadcasting", "code_tracing"), page_number=6), + short_answer("2b_ii", "2b", "2", ("b", "ii"), 2, analytics_topic="Python Fundamentals", topic_primary="Python Fundamentals", topic_tags=("Python Fundamentals",), skill_tags=("broadcasting", "error_reasoning"), page_number=6), + short_answer("2b_iii", "2b", "2", ("b", "iii"), 2, analytics_topic="Python Fundamentals", topic_primary="Python Fundamentals", topic_tags=("Python Fundamentals",), skill_tags=("broadcasting", "code_tracing"), page_number=6), + ChildSpec("2c", "2", "2", ("c",), 6, "long_question", "coding", "Python Fundamentals", "Python Fundamentals", ("Python Fundamentals",), ("implementation", "vectorization", "geometry_reasoning"), page_number=7), + short_answer("3", "3", "3", (), 8, analytics_topic="Probabilistic Models", topic_primary="Probabilistic Models", topic_tags=("Probabilistic Models",), skill_tags=("concept_explanation", "missing_data_reasoning"), page_number=9), + ChildSpec("4a", "4", "4", ("a",), 8, "long_question", "long_answer", "KNN and Clustering", "KNN and Clustering", ("KNN and Clustering",), ("distance_calculation", "classification_decision"), page_number=10), + short_answer("4b", "4", "4", ("b",), 6, analytics_topic="KNN and Clustering", topic_primary="KNN and Clustering", topic_tags=("KNN and Clustering",), skill_tags=("distance_reasoning", "comparison"), page_number=11), + ChildSpec("5a", "5", "5", ("a",), 7, "long_question", "long_answer", "KNN and Clustering", "KNN and Clustering", ("KNN and Clustering",), ("distance_calculation", "algorithm_tracing"), page_number=12), + ChildSpec("5b", "5", "5", ("b",), 7, "long_question", "long_answer", "KNN and Clustering", "KNN and Clustering", ("KNN and Clustering",), ("centroid_update", "algorithm_tracing"), page_number=12), + short_answer("5c", "5", "5", ("c",), 5, analytics_topic="KNN and Clustering", topic_primary="KNN and Clustering", topic_tags=("KNN and Clustering",), skill_tags=("concept_explanation", "model_selection"), page_number=14), + short_answer("6a", "6", "6", ("a",), 2, analytics_topic="Perceptron and MLP", topic_primary="Perceptron and MLP", topic_tags=("Perceptron and MLP",), skill_tags=("convergence_reasoning",), page_number=15), + mc("6b", "6", "6", ("b",), 2, options=ABCDE, correct_option="D", analytics_topic="Perceptron and MLP", skill_tags=("generalization_reasoning",), page_number=15), + short_answer("6c", "6", "6", ("c",), 2, analytics_topic="Perceptron and MLP", topic_primary="Perceptron and MLP", topic_tags=("Perceptron and MLP",), skill_tags=("activation_reasoning",), page_number=16), + ChildSpec("6d", "6", "6", ("d",), 6, "long_question", "coding", "Perceptron and MLP", "Perceptron and MLP", ("Perceptron and MLP",), ("debugging", "implementation", "weight_update"), page_number=16), + short_answer("7a", "7", "7", ("a",), 4, analytics_topic="Perceptron and MLP", topic_primary="Perceptron and MLP", topic_tags=("Perceptron and MLP",), skill_tags=("decision_boundary", "linearity_reasoning"), page_number=18), + short_answer("7b", "7", "7", ("b",), 2, analytics_topic="Perceptron and MLP", topic_primary="Perceptron and MLP", topic_tags=("Perceptron and MLP",), skill_tags=("decision_boundary", "linearity_reasoning"), page_number=18), + ChildSpec("7c", "7", "7", ("c",), 10, "long_question", "long_answer", "Perceptron and MLP", "Perceptron and MLP", ("Perceptron and MLP",), ("architecture_reasoning", "parameter_design"), page_number=19), +] + + +MARKER_RE = re.compile(r"(?m)^\(([a-z]+|[ivx]+)\)\s*") + + +def split_sections(text: str) -> tuple[str, dict[str, str]]: + matches = list(MARKER_RE.finditer(text)) + if not matches: + return text.strip(), {} + intro = text[: matches[0].start()].strip() + sections: dict[str, str] = {} + for idx, match in enumerate(matches): + marker = match.group(1) + end = matches[idx + 1].start() if idx + 1 < len(matches) else len(text) + sections[marker] = text[match.start() : end].strip() + return intro, sections + + +def extract_segment(text: str, path: tuple[str, ...]) -> str: + current = text.strip() + carried_intro: list[str] = [] + for depth, marker in enumerate(path): + intro, sections = split_sections(current) + if depth == 0 and intro: + carried_intro.append(intro) + current = sections.get(marker, current) + return "\n".join(part for part in [*carried_intro, current] if part).strip() + + +def extract_true_false_answers(answer_text: str) -> dict[str, str]: + answers: dict[str, str] = {} + matches = list(re.finditer(r"(?m)^\(([a-j])\)\s*\n?T\s*F", answer_text)) + if matches: + return answers + for match in re.finditer(r"(?m)^\(([a-j])\)\s*\n?([TF])\b", answer_text): + answers[match.group(1)] = match.group(2) + if answers: + return answers + lines = [line.strip() for line in answer_text.splitlines() if line.strip()] + current = None + for line in lines: + m = re.fullmatch(r"\(([a-j])\)", line) + if m: + current = m.group(1) + continue + if current and line in {"T", "F"}: + answers[current] = line + current = None + return answers + + +def load_seed_rows() -> dict[str, dict]: + data = json.loads(PROBLEM_SEED_PATH.read_text()) + return {row["question_number"]: row for row in data if row["source_exam_key"] == EXAM_KEY} + + +def main() -> None: + sb = get_supabase() + paper = sb.table("papers").select("id").eq("source_exam_key", EXAM_KEY).execute().data[0] + paper_id = paper["id"] + current_rows = ( + sb.table("paper_questions") + .select("*") + .eq("paper_id", paper_id) + .order("display_order") + .execute() + .data + ) + existing_by_number = {row["question_number"]: row for row in current_rows} + parent_rows = load_seed_rows() + tf_answers = extract_true_false_answers(parent_rows["1"]["raw_answer_text"] or "") + + inserts = [] + for display_order, child in enumerate(CHILDREN, start=1): + parent = parent_rows[child.top_level_number] + existing = existing_by_number.get(child.question_number, {}) + question_text = extract_segment(parent["question_text"] or "", child.path) + raw_answer_text = extract_segment(parent["raw_answer_text"] or "", child.path) if child.path else (parent["raw_answer_text"] or "") + + options = None + correct_option = child.correct_option + if child.options: + options = [{"label": label, "text": text} for label, text in child.options] + if child.question_type == "true_false": + options = TRUE_FALSE_OPTIONS + correct_option = tf_answers.get(child.path[0]) + + inserts.append( + { + "paper_id": paper_id, + "question_number": child.question_number, + "parent_question": child.parent_question, + "display_order": display_order, + "question_type": child.question_type, + "question_format": child.question_format, + "question_text": question_text, + "score": child.score, + "page_number": child.page_number, + "page_y_ratio": existing.get("page_y_ratio"), + "options": options, + "correct_option": correct_option, + "correct_answer": child.correct_answer, + "raw_answer_text": raw_answer_text, + "topics": existing.get("topics") or (list(child.topic_tags) if child.topic_tags else parent.get("topics")), + "topic_primary": existing.get("topic_primary") or child.topic_primary or parent.get("topic_primary"), + "analytics_topic": existing.get("analytics_topic") or child.analytics_topic or parent.get("analytics_topic"), + "topic_tags": existing.get("topic_tags") or (list(child.topic_tags) if child.topic_tags else parent.get("topic_tags")), + "skill_tags": existing.get("skill_tags") or (list(child.skill_tags) if child.skill_tags else parent.get("skill_tags")), + "difficulty": existing.get("difficulty") or parent.get("difficulty"), + "knowledge_reminder": existing.get("knowledge_reminder", ""), + "ai_hint": existing.get("ai_hint", ""), + "solution": existing.get("solution", ""), + } + ) + + sb.table("paper_questions").delete().eq("paper_id", paper_id).execute() + sb.table("paper_questions").insert(inserts).execute() + sb.table("papers").update({"question_count": len(inserts), "status": "processing"}).eq("id", paper_id).execute() + print(f"Inserted {len(inserts)} rows for {EXAM_KEY}.") + + +if __name__ == "__main__": + main() diff --git a/backend/split_comp2211_2024_spring_final.py b/backend/split_comp2211_2024_spring_final.py new file mode 100644 index 0000000..803b198 --- /dev/null +++ b/backend/split_comp2211_2024_spring_final.py @@ -0,0 +1,242 @@ +"""Split COMP2211 Spring 2024 final into subquestions.""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass +from pathlib import Path + +from app.services.supabase_client import get_supabase + + +EXAM_KEY = "COMP2211-2024-spring-final" +PROBLEM_SEED_PATH = ( + Path(__file__).resolve().parent.parent + / "pastpaper-scraper" + / "reviews" + / "COMP2211" + / "problem_seed.json" +) +TRUE_FALSE_OPTIONS = [{"label": "True", "text": "True"}, {"label": "False", "text": "False"}] + + +@dataclass(frozen=True) +class ChildSpec: + question_number: str + parent_question: str + top_level_number: str + path: tuple[str, ...] + score: float + question_type: str + question_format: str | None = None + analytics_topic: str | None = None + topic_primary: str | None = None + topic_tags: tuple[str, ...] | None = None + skill_tags: tuple[str, ...] | None = None + options: tuple[tuple[str, str], ...] | None = None + correct_option: str | None = None + correct_answer: str | None = None + page_number: int = 1 + + +def short_answer( + question_number: str, + parent_question: str, + top_level_number: str, + path: tuple[str, ...], + score: float, + *, + analytics_topic: str | None = None, + topic_primary: str | None = None, + topic_tags: tuple[str, ...] | None = None, + skill_tags: tuple[str, ...] | None = None, + correct_answer: str | None = None, + page_number: int, +) -> ChildSpec: + return ChildSpec( + question_number=question_number, + parent_question=parent_question, + top_level_number=top_level_number, + path=path, + score=score, + question_type="long_question", + question_format="short_answer", + analytics_topic=analytics_topic, + topic_primary=topic_primary, + topic_tags=topic_tags, + skill_tags=skill_tags, + correct_answer=correct_answer, + page_number=page_number, + ) + + +CHILDREN: list[ChildSpec] = [ + ChildSpec("1a", "1", "1", ("a",), 1, "true_false", "true_false", "Python Fundamentals", "Python Fundamentals", ("Python Fundamentals",), ("concept_check", "code_tracing"), page_number=2), + ChildSpec("1b", "1", "1", ("b",), 1, "true_false", "true_false", "Probabilistic Models", "Probabilistic Models", ("Probabilistic Models",), ("concept_check", "classification_decision"), page_number=2), + ChildSpec("1c", "1", "1", ("c",), 1, "true_false", "true_false", "KNN and Clustering", "KNN and Clustering", ("KNN and Clustering",), ("concept_check", "algorithm_property"), page_number=2), + ChildSpec("1d", "1", "1", ("d",), 1, "true_false", "true_false", "KNN and Clustering", "KNN and Clustering", ("KNN and Clustering",), ("concept_check", "algorithm_property"), page_number=2), + ChildSpec("1e", "1", "1", ("e",), 1, "true_false", "true_false", "Perceptron and MLP", "Perceptron and MLP", ("Perceptron and MLP",), ("concept_check", "activation_reasoning"), page_number=2), + ChildSpec("1f", "1", "1", ("f",), 1, "true_false", "true_false", "Vision and CNN", "Vision and CNN", ("Vision and CNN",), ("concept_check", "image_processing"), page_number=2), + ChildSpec("1g", "1", "1", ("g",), 1, "true_false", "true_false", "Vision and CNN", "Vision and CNN", ("Vision and CNN",), ("concept_check", "cnn_complexity"), page_number=2), + ChildSpec("1h", "1", "1", ("h",), 1, "true_false", "true_false", "Vision and CNN", "Vision and CNN", ("Vision and CNN",), ("concept_check", "regularization"), page_number=2), + ChildSpec("1i", "1", "1", ("i",), 1, "true_false", "true_false", "Search and Games", "Search and Games", ("Search and Games",), ("concept_check", "pruning_reasoning"), page_number=2), + ChildSpec("1j", "1", "1", ("j",), 1, "true_false", "true_false", "Ethics of AI", "Ethics of AI", ("Ethics of AI",), ("concept_check", "research_ethics"), page_number=2), + ChildSpec("2a", "2", "2", ("a",), 4, "long_question", "coding", "Python Fundamentals", "Python Fundamentals", ("Python Fundamentals",), ("implementation", "vectorization", "masking"), page_number=3), + ChildSpec("2b", "2", "2", ("b",), 6, "long_question", "coding", "Python Fundamentals", "Python Fundamentals", ("Python Fundamentals",), ("implementation", "convolution", "array_manipulation"), page_number=4), + short_answer("3a_i", "3a", "3", ("a", "i"), 1.5, analytics_topic="Probabilistic Models", topic_primary="Probabilistic Models", topic_tags=("Probabilistic Models",), skill_tags=("manual_computation", "probability_reasoning"), page_number=6), + short_answer("3a_ii", "3a", "3", ("a", "ii"), 1.5, analytics_topic="Probabilistic Models", topic_primary="Probabilistic Models", topic_tags=("Probabilistic Models",), skill_tags=("manual_computation", "probability_reasoning"), page_number=6), + short_answer("3a_iii", "3a", "3", ("a", "iii"), 1.5, analytics_topic="Probabilistic Models", topic_primary="Probabilistic Models", topic_tags=("Probabilistic Models",), skill_tags=("manual_computation", "probability_reasoning"), page_number=6), + short_answer("3a_iv", "3a", "3", ("a", "iv"), 1.5, analytics_topic="Probabilistic Models", topic_primary="Probabilistic Models", topic_tags=("Probabilistic Models",), skill_tags=("manual_computation", "probability_reasoning"), page_number=6), + short_answer("3b_i", "3b", "3", ("b", "i"), 1.5, analytics_topic="Evaluation and Validation", topic_primary="Evaluation and Validation", topic_tags=("Evaluation and Validation",), skill_tags=("validation_reasoning",), page_number=6), + short_answer("3b_ii", "3b", "3", ("b", "ii"), 1.5, analytics_topic="Evaluation and Validation", topic_primary="Evaluation and Validation", topic_tags=("Evaluation and Validation",), skill_tags=("validation_reasoning",), page_number=6), + short_answer("3b_iii", "3b", "3", ("b", "iii"), 1.5, analytics_topic="Evaluation and Validation", topic_primary="Evaluation and Validation", topic_tags=("Evaluation and Validation",), skill_tags=("validation_reasoning",), page_number=6), + short_answer("3c", "3", "3", ("c",), 1.5, analytics_topic="Perceptron and MLP", topic_primary="Perceptron and MLP", topic_tags=("Perceptron and MLP",), skill_tags=("linearity_reasoning", "classification_decision"), page_number=6), + short_answer("4a_i", "4a", "4", ("a", "i"), 2.5, analytics_topic="Perceptron and MLP", topic_primary="Perceptron and MLP", topic_tags=("Perceptron and MLP",), skill_tags=("parameter_counting",), page_number=7), + short_answer("4a_ii", "4a", "4", ("a", "ii"), 2.5, analytics_topic="Perceptron and MLP", topic_primary="Perceptron and MLP", topic_tags=("Perceptron and MLP",), skill_tags=("model_selection",), page_number=7), + short_answer("4b", "4", "4", ("b",), 1, analytics_topic="Perceptron and MLP", topic_primary="Perceptron and MLP", topic_tags=("Perceptron and MLP",), skill_tags=("concept_explanation",), page_number=7), + short_answer("4c", "4", "4", ("c",), 2, analytics_topic="Perceptron and MLP", topic_primary="Perceptron and MLP", topic_tags=("Perceptron and MLP",), skill_tags=("activation_reasoning", "optimization_reasoning"), page_number=7), + ChildSpec("4d_i", "4d", "4", ("d", "i"), 1.5, "long_question", "long_answer", "Perceptron and MLP", "Perceptron and MLP", ("Perceptron and MLP",), ("forward_pass", "activation_reasoning"), page_number=8), + ChildSpec("4d_ii", "4d", "4", ("d", "ii"), 1.5, "long_question", "long_answer", "Perceptron and MLP", "Perceptron and MLP", ("Perceptron and MLP",), ("backpropagation", "weight_update"), page_number=8), + ChildSpec("5a", "5", "5", ("a",), 4.5, "long_question", "long_answer", "Vision and CNN", "Vision and CNN", ("Vision and CNN",), ("histogram_reasoning", "image_transform"), page_number=9), + ChildSpec("5b", "5", "5", ("b",), 3, "long_question", "long_answer", "Vision and CNN", "Vision and CNN", ("Vision and CNN",), ("thresholding", "manual_computation"), page_number=10), + ChildSpec("5c", "5", "5", ("c",), 2, "long_question", "long_answer", "Vision and CNN", "Vision and CNN", ("Vision and CNN",), ("padding", "manual_construction"), page_number=10), + short_answer("5d_i", "5d", "5", ("d", "i"), 0.5, analytics_topic="Vision and CNN", topic_primary="Vision and CNN", topic_tags=("Vision and CNN",), skill_tags=("filter_effect_reasoning",), page_number=11), + short_answer("5d_ii", "5d", "5", ("d", "ii"), 0.5, analytics_topic="Vision and CNN", topic_primary="Vision and CNN", topic_tags=("Vision and CNN",), skill_tags=("filter_effect_reasoning",), page_number=11), + short_answer("5d_iii", "5d", "5", ("d", "iii"), 0.5, analytics_topic="Vision and CNN", topic_primary="Vision and CNN", topic_tags=("Vision and CNN",), skill_tags=("filter_effect_reasoning",), page_number=11), + short_answer("5e", "5", "5", ("e",), 2, analytics_topic="Vision and CNN", topic_primary="Vision and CNN", topic_tags=("Vision and CNN",), skill_tags=("concept_explanation", "local_vs_global"), page_number=11), + ChildSpec("6a", "6", "6", ("a",), 10, "long_question", "coding", "Vision and CNN", "Vision and CNN", ("Vision and CNN",), ("implementation", "convolution", "debugging"), page_number=12), + ChildSpec("6b", "6", "6", ("b",), 3, "long_question", "coding", "Vision and CNN", "Vision and CNN", ("Vision and CNN",), ("implementation", "regularization"), page_number=15), + short_answer("7a_i", "7a", "7", ("a", "i"), 1, analytics_topic="Vision and CNN", topic_primary="Vision and CNN", topic_tags=("Vision and CNN",), skill_tags=("cnn_architecture",), page_number=16), + short_answer("7a_ii", "7a", "7", ("a", "ii"), 4, analytics_topic="Vision and CNN", topic_primary="Vision and CNN", topic_tags=("Vision and CNN",), skill_tags=("shape_reasoning", "parameter_counting"), page_number=16), + short_answer("7a_iii", "7a", "7", ("a", "iii"), 3, analytics_topic="Vision and CNN", topic_primary="Vision and CNN", topic_tags=("Vision and CNN",), skill_tags=("overfitting", "regularization"), page_number=16), + ChildSpec("7b", "7", "7", ("b",), 5, "long_question", "long_answer", "Vision and CNN", "Vision and CNN", ("Vision and CNN",), ("manual_computation", "cnn_forward_pass"), page_number=17), + short_answer("7c_i", "7c", "7", ("c", "i"), 2, analytics_topic="Vision and CNN", topic_primary="Vision and CNN", topic_tags=("Vision and CNN",), skill_tags=("shape_reasoning", "3d_convolution"), page_number=17), + short_answer("7c_ii", "7c", "7", ("c", "ii"), 1.5, analytics_topic="Vision and CNN", topic_primary="Vision and CNN", topic_tags=("Vision and CNN",), skill_tags=("parameter_counting", "3d_convolution"), page_number=17), + short_answer("7c_iii", "7c", "7", ("c", "iii"), 1.5, analytics_topic="Vision and CNN", topic_primary="Vision and CNN", topic_tags=("Vision and CNN",), skill_tags=("parameter_counting", "3d_convolution"), page_number=17), + short_answer("8a_i", "8a", "8", ("a", "i"), 1, analytics_topic="Search and Games", topic_primary="Search and Games", topic_tags=("Search and Games",), skill_tags=("tree_search", "manual_tracing"), page_number=18), + short_answer("8a_ii", "8a", "8", ("a", "ii"), 3, analytics_topic="Search and Games", topic_primary="Search and Games", topic_tags=("Search and Games",), skill_tags=("pruning", "manual_tracing"), page_number=18), + short_answer("8a_iii", "8a", "8", ("a", "iii"), 1, analytics_topic="Search and Games", topic_primary="Search and Games", topic_tags=("Search and Games",), skill_tags=("game_reasoning",), page_number=18), + short_answer("8b_i", "8b", "8", ("b", "i"), 2.5, analytics_topic="Search and Games", topic_primary="Search and Games", topic_tags=("Search and Games",), skill_tags=("utility_reasoning",), page_number=18), + short_answer("8b_ii", "8b", "8", ("b", "ii"), 2.5, analytics_topic="Search and Games", topic_primary="Search and Games", topic_tags=("Search and Games",), skill_tags=("pruning_reasoning", "concept_explanation"), page_number=18), + short_answer("9", "9", "9", (), 3, analytics_topic="Ethics of AI", topic_primary="Ethics of AI", topic_tags=("Ethics of AI",), skill_tags=("concept_explanation", "governance"), page_number=19), +] + + +MARKER_RE = re.compile(r"(?m)^\(([a-z]+|[ivx]+)\)\s*") + + +def split_sections(text: str) -> tuple[str, dict[str, str]]: + matches = list(MARKER_RE.finditer(text)) + if not matches: + return text.strip(), {} + intro = text[: matches[0].start()].strip() + sections: dict[str, str] = {} + for idx, match in enumerate(matches): + marker = match.group(1) + end = matches[idx + 1].start() if idx + 1 < len(matches) else len(text) + sections[marker] = text[match.start() : end].strip() + return intro, sections + + +def extract_segment(text: str, path: tuple[str, ...]) -> str: + if not path: + return text.strip() + current = text.strip() + carried_intro: list[str] = [] + for depth, marker in enumerate(path): + intro, sections = split_sections(current) + if depth == 0 and intro: + carried_intro.append(intro) + current = sections.get(marker, current) + return "\n".join(part for part in [*carried_intro, current] if part).strip() + + +def extract_true_false_answers(answer_text: str) -> dict[str, str]: + answers: dict[str, str] = {} + table_match = re.search(r"Answer\s+(T\s+F\s+T\s+F\s+F\s+T\s+F\s+F\s+F\s+T)", answer_text, re.S) + if table_match: + seq = re.findall(r"[TF]", table_match.group(1)) + if len(seq) == 10: + for idx, val in enumerate(seq): + answers[chr(ord("a") + idx)] = val + return answers + seq = re.findall(r"\b([TF])\b", answer_text) + if len(seq) >= 10: + for idx, val in enumerate(seq[:10]): + answers[chr(ord("a") + idx)] = val + return answers + + +def load_seed_rows() -> dict[str, dict]: + data = json.loads(PROBLEM_SEED_PATH.read_text()) + return {row["question_number"]: row for row in data if row["source_exam_key"] == EXAM_KEY} + + +def main() -> None: + sb = get_supabase() + paper = sb.table("papers").select("id").eq("source_exam_key", EXAM_KEY).execute().data[0] + paper_id = paper["id"] + current_rows = ( + sb.table("paper_questions") + .select("*") + .eq("paper_id", paper_id) + .order("display_order") + .execute() + .data + ) + existing_by_number = {row["question_number"]: row for row in current_rows} + parent_rows = load_seed_rows() + tf_answers = extract_true_false_answers(parent_rows["1"]["raw_answer_text"] or "") + + inserts = [] + for display_order, child in enumerate(CHILDREN, start=1): + parent = parent_rows[child.top_level_number] + existing = existing_by_number.get(child.question_number, {}) + question_text = extract_segment(parent["question_text"] or "", child.path) + raw_answer_text = extract_segment(parent["raw_answer_text"] or "", child.path) if child.path else (parent["raw_answer_text"] or "") + + options = None + correct_option = child.correct_option + if child.question_type == "true_false": + options = TRUE_FALSE_OPTIONS + correct_option = tf_answers.get(child.path[0]) + elif child.options: + options = [{"label": label, "text": text} for label, text in child.options] + + inserts.append( + { + "paper_id": paper_id, + "question_number": child.question_number, + "parent_question": child.parent_question, + "display_order": display_order, + "question_type": child.question_type, + "question_format": child.question_format, + "question_text": question_text, + "score": child.score, + "page_number": child.page_number, + "page_y_ratio": existing.get("page_y_ratio"), + "options": options, + "correct_option": correct_option, + "correct_answer": child.correct_answer, + "raw_answer_text": raw_answer_text, + "topics": existing.get("topics") or (list(child.topic_tags) if child.topic_tags else parent.get("topics")), + "topic_primary": existing.get("topic_primary") or child.topic_primary or parent.get("topic_primary"), + "analytics_topic": existing.get("analytics_topic") or child.analytics_topic or parent.get("analytics_topic"), + "topic_tags": existing.get("topic_tags") or (list(child.topic_tags) if child.topic_tags else parent.get("topic_tags")), + "skill_tags": existing.get("skill_tags") or (list(child.skill_tags) if child.skill_tags else parent.get("skill_tags")), + "difficulty": existing.get("difficulty") or parent.get("difficulty"), + "knowledge_reminder": existing.get("knowledge_reminder", ""), + "ai_hint": existing.get("ai_hint", ""), + "solution": existing.get("solution", ""), + } + ) + + sb.table("paper_questions").delete().eq("paper_id", paper_id).execute() + sb.table("paper_questions").insert(inserts).execute() + sb.table("papers").update({"question_count": len(inserts), "status": "processing"}).eq("id", paper_id).execute() + print(f"Inserted {len(inserts)} rows for {EXAM_KEY}.") + + +if __name__ == "__main__": + main() diff --git a/backend/split_comp2211_2024_spring_midterm.py b/backend/split_comp2211_2024_spring_midterm.py new file mode 100644 index 0000000..04fe328 --- /dev/null +++ b/backend/split_comp2211_2024_spring_midterm.py @@ -0,0 +1,291 @@ +"""Rebuild COMP2211 Spring 2024 midterm into subquestions.""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass +from pathlib import Path + +import fitz + +from app.services.supabase_client import get_supabase + + +EXAM_KEY = "COMP2211-2024-spring-midterm" +ROOT = Path(__file__).resolve().parent.parent +QUESTION_PDF = ROOT / "pastpaper-scraper" / "papers" / "COMP2211" / "(COMP2211)[2024](s)midterm~=rcidkjgf^_82003.pdf" +ANSWER_PDF = ROOT / "pastpaper-scraper" / "papers" / "COMP2211" / "(COMP2211)[2024](s)midterm~=ubrzkjmz^_90406.pdf" +PROBLEM_SEED_PATH = ROOT / "pastpaper-scraper" / "reviews" / "COMP2211" / "problem_seed.json" +TRUE_FALSE_OPTIONS = [{"label": "True", "text": "True"}, {"label": "False", "text": "False"}] + + +@dataclass(frozen=True) +class ChildSpec: + question_number: str + parent_question: str + top_level_number: str + path: tuple[str, ...] + score: float + question_type: str + question_format: str | None = None + analytics_topic: str | None = None + topic_primary: str | None = None + topic_tags: tuple[str, ...] | None = None + skill_tags: tuple[str, ...] | None = None + page_number: int = 1 + + +def short_answer( + question_number: str, + parent_question: str, + top_level_number: str, + path: tuple[str, ...], + score: float, + *, + analytics_topic: str | None = None, + topic_primary: str | None = None, + topic_tags: tuple[str, ...] | None = None, + skill_tags: tuple[str, ...] | None = None, + page_number: int, +) -> ChildSpec: + return ChildSpec( + question_number=question_number, + parent_question=parent_question, + top_level_number=top_level_number, + path=path, + score=score, + question_type="long_question", + question_format="short_answer", + analytics_topic=analytics_topic, + topic_primary=topic_primary, + topic_tags=topic_tags, + skill_tags=skill_tags, + page_number=page_number, + ) + + +CHILDREN: list[ChildSpec] = [ + ChildSpec("1a", "1", "1", ("a",), 0.5, "true_false", "true_false", "Python Fundamentals", "Python Fundamentals", ("Python Fundamentals",), ("concept_check", "code_tracing"), page_number=3), + ChildSpec("1b", "1", "1", ("b",), 0.5, "true_false", "true_false", "Python Fundamentals", "Python Fundamentals", ("Python Fundamentals",), ("concept_check", "broadcasting"), page_number=3), + ChildSpec("1c", "1", "1", ("c",), 0.5, "true_false", "true_false", "KNN and Clustering", "KNN and Clustering", ("KNN and Clustering",), ("concept_check", "algorithm_property"), page_number=3), + ChildSpec("1d", "1", "1", ("d",), 0.5, "true_false", "true_false", "KNN and Clustering", "KNN and Clustering", ("KNN and Clustering",), ("concept_check", "tie_reasoning"), page_number=3), + ChildSpec("1e", "1", "1", ("e",), 0.5, "true_false", "true_false", "Evaluation and Validation", "Evaluation and Validation", ("Evaluation and Validation",), ("concept_check", "cross_validation"), page_number=3), + ChildSpec("1f", "1", "1", ("f",), 0.5, "true_false", "true_false", "KNN and Clustering", "KNN and Clustering", ("KNN and Clustering",), ("concept_check", "clustering_property"), page_number=3), + ChildSpec("1g", "1", "1", ("g",), 0.5, "true_false", "true_false", "KNN and Clustering", "KNN and Clustering", ("KNN and Clustering",), ("concept_check", "robustness_reasoning"), page_number=3), + ChildSpec("1h", "1", "1", ("h",), 0.5, "true_false", "true_false", "Perceptron and MLP", "Perceptron and MLP", ("Perceptron and MLP",), ("concept_check", "decision_boundary"), page_number=3), + ChildSpec("1i", "1", "1", ("i",), 0.5, "true_false", "true_false", "Perceptron and MLP", "Perceptron and MLP", ("Perceptron and MLP",), ("concept_check", "optimization_reasoning"), page_number=3), + ChildSpec("1j", "1", "1", ("j",), 0.5, "true_false", "true_false", "KNN and Clustering", "KNN and Clustering", ("KNN and Clustering",), ("concept_check", "clustering_property"), page_number=3), + short_answer("2a_i", "2a", "2", ("a", "i"), 1, analytics_topic="Python Fundamentals", topic_primary="Python Fundamentals", topic_tags=("Python Fundamentals",), skill_tags=("code_tracing",), page_number=4), + short_answer("2a_ii", "2a", "2", ("a", "ii"), 1, analytics_topic="Python Fundamentals", topic_primary="Python Fundamentals", topic_tags=("Python Fundamentals",), skill_tags=("code_tracing",), page_number=4), + short_answer("2a_iii", "2a", "2", ("a", "iii"), 1, analytics_topic="Python Fundamentals", topic_primary="Python Fundamentals", topic_tags=("Python Fundamentals",), skill_tags=("array_manipulation",), page_number=5), + short_answer("2a_iv", "2a", "2", ("a", "iv"), 1, analytics_topic="Python Fundamentals", topic_primary="Python Fundamentals", topic_tags=("Python Fundamentals",), skill_tags=("array_construction",), page_number=5), + short_answer("2a_v", "2a", "2", ("a", "v"), 1, analytics_topic="Python Fundamentals", topic_primary="Python Fundamentals", topic_tags=("Python Fundamentals",), skill_tags=("aggregation",), page_number=5), + short_answer("2a_vi", "2a", "2", ("a", "vi"), 1, analytics_topic="Python Fundamentals", topic_primary="Python Fundamentals", topic_tags=("Python Fundamentals",), skill_tags=("transpose",), page_number=6), + short_answer("2a_vii", "2a", "2", ("a", "vii"), 1, analytics_topic="Python Fundamentals", topic_primary="Python Fundamentals", topic_tags=("Python Fundamentals",), skill_tags=("matrix_multiplication",), page_number=6), + short_answer("2a_viii", "2a", "2", ("a", "viii"), 1, analytics_topic="Python Fundamentals", topic_primary="Python Fundamentals", topic_tags=("Python Fundamentals",), skill_tags=("dot_product",), page_number=6), + short_answer("2a_ix", "2a", "2", ("a", "ix"), 1, analytics_topic="Python Fundamentals", topic_primary="Python Fundamentals", topic_tags=("Python Fundamentals",), skill_tags=("broadcasting",), page_number=6), + short_answer("2a_x", "2a", "2", ("a", "x"), 1, analytics_topic="Python Fundamentals", topic_primary="Python Fundamentals", topic_tags=("Python Fundamentals",), skill_tags=("error_reasoning",), page_number=7), + short_answer("2a_xi", "2a", "2", ("a", "xi"), 1, analytics_topic="Python Fundamentals", topic_primary="Python Fundamentals", topic_tags=("Python Fundamentals",), skill_tags=("broadcasting",), page_number=7), + short_answer("2a_xii", "2a", "2", ("a", "xii"), 1, analytics_topic="Python Fundamentals", topic_primary="Python Fundamentals", topic_tags=("Python Fundamentals",), skill_tags=("slicing",), page_number=7), + short_answer("2a_xiii", "2a", "2", ("a", "xiii"), 1, analytics_topic="Python Fundamentals", topic_primary="Python Fundamentals", topic_tags=("Python Fundamentals",), skill_tags=("views_vs_copies",), page_number=7), + ChildSpec("2b", "2", "2", ("b",), 6, "long_question", "coding", "Python Fundamentals", "Python Fundamentals", ("Python Fundamentals",), ("implementation", "vectorization", "similarity_computation"), page_number=8), + ChildSpec("3a", "3", "3", ("a",), 5.5, "long_question", "long_answer", "Evaluation and Validation", "Evaluation and Validation", ("Evaluation and Validation",), ("manual_computation", "metric_reasoning"), page_number=10), + short_answer("3b", "3", "3", ("b",), 1, analytics_topic="Evaluation and Validation", topic_primary="Evaluation and Validation", topic_tags=("Evaluation and Validation",), skill_tags=("metric_reasoning",), page_number=11), + ChildSpec("3c", "3", "3", ("c",), 2.5, "long_question", "long_answer", "Evaluation and Validation", "Evaluation and Validation", ("Evaluation and Validation",), ("manual_computation", "metric_reasoning"), page_number=11), + short_answer("3d", "3", "3", ("d",), 1, analytics_topic="Evaluation and Validation", topic_primary="Evaluation and Validation", topic_tags=("Evaluation and Validation",), skill_tags=("metric_reasoning",), page_number=12), + ChildSpec("3e", "3", "3", ("e",), 6, "long_question", "coding", "Evaluation and Validation", "Evaluation and Validation", ("Evaluation and Validation",), ("implementation", "metrics", "vectorization"), page_number=12), + ChildSpec("4a", "4", "4", ("a",), 4, "long_question", "long_answer", "Probabilistic Models", "Probabilistic Models", ("Probabilistic Models",), ("manual_computation", "gaussian_nb"), page_number=15), + ChildSpec("4b", "4", "4", ("b",), 3, "long_question", "long_answer", "Probabilistic Models", "Probabilistic Models", ("Probabilistic Models",), ("manual_computation", "likelihood_reasoning"), page_number=15), + ChildSpec("4c", "4", "4", ("c",), 4, "long_question", "long_answer", "Probabilistic Models", "Probabilistic Models", ("Probabilistic Models",), ("laplace_smoothing", "likelihood_reasoning"), page_number=16), + short_answer("4d", "4", "4", ("d",), 2, analytics_topic="Probabilistic Models", topic_primary="Probabilistic Models", topic_tags=("Probabilistic Models",), skill_tags=("prior_reasoning",), page_number=17), + ChildSpec("4e", "4", "4", ("e",), 3, "long_question", "long_answer", "Probabilistic Models", "Probabilistic Models", ("Probabilistic Models",), ("posterior_reasoning", "classification_decision"), page_number=17), + ChildSpec("5a", "5", "5", ("a",), 3, "long_question", "long_answer", "KNN and Clustering", "KNN and Clustering", ("KNN and Clustering",), ("distance_calculation", "weighted_knn"), page_number=18), + ChildSpec("5b", "5", "5", ("b",), 13, "long_question", "long_answer", "KNN and Clustering", "KNN and Clustering", ("KNN and Clustering",), ("cross_validation", "manual_tracing", "model_selection"), page_number=18), + short_answer("5c", "5", "5", ("c",), 2, analytics_topic="KNN and Clustering", topic_primary="KNN and Clustering", topic_tags=("KNN and Clustering",), skill_tags=("test_error", "model_selection"), page_number=20), + ChildSpec("6a", "6", "6", ("a",), 6, "long_question", "long_answer", "KNN and Clustering", "KNN and Clustering", ("KNN and Clustering",), ("manual_computation", "clustering"), page_number=21), + ChildSpec("6b", "6", "6", ("b",), 6, "long_question", "long_answer", "KNN and Clustering", "KNN and Clustering", ("KNN and Clustering",), ("manual_computation", "clustering"), page_number=22), + short_answer("6c", "6", "6", ("c",), 2, analytics_topic="KNN and Clustering", topic_primary="KNN and Clustering", topic_tags=("KNN and Clustering",), skill_tags=("outlier_reasoning",), page_number=22), + short_answer("6d", "6", "6", ("d",), 2, analytics_topic="KNN and Clustering", topic_primary="KNN and Clustering", topic_tags=("KNN and Clustering",), skill_tags=("model_selection", "threshold_reasoning"), page_number=22), + ChildSpec("7", "7", "7", (), 10, "long_question", "long_answer", "Evaluation and Validation", "Evaluation and Validation", ("Evaluation and Validation",), ("cross_validation", "data_leakage_reasoning"), page_number=23), +] + + +MARKER_RE = re.compile(r"(?m)^\(([a-z]+|[ivx]+)\)\s*") + + +def split_sections(text: str) -> tuple[str, dict[str, str]]: + matches = list(MARKER_RE.finditer(text)) + if not matches: + return text.strip(), {} + intro = text[: matches[0].start()].strip() + sections: dict[str, str] = {} + for idx, match in enumerate(matches): + marker = match.group(1) + end = matches[idx + 1].start() if idx + 1 < len(matches) else len(text) + sections[marker] = text[match.start() : end].strip() + return intro, sections + + +def extract_segment(text: str, path: tuple[str, ...]) -> str: + if not path: + return text.strip() + current = text.strip() + carried_intro: list[str] = [] + for depth, marker in enumerate(path): + intro, sections = split_sections(current) + if depth == 0 and intro: + carried_intro.append(intro) + current = sections.get(marker, current) + return "\n".join(part for part in [*carried_intro, current] if part).strip() + + +def extract_pages(pdf_path: Path, start: int, end: int) -> str: + doc = fitz.open(pdf_path) + try: + return "\n".join(doc[i].get_text("text") for i in range(start - 1, end)) + finally: + doc.close() + + +def load_seed_rows() -> dict[str, dict]: + data = json.loads(PROBLEM_SEED_PATH.read_text()) + return {row["question_number"]: row for row in data if row["source_exam_key"] == EXAM_KEY} + + +def build_source_rows(existing_rows: dict[str, dict]) -> dict[str, dict]: + seed_rows = load_seed_rows() + rows = dict(seed_rows) + if "5" in rows: + rows["5"] = { + **rows["5"], + "question_text": extract_pages(QUESTION_PDF, 18, 20), + "raw_answer_text": extract_pages(ANSWER_PDF, 21, 25), + "page_number": 18, + "analytics_topic": "KNN and Clustering", + "topic_primary": "KNN and Clustering", + "topic_tags": ["KNN and Clustering"], + "skill_tags": ["manual_computation", "distance_calculation", "algorithm_tracing"], + "difficulty": "medium", + } + else: + rows["5"] = { + **seed_rows["5"], + "question_text": extract_pages(QUESTION_PDF, 18, 20), + "raw_answer_text": extract_pages(ANSWER_PDF, 21, 25), + "page_number": 18, + } + if "7" in rows: + rows["7"] = { + **rows["7"], + "question_text": extract_pages(QUESTION_PDF, 23, 24), + "raw_answer_text": extract_pages(ANSWER_PDF, 31, 34), + "page_number": 23, + "analytics_topic": "Evaluation and Validation", + "topic_primary": "Evaluation and Validation", + "topic_tags": ["Evaluation and Validation"], + "skill_tags": ["cross_validation", "data_leakage_reasoning"], + "difficulty": "medium", + } + else: + rows["7"] = { + **seed_rows["7"], + "question_text": extract_pages(QUESTION_PDF, 23, 24), + "raw_answer_text": extract_pages(ANSWER_PDF, 31, 34), + "page_number": 23, + } + return rows + + +def extract_true_false_answers(answer_text: str) -> dict[str, str]: + answers: dict[str, str] = {} + table_match = re.search(r"Answer\s+([TF\s]+)", answer_text, re.S) + if table_match: + seq = re.findall(r"[TF]", table_match.group(1)) + if len(seq) >= 10: + for idx, val in enumerate(seq[:10]): + answers[chr(ord("a") + idx)] = val + return answers + lines = [line.strip() for line in answer_text.splitlines() if line.strip()] + current_letter: str | None = None + for line in lines: + m = re.fullmatch(r"\(([a-j])\)", line) + if m: + current_letter = m.group(1) + continue + if current_letter and line in {"T", "F"}: + answers[current_letter] = line + current_letter = None + if answers: + return answers + seq = re.findall(r"\b([TF])\b", answer_text) + if len(seq) >= 10: + for idx, val in enumerate(seq[:10]): + answers[chr(ord("a") + idx)] = val + return answers + + +def main() -> None: + sb = get_supabase() + paper = sb.table("papers").select("id").eq("source_exam_key", EXAM_KEY).execute().data[0] + paper_id = paper["id"] + current_rows = ( + sb.table("paper_questions") + .select("*") + .eq("paper_id", paper_id) + .order("display_order") + .execute() + .data + ) + existing_by_number = {row["question_number"]: row for row in current_rows} + parent_rows = build_source_rows(existing_by_number) + tf_answers = extract_true_false_answers(parent_rows["1"]["raw_answer_text"] or "") + + inserts = [] + for display_order, child in enumerate(CHILDREN, start=1): + parent = parent_rows[child.top_level_number] + existing = existing_by_number.get(child.question_number, {}) + question_text = extract_segment(parent["question_text"] or "", child.path) + raw_answer_text = extract_segment(parent["raw_answer_text"] or "", child.path) if child.path else (parent["raw_answer_text"] or "") + options = None + correct_option = None + if child.question_type == "true_false": + options = TRUE_FALSE_OPTIONS + correct_option = tf_answers.get(child.path[0]) + + inserts.append( + { + "paper_id": paper_id, + "question_number": child.question_number, + "parent_question": child.parent_question, + "display_order": display_order, + "question_type": child.question_type, + "question_format": child.question_format, + "question_text": question_text, + "score": child.score, + "page_number": child.page_number, + "page_y_ratio": existing.get("page_y_ratio"), + "options": options, + "correct_option": correct_option, + "correct_answer": None, + "raw_answer_text": raw_answer_text, + "topics": existing.get("topics") or (list(child.topic_tags) if child.topic_tags else parent.get("topics")), + "topic_primary": existing.get("topic_primary") or child.topic_primary or parent.get("topic_primary"), + "analytics_topic": existing.get("analytics_topic") or child.analytics_topic or parent.get("analytics_topic"), + "topic_tags": existing.get("topic_tags") or (list(child.topic_tags) if child.topic_tags else parent.get("topic_tags")), + "skill_tags": existing.get("skill_tags") or (list(child.skill_tags) if child.skill_tags else parent.get("skill_tags")), + "difficulty": existing.get("difficulty") or parent.get("difficulty"), + "knowledge_reminder": existing.get("knowledge_reminder", ""), + "ai_hint": existing.get("ai_hint", ""), + "solution": existing.get("solution", ""), + } + ) + + sb.table("paper_questions").delete().eq("paper_id", paper_id).execute() + sb.table("paper_questions").insert(inserts).execute() + sb.table("papers").update({"question_count": len(inserts), "status": "processing"}).eq("id", paper_id).execute() + print(f"Inserted {len(inserts)} rows for {EXAM_KEY}.") + + +if __name__ == "__main__": + main() diff --git a/backend/upload_course_library_pdfs.py b/backend/upload_course_library_pdfs.py new file mode 100644 index 0000000..75a33d7 --- /dev/null +++ b/backend/upload_course_library_pdfs.py @@ -0,0 +1,121 @@ +"""Upload COMP2211 course-library PDFs to Supabase Storage. + +Run from the backend directory: + uv run python upload_course_library_pdfs.py + +Each entry maps a storage path (inside the `papers` bucket) to the local +source file under pastpaper-scraper/papers/COMP2211/. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +# --------------------------------------------------------------------------- +# Manifest: (storage_path, local_filename) +# storage_path is relative inside the `papers` bucket. +# local_filename is relative to PAPERS_DIR below. +# --------------------------------------------------------------------------- +MANIFEST: list[tuple[str, str]] = [ + ( + "course-library/COMP2211/COMP2211-2022-fall-midterm/paper.pdf", + "(COMP2211)[2022](f)midterm~=yjz8dxdd^_27002.pdf", + ), + ( + "course-library/COMP2211/COMP2211-2022-fall-midterm/answer.pdf", + "(COMP2211)[2022](f)midterm~=yjz8dxdd^_18747.pdf", + ), + ( + "course-library/COMP2211/COMP2211-2022-spring-midterm/paper.pdf", + "(COMP2211)[2022](s)midterm~=b8bidkgs^_14629.pdf", + ), + ( + "course-library/COMP2211/COMP2211-2022-spring-midterm/answer.pdf", + "(COMP2211)[2022](s)midterm~=6ma030^_89587.pdf", + ), + ( + "course-library/COMP2211/COMP2211-2022-spring-final-part-a/paper.pdf", + "(COMP2211)[2022](s)final~=b8bidkgs^_33018.pdf", + ), + ( + "course-library/COMP2211/COMP2211-2022-spring-final-part-a/answer.pdf", + "(COMP2211)[2022](s)final~=ajou6^_82011.pdf", + ), + ( + "course-library/COMP2211/COMP2211-2022-spring-final-part-b/paper.pdf", + "(COMP2211)[2022](s)final~=b8bidkgs^_40627.pdf", + ), + ( + "course-library/COMP2211/COMP2211-2022-spring-final-part-b/answer.pdf", + "(COMP2211)[2022](s)final~=ajou6^_51199.pdf", + ), + ( + "course-library/COMP2211/COMP2211-2023-spring-midterm/paper.pdf", + "(COMP2211)[2023](s)midterm~=bxbidkmj^_26587.pdf", + ), + ( + "course-library/COMP2211/COMP2211-2023-spring-midterm/answer.pdf", + "(COMP2211)[2023](s)midterm~clchanbg^_17297.pdf", + ), + ( + "course-library/COMP2211/COMP2211-2024-spring-midterm/paper.pdf", + "(COMP2211)[2024](s)midterm~=rcidkjgf^_82003.pdf", + ), + ( + "course-library/COMP2211/COMP2211-2024-spring-midterm/answer.pdf", + "(COMP2211)[2024](s)midterm~=ubrzkjmz^_90406.pdf", + ), + ( + "course-library/COMP2211/COMP2211-2024-spring-final/paper.pdf", + "(COMP2211)[2024](s)final~=igk5mmg^_90365.pdf", + ), + ( + "course-library/COMP2211/COMP2211-2024-spring-final/answer.pdf", + "(COMP2211)[2024](s)final~=igk5mmg^_58857.pdf", + ), +] + +PAPERS_DIR = ( + Path(__file__).parent.parent + / "pastpaper-scraper" + / "papers" + / "COMP2211" +) + + +def main() -> None: + from app.services.supabase_client import get_supabase + + sb = get_supabase() + bucket = sb.storage.from_("papers") + + ok = 0 + skipped = 0 + failed = 0 + + for storage_path, local_name in MANIFEST: + local_file = PAPERS_DIR / local_name + if not local_file.exists(): + print(f" MISSING local file: {local_name}") + failed += 1 + continue + + data = local_file.read_bytes() + try: + bucket.upload( + storage_path, + data, + file_options={"content-type": "application/pdf", "upsert": "true"}, + ) + print(f" OK {storage_path}") + ok += 1 + except Exception as exc: + print(f" ERR {storage_path}: {exc}") + failed += 1 + + print(f"\nDone: {ok} uploaded, {skipped} skipped, {failed} failed.") + + +if __name__ == "__main__": + main() diff --git a/backend/uv.lock b/backend/uv.lock new file mode 100644 index 0000000..c1f046c --- /dev/null +++ b/backend/uv.lock @@ -0,0 +1,1969 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "cachetools" +version = "6.2.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/39/91/d9ae9a66b01102a18cd16db0cf4cd54187ffe10f0865cc80071a4104fbb3/cachetools-6.2.6.tar.gz", hash = "sha256:16c33e1f276b9a9c0b49ab5782d901e3ad3de0dd6da9bf9bcd29ac5672f2f9e6", size = 32363, upload-time = "2026-01-27T20:32:59.956Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/45/f458fa2c388e79dd9d8b9b0c99f1d31b568f27388f2fdba7bb66bbc0c6ed/cachetools-6.2.6-py3-none-any.whl", hash = "sha256:8c9717235b3c651603fff0076db52d6acbfd1b338b8ed50256092f7ce9c85bda", size = 11668, upload-time = "2026-01-27T20:32:58.527Z" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1d/35/02daf95b9cd686320bb622eb148792655c9412dbb9b67abb5694e5910a24/charset_normalizer-3.4.5.tar.gz", hash = "sha256:95adae7b6c42a6c5b5b559b1a99149f090a57128155daeea91732c8d970d8644", size = 134804, upload-time = "2026-03-06T06:03:19.46Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/9e/bcec3b22c64ecec47d39bf5167c2613efd41898c019dccd4183f6aa5d6a7/charset_normalizer-3.4.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:610f72c0ee565dfb8ae1241b666119582fdbfe7c0975c175be719f940e110694", size = 279531, upload-time = "2026-03-06T06:00:52.252Z" }, + { url = "https://files.pythonhosted.org/packages/58/12/81fd25f7e7078ab5d1eedbb0fac44be4904ae3370a3bf4533c8f2d159acd/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60d68e820af339df4ae8358c7a2e7596badeb61e544438e489035f9fbf3246a5", size = 188006, upload-time = "2026-03-06T06:00:53.8Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6e/f2d30e8c27c1b0736a6520311982cf5286cfc7f6cac77d7bc1325e3a23f2/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b473fc8dca1c3ad8559985794815f06ca3fc71942c969129070f2c3cdf7281", size = 205085, upload-time = "2026-03-06T06:00:55.311Z" }, + { url = "https://files.pythonhosted.org/packages/d0/90/d12cefcb53b5931e2cf792a33718d7126efb116a320eaa0742c7059a95e4/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d4eb8ac7469b2a5d64b5b8c04f84d8bf3ad340f4514b98523805cbf46e3b3923", size = 200545, upload-time = "2026-03-06T06:00:56.532Z" }, + { url = "https://files.pythonhosted.org/packages/03/f4/44d3b830a20e89ff82a3134912d9a1cf6084d64f3b95dcad40f74449a654/charset_normalizer-3.4.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bcb3227c3d9aaf73eaaab1db7ccd80a8995c509ee9941e2aae060ca6e4e5d81", size = 193863, upload-time = "2026-03-06T06:00:57.823Z" }, + { url = "https://files.pythonhosted.org/packages/25/4b/f212119c18a6320a9d4a730d1b4057875cdeabf21b3614f76549042ef8a8/charset_normalizer-3.4.5-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:75ee9c1cce2911581a70a3c0919d8bccf5b1cbc9b0e5171400ec736b4b569497", size = 181827, upload-time = "2026-03-06T06:00:59.323Z" }, + { url = "https://files.pythonhosted.org/packages/74/00/b26158e48b425a202a92965f8069e8a63d9af1481dfa206825d7f74d2a3c/charset_normalizer-3.4.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d1401945cb77787dbd3af2446ff2d75912327c4c3a1526ab7955ecf8600687c", size = 191085, upload-time = "2026-03-06T06:01:00.546Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/1c1737bf6fd40335fe53d28fe49afd99ee4143cc57a845e99635ce0b9b6d/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a45e504f5e1be0bd385935a8e1507c442349ca36f511a47057a71c9d1d6ea9e", size = 190688, upload-time = "2026-03-06T06:01:02.479Z" }, + { url = "https://files.pythonhosted.org/packages/5a/3d/abb5c22dc2ef493cd56522f811246a63c5427c08f3e3e50ab663de27fcf4/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:e09f671a54ce70b79a1fc1dc6da3072b7ef7251fadb894ed92d9aa8218465a5f", size = 183077, upload-time = "2026-03-06T06:01:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/44/33/5298ad4d419a58e25b3508e87f2758d1442ff00c2471f8e0403dab8edad5/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d01de5e768328646e6a3fa9e562706f8f6641708c115c62588aef2b941a4f88e", size = 206706, upload-time = "2026-03-06T06:01:05.773Z" }, + { url = "https://files.pythonhosted.org/packages/7b/17/51e7895ac0f87c3b91d276a449ef09f5532a7529818f59646d7a55089432/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:131716d6786ad5e3dc542f5cc6f397ba3339dc0fb87f87ac30e550e8987756af", size = 191665, upload-time = "2026-03-06T06:01:07.473Z" }, + { url = "https://files.pythonhosted.org/packages/90/8f/cce9adf1883e98906dbae380d769b4852bb0fa0004bc7d7a2243418d3ea8/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a374cc0b88aa710e8865dc1bd6edb3743c59f27830f0293ab101e4cf3ce9f85", size = 201950, upload-time = "2026-03-06T06:01:08.973Z" }, + { url = "https://files.pythonhosted.org/packages/08/ca/bce99cd5c397a52919e2769d126723f27a4c037130374c051c00470bcd38/charset_normalizer-3.4.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d31f0d1671e1534e395f9eb84a68e0fb670e1edb1fe819a9d7f564ae3bc4e53f", size = 195830, upload-time = "2026-03-06T06:01:10.155Z" }, + { url = "https://files.pythonhosted.org/packages/87/4f/2e3d023a06911f1281f97b8f036edc9872167036ca6f55cc874a0be6c12c/charset_normalizer-3.4.5-cp311-cp311-win32.whl", hash = "sha256:cace89841c0599d736d3d74a27bc5821288bb47c5441923277afc6059d7fbcb4", size = 132029, upload-time = "2026-03-06T06:01:11.706Z" }, + { url = "https://files.pythonhosted.org/packages/fe/1f/a853b73d386521fd44b7f67ded6b17b7b2367067d9106a5c4b44f9a34274/charset_normalizer-3.4.5-cp311-cp311-win_amd64.whl", hash = "sha256:f8102ae93c0bc863b1d41ea0f4499c20a83229f52ed870850892df555187154a", size = 142404, upload-time = "2026-03-06T06:01:12.865Z" }, + { url = "https://files.pythonhosted.org/packages/b4/10/dba36f76b71c38e9d391abe0fd8a5b818790e053c431adecfc98c35cd2a9/charset_normalizer-3.4.5-cp311-cp311-win_arm64.whl", hash = "sha256:ed98364e1c262cf5f9363c3eca8c2df37024f52a8fa1180a3610014f26eac51c", size = 132796, upload-time = "2026-03-06T06:01:14.106Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b6/9ee9c1a608916ca5feae81a344dffbaa53b26b90be58cc2159e3332d44ec/charset_normalizer-3.4.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed97c282ee4f994ef814042423a529df9497e3c666dca19be1d4cd1129dc7ade", size = 280976, upload-time = "2026-03-06T06:01:15.276Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d8/a54f7c0b96f1df3563e9190f04daf981e365a9b397eedfdfb5dbef7e5c6c/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0294916d6ccf2d069727d65973c3a1ca477d68708db25fd758dd28b0827cff54", size = 189356, upload-time = "2026-03-06T06:01:16.511Z" }, + { url = "https://files.pythonhosted.org/packages/42/69/2bf7f76ce1446759a5787cb87d38f6a61eb47dbbdf035cfebf6347292a65/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dc57a0baa3eeedd99fafaef7511b5a6ef4581494e8168ee086031744e2679467", size = 206369, upload-time = "2026-03-06T06:01:17.853Z" }, + { url = "https://files.pythonhosted.org/packages/10/9c/949d1a46dab56b959d9a87272482195f1840b515a3380e39986989a893ae/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ed1a9a204f317ef879b32f9af507d47e49cd5e7f8e8d5d96358c98373314fc60", size = 203285, upload-time = "2026-03-06T06:01:19.473Z" }, + { url = "https://files.pythonhosted.org/packages/67/5c/ae30362a88b4da237d71ea214a8c7eb915db3eec941adda511729ac25fa2/charset_normalizer-3.4.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ad83b8f9379176c841f8865884f3514d905bcd2a9a3b210eaa446e7d2223e4d", size = 196274, upload-time = "2026-03-06T06:01:20.728Z" }, + { url = "https://files.pythonhosted.org/packages/b2/07/c9f2cb0e46cb6d64fdcc4f95953747b843bb2181bda678dc4e699b8f0f9a/charset_normalizer-3.4.5-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:a118e2e0b5ae6b0120d5efa5f866e58f2bb826067a646431da4d6a2bdae7950e", size = 184715, upload-time = "2026-03-06T06:01:22.194Z" }, + { url = "https://files.pythonhosted.org/packages/36/64/6b0ca95c44fddf692cd06d642b28f63009d0ce325fad6e9b2b4d0ef86a52/charset_normalizer-3.4.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:754f96058e61a5e22e91483f823e07df16416ce76afa4ebf306f8e1d1296d43f", size = 193426, upload-time = "2026-03-06T06:01:23.795Z" }, + { url = "https://files.pythonhosted.org/packages/50/bc/a730690d726403743795ca3f5bb2baf67838c5fea78236098f324b965e40/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0c300cefd9b0970381a46394902cd18eaf2aa00163f999590ace991989dcd0fc", size = 191780, upload-time = "2026-03-06T06:01:25.053Z" }, + { url = "https://files.pythonhosted.org/packages/97/4f/6c0bc9af68222b22951552d73df4532b5be6447cee32d58e7e8c74ecbb7b/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c108f8619e504140569ee7de3f97d234f0fbae338a7f9f360455071ef9855a95", size = 185805, upload-time = "2026-03-06T06:01:26.294Z" }, + { url = "https://files.pythonhosted.org/packages/dd/b9/a523fb9b0ee90814b503452b2600e4cbc118cd68714d57041564886e7325/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:d1028de43596a315e2720a9849ee79007ab742c06ad8b45a50db8cdb7ed4a82a", size = 208342, upload-time = "2026-03-06T06:01:27.55Z" }, + { url = "https://files.pythonhosted.org/packages/4d/61/c59e761dee4464050713e50e27b58266cc8e209e518c0b378c1580c959ba/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:19092dde50335accf365cce21998a1c6dd8eafd42c7b226eb54b2747cdce2fac", size = 193661, upload-time = "2026-03-06T06:01:29.051Z" }, + { url = "https://files.pythonhosted.org/packages/1c/43/729fa30aad69783f755c5ad8649da17ee095311ca42024742701e202dc59/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:4354e401eb6dab9aed3c7b4030514328a6c748d05e1c3e19175008ca7de84fb1", size = 204819, upload-time = "2026-03-06T06:01:30.298Z" }, + { url = "https://files.pythonhosted.org/packages/87/33/d9b442ce5a91b96fc0840455a9e49a611bbadae6122778d0a6a79683dd31/charset_normalizer-3.4.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a68766a3c58fde7f9aaa22b3786276f62ab2f594efb02d0a1421b6282e852e98", size = 198080, upload-time = "2026-03-06T06:01:31.478Z" }, + { url = "https://files.pythonhosted.org/packages/56/5a/b8b5a23134978ee9885cee2d6995f4c27cc41f9baded0a9685eabc5338f0/charset_normalizer-3.4.5-cp312-cp312-win32.whl", hash = "sha256:1827734a5b308b65ac54e86a618de66f935a4f63a8a462ff1e19a6788d6c2262", size = 132630, upload-time = "2026-03-06T06:01:33.056Z" }, + { url = "https://files.pythonhosted.org/packages/70/53/e44a4c07e8904500aec95865dc3f6464dc3586a039ef0df606eb3ac38e35/charset_normalizer-3.4.5-cp312-cp312-win_amd64.whl", hash = "sha256:728c6a963dfab66ef865f49286e45239384249672cd598576765acc2a640a636", size = 142856, upload-time = "2026-03-06T06:01:34.489Z" }, + { url = "https://files.pythonhosted.org/packages/ea/aa/c5628f7cad591b1cf45790b7a61483c3e36cf41349c98af7813c483fd6e8/charset_normalizer-3.4.5-cp312-cp312-win_arm64.whl", hash = "sha256:75dfd1afe0b1647449e852f4fb428195a7ed0588947218f7ba929f6538487f02", size = 132982, upload-time = "2026-03-06T06:01:35.641Z" }, + { url = "https://files.pythonhosted.org/packages/f5/48/9f34ec4bb24aa3fdba1890c1bddb97c8a4be1bd84ef5c42ac2352563ad05/charset_normalizer-3.4.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac59c15e3f1465f722607800c68713f9fbc2f672b9eb649fe831da4019ae9b23", size = 280788, upload-time = "2026-03-06T06:01:37.126Z" }, + { url = "https://files.pythonhosted.org/packages/0e/09/6003e7ffeb90cc0560da893e3208396a44c210c5ee42efff539639def59b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:165c7b21d19365464e8f70e5ce5e12524c58b48c78c1f5a57524603c1ab003f8", size = 188890, upload-time = "2026-03-06T06:01:38.73Z" }, + { url = "https://files.pythonhosted.org/packages/42/1e/02706edf19e390680daa694d17e2b8eab4b5f7ac285e2a51168b4b22ee6b/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:28269983f25a4da0425743d0d257a2d6921ea7d9b83599d4039486ec5b9f911d", size = 206136, upload-time = "2026-03-06T06:01:40.016Z" }, + { url = "https://files.pythonhosted.org/packages/c7/87/942c3def1b37baf3cf786bad01249190f3ca3d5e63a84f831e704977de1f/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d27ce22ec453564770d29d03a9506d449efbb9fa13c00842262b2f6801c48cce", size = 202551, upload-time = "2026-03-06T06:01:41.522Z" }, + { url = "https://files.pythonhosted.org/packages/94/0a/af49691938dfe175d71b8a929bd7e4ace2809c0c5134e28bc535660d5262/charset_normalizer-3.4.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0625665e4ebdddb553ab185de5db7054393af8879fb0c87bd5690d14379d6819", size = 195572, upload-time = "2026-03-06T06:01:43.208Z" }, + { url = "https://files.pythonhosted.org/packages/20/ea/dfb1792a8050a8e694cfbde1570ff97ff74e48afd874152d38163d1df9ae/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:c23eb3263356d94858655b3e63f85ac5d50970c6e8febcdde7830209139cc37d", size = 184438, upload-time = "2026-03-06T06:01:44.755Z" }, + { url = "https://files.pythonhosted.org/packages/72/12/c281e2067466e3ddd0595bfaea58a6946765ace5c72dfa3edc2f5f118026/charset_normalizer-3.4.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e6302ca4ae283deb0af68d2fbf467474b8b6aedcd3dab4db187e07f94c109763", size = 193035, upload-time = "2026-03-06T06:01:46.051Z" }, + { url = "https://files.pythonhosted.org/packages/ba/4f/3792c056e7708e10464bad0438a44708886fb8f92e3c3d29ec5e2d964d42/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e51ae7d81c825761d941962450f50d041db028b7278e7b08930b4541b3e45cb9", size = 191340, upload-time = "2026-03-06T06:01:47.547Z" }, + { url = "https://files.pythonhosted.org/packages/e7/86/80ddba897127b5c7a9bccc481b0cd36c8fefa485d113262f0fe4332f0bf4/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:597d10dec876923e5c59e48dbd366e852eacb2b806029491d307daea6b917d7c", size = 185464, upload-time = "2026-03-06T06:01:48.764Z" }, + { url = "https://files.pythonhosted.org/packages/4d/00/b5eff85ba198faacab83e0e4b6f0648155f072278e3b392a82478f8b988b/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5cffde4032a197bd3b42fd0b9509ec60fb70918d6970e4cc773f20fc9180ca67", size = 208014, upload-time = "2026-03-06T06:01:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/c8/11/d36f70be01597fd30850dde8a1269ebc8efadd23ba5785808454f2389bde/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2da4eedcb6338e2321e831a0165759c0c620e37f8cd044a263ff67493be8ffb3", size = 193297, upload-time = "2026-03-06T06:01:51.933Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1d/259eb0a53d4910536c7c2abb9cb25f4153548efb42800c6a9456764649c0/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:65a126fb4b070d05340a84fc709dd9e7c75d9b063b610ece8a60197a291d0adf", size = 204321, upload-time = "2026-03-06T06:01:53.887Z" }, + { url = "https://files.pythonhosted.org/packages/84/31/faa6c5b9d3688715e1ed1bb9d124c384fe2fc1633a409e503ffe1c6398c1/charset_normalizer-3.4.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7a80a9242963416bd81f99349d5f3fce1843c303bd404f204918b6d75a75fd6", size = 197509, upload-time = "2026-03-06T06:01:56.439Z" }, + { url = "https://files.pythonhosted.org/packages/fd/a5/c7d9dd1503ffc08950b3260f5d39ec2366dd08254f0900ecbcf3a6197c7c/charset_normalizer-3.4.5-cp313-cp313-win32.whl", hash = "sha256:f1d725b754e967e648046f00c4facc42d414840f5ccc670c5670f59f83693e4f", size = 132284, upload-time = "2026-03-06T06:01:57.812Z" }, + { url = "https://files.pythonhosted.org/packages/b9/0f/57072b253af40c8aa6636e6de7d75985624c1eb392815b2f934199340a89/charset_normalizer-3.4.5-cp313-cp313-win_amd64.whl", hash = "sha256:e37bd100d2c5d3ba35db9c7c5ba5a9228cbcffe5c4778dc824b164e5257813d7", size = 142630, upload-time = "2026-03-06T06:01:59.062Z" }, + { url = "https://files.pythonhosted.org/packages/31/41/1c4b7cc9f13bd9d369ce3bc993e13d374ce25fa38a2663644283ecf422c1/charset_normalizer-3.4.5-cp313-cp313-win_arm64.whl", hash = "sha256:93b3b2cc5cf1b8743660ce77a4f45f3f6d1172068207c1defc779a36eea6bb36", size = 133254, upload-time = "2026-03-06T06:02:00.281Z" }, + { url = "https://files.pythonhosted.org/packages/43/be/0f0fd9bb4a7fa4fb5067fb7d9ac693d4e928d306f80a0d02bde43a7c4aee/charset_normalizer-3.4.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8197abe5ca1ffb7d91e78360f915eef5addff270f8a71c1fc5be24a56f3e4873", size = 280232, upload-time = "2026-03-06T06:02:01.508Z" }, + { url = "https://files.pythonhosted.org/packages/28/02/983b5445e4bef49cd8c9da73a8e029f0825f39b74a06d201bfaa2e55142a/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2aecdb364b8a1802afdc7f9327d55dad5366bc97d8502d0f5854e50712dbc5f", size = 189688, upload-time = "2026-03-06T06:02:02.857Z" }, + { url = "https://files.pythonhosted.org/packages/d0/88/152745c5166437687028027dc080e2daed6fe11cfa95a22f4602591c42db/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a66aa5022bf81ab4b1bebfb009db4fd68e0c6d4307a1ce5ef6a26e5878dfc9e4", size = 206833, upload-time = "2026-03-06T06:02:05.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0f/ebc15c8b02af2f19be9678d6eed115feeeccc45ce1f4b098d986c13e8769/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d77f97e515688bd615c1d1f795d540f32542d514242067adcb8ef532504cb9ee", size = 202879, upload-time = "2026-03-06T06:02:06.446Z" }, + { url = "https://files.pythonhosted.org/packages/38/9c/71336bff6934418dc8d1e8a1644176ac9088068bc571da612767619c97b3/charset_normalizer-3.4.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01a1ed54b953303ca7e310fafe0fe347aab348bd81834a0bcd602eb538f89d66", size = 195764, upload-time = "2026-03-06T06:02:08.763Z" }, + { url = "https://files.pythonhosted.org/packages/b7/95/ce92fde4f98615661871bc282a856cf9b8a15f686ba0af012984660d480b/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:b2d37d78297b39a9eb9eb92c0f6df98c706467282055419df141389b23f93362", size = 183728, upload-time = "2026-03-06T06:02:10.137Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e7/f5b4588d94e747ce45ae680f0f242bc2d98dbd4eccfab73e6160b6893893/charset_normalizer-3.4.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e71bbb595973622b817c042bd943c3f3667e9c9983ce3d205f973f486fec98a7", size = 192937, upload-time = "2026-03-06T06:02:11.663Z" }, + { url = "https://files.pythonhosted.org/packages/f9/29/9d94ed6b929bf9f48bf6ede6e7474576499f07c4c5e878fb186083622716/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4cd966c2559f501c6fd69294d082c2934c8dd4719deb32c22961a5ac6db0df1d", size = 192040, upload-time = "2026-03-06T06:02:13.489Z" }, + { url = "https://files.pythonhosted.org/packages/15/d2/1a093a1cf827957f9445f2fe7298bcc16f8fc5e05c1ed2ad1af0b239035e/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d5e52d127045d6ae01a1e821acfad2f3a1866c54d0e837828538fabe8d9d1bd6", size = 184107, upload-time = "2026-03-06T06:02:14.83Z" }, + { url = "https://files.pythonhosted.org/packages/0f/7d/82068ce16bd36135df7b97f6333c5d808b94e01d4599a682e2337ed5fd14/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:30a2b1a48478c3428d047ed9690d57c23038dac838a87ad624c85c0a78ebeb39", size = 208310, upload-time = "2026-03-06T06:02:16.165Z" }, + { url = "https://files.pythonhosted.org/packages/84/4e/4dfb52307bb6af4a5c9e73e482d171b81d36f522b21ccd28a49656baa680/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d8ed79b8f6372ca4254955005830fd61c1ccdd8c0fac6603e2c145c61dd95db6", size = 192918, upload-time = "2026-03-06T06:02:18.144Z" }, + { url = "https://files.pythonhosted.org/packages/08/a4/159ff7da662cf7201502ca89980b8f06acf3e887b278956646a8aeb178ab/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c5af897b45fa606b12464ccbe0014bbf8c09191e0a66aab6aa9d5cf6e77e0c94", size = 204615, upload-time = "2026-03-06T06:02:19.821Z" }, + { url = "https://files.pythonhosted.org/packages/d6/62/0dd6172203cb6b429ffffc9935001fde42e5250d57f07b0c28c6046deb6b/charset_normalizer-3.4.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1088345bcc93c58d8d8f3d783eca4a6e7a7752bbff26c3eee7e73c597c191c2e", size = 197784, upload-time = "2026-03-06T06:02:21.86Z" }, + { url = "https://files.pythonhosted.org/packages/c7/5e/1aab5cb737039b9c59e63627dc8bbc0d02562a14f831cc450e5f91d84ce1/charset_normalizer-3.4.5-cp314-cp314-win32.whl", hash = "sha256:ee57b926940ba00bca7ba7041e665cc956e55ef482f851b9b65acb20d867e7a2", size = 133009, upload-time = "2026-03-06T06:02:23.289Z" }, + { url = "https://files.pythonhosted.org/packages/40/65/e7c6c77d7aaa4c0d7974f2e403e17f0ed2cb0fc135f77d686b916bf1eead/charset_normalizer-3.4.5-cp314-cp314-win_amd64.whl", hash = "sha256:4481e6da1830c8a1cc0b746b47f603b653dadb690bcd851d039ffaefe70533aa", size = 143511, upload-time = "2026-03-06T06:02:26.195Z" }, + { url = "https://files.pythonhosted.org/packages/ba/91/52b0841c71f152f563b8e072896c14e3d83b195c188b338d3cc2e582d1d4/charset_normalizer-3.4.5-cp314-cp314-win_arm64.whl", hash = "sha256:97ab7787092eb9b50fb47fa04f24c75b768a606af1bcba1957f07f128a7219e4", size = 133775, upload-time = "2026-03-06T06:02:27.473Z" }, + { url = "https://files.pythonhosted.org/packages/c5/60/3a621758945513adfd4db86827a5bafcc615f913dbd0b4c2ed64a65731be/charset_normalizer-3.4.5-py3-none-any.whl", hash = "sha256:9db5e3fcdcee89a78c04dffb3fe33c79f77bd741a624946db2591c81b2fc85b0", size = 55455, upload-time = "2026-03-06T06:03:17.827Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "cryptography" +version = "46.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, + { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, + { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, + { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, + { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, + { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, + { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, + { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, + { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" }, + { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" }, + { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" }, + { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" }, + { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" }, + { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, + { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, + { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, + { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, + { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, + { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, + { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, + { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, + { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, + { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, + { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, + { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, + { url = "https://files.pythonhosted.org/packages/eb/dd/2d9fdb07cebdf3d51179730afb7d5e576153c6744c3ff8fded23030c204e/cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c", size = 3476964, upload-time = "2026-02-10T19:18:20.687Z" }, + { url = "https://files.pythonhosted.org/packages/e9/6f/6cc6cc9955caa6eaf83660b0da2b077c7fe8ff9950a3c5e45d605038d439/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a", size = 4218321, upload-time = "2026-02-10T19:18:22.349Z" }, + { url = "https://files.pythonhosted.org/packages/3e/5d/c4da701939eeee699566a6c1367427ab91a8b7088cc2328c09dbee940415/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356", size = 4381786, upload-time = "2026-02-10T19:18:24.529Z" }, + { url = "https://files.pythonhosted.org/packages/ac/97/a538654732974a94ff96c1db621fa464f455c02d4bb7d2652f4edc21d600/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da", size = 4217990, upload-time = "2026-02-10T19:18:25.957Z" }, + { url = "https://files.pythonhosted.org/packages/ae/11/7e500d2dd3ba891197b9efd2da5454b74336d64a7cc419aa7327ab74e5f6/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257", size = 4381252, upload-time = "2026-02-10T19:18:27.496Z" }, + { url = "https://files.pythonhosted.org/packages/bc/58/6b3d24e6b9bc474a2dcdee65dfd1f008867015408a271562e4b690561a4d/cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", size = 3407605, upload-time = "2026-02-10T19:18:29.233Z" }, +] + +[[package]] +name = "deprecation" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/d3/8ae2869247df154b64c1884d7346d412fed0c49df84db635aab2d1c40e62/deprecation-2.1.0.tar.gz", hash = "sha256:72b3bde64e5d778694b0cf68178aed03d15e15477116add3fb773e581f9518ff", size = 173788, upload-time = "2020-04-20T14:23:38.738Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/c3/253a89ee03fc9b9682f1541728eb66db7db22148cd94f89ab22528cd1e1b/deprecation-2.1.0-py2.py3-none-any.whl", hash = "sha256:a10811591210e1fb0e768a8c25517cabeabcba6f0bf96564f8ff45189f90b14a", size = 11178, upload-time = "2020-04-20T14:23:36.581Z" }, +] + +[[package]] +name = "distro" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, +] + +[[package]] +name = "fastapi" +version = "0.135.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/7b/f8e0211e9380f7195ba3f3d40c292594fd81ba8ec4629e3854c353aaca45/fastapi-0.135.1.tar.gz", hash = "sha256:d04115b508d936d254cea545b7312ecaa58a7b3a0f84952535b4c9afae7668cd", size = 394962, upload-time = "2026-03-01T18:18:29.369Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/72/42e900510195b23a56bde950d26a51f8b723846bfcaa0286e90287f0422b/fastapi-0.135.1-py3-none-any.whl", hash = "sha256:46e2fc5745924b7c840f71ddd277382af29ce1cdb7d5eab5bf697e3fb9999c9e", size = 116999, upload-time = "2026-03-01T18:18:30.831Z" }, +] + +[[package]] +name = "fsspec" +version = "2026.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/7c/f60c259dcbf4f0c47cc4ddb8f7720d2dcdc8888c8e5ad84c73ea4531cc5b/fsspec-2026.2.0.tar.gz", hash = "sha256:6544e34b16869f5aacd5b90bdf1a71acb37792ea3ddf6125ee69a22a53fb8bff", size = 313441, upload-time = "2026-02-05T21:50:53.743Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ab/fb21f4c939bb440104cc2b396d3be1d9b7a9fd3c6c2a53d98c45b3d7c954/fsspec-2026.2.0-py3-none-any.whl", hash = "sha256:98de475b5cb3bd66bedd5c4679e87b4fdfe1a3bf4d707b151b3c07e58c9a2437", size = 202505, upload-time = "2026-02-05T21:50:51.819Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "h2" +version = "4.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hpack" }, + { name = "hyperframe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1d/17/afa56379f94ad0fe8defd37d6eb3f89a25404ffc71d4d848893d270325fc/h2-4.3.0.tar.gz", hash = "sha256:6c59efe4323fa18b47a632221a1888bd7fde6249819beda254aeca909f221bf1", size = 2152026, upload-time = "2025-08-23T18:12:19.778Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/b2/119f6e6dcbd96f9069ce9a2665e0146588dc9f88f29549711853645e736a/h2-4.3.0-py3-none-any.whl", hash = "sha256:c438f029a25f7945c69e0ccf0fb951dc3f73a5f6412981daee861431b70e2bdd", size = 61779, upload-time = "2025-08-23T18:12:17.779Z" }, +] + +[[package]] +name = "hpack" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/48/71de9ed269fdae9c8057e5a4c0aa7402e8bb16f2c6e90b3aa53327b113f8/hpack-4.1.0.tar.gz", hash = "sha256:ec5eca154f7056aa06f196a557655c5b009b382873ac8d1e66e79e87535f1dca", size = 51276, upload-time = "2025-01-22T21:44:58.347Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/c6/80c95b1b2b94682a72cbdbfb85b81ae2daffa4291fbfa1b1464502ede10d/hpack-4.1.0-py3-none-any.whl", hash = "sha256:157ac792668d995c657d93111f46b4535ed114f0c9c8d672271bbec7eae1b496", size = 34357, upload-time = "2025-01-22T21:44:56.92Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/08/17e07e8d89ab8f343c134616d72eebfe03798835058e2ab579dcc8353c06/httptools-0.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:474d3b7ab469fefcca3697a10d11a32ee2b9573250206ba1e50d5980910da657", size = 206521, upload-time = "2025-10-10T03:54:31.002Z" }, + { url = "https://files.pythonhosted.org/packages/aa/06/c9c1b41ff52f16aee526fd10fbda99fa4787938aa776858ddc4a1ea825ec/httptools-0.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3c3b7366bb6c7b96bd72d0dbe7f7d5eead261361f013be5f6d9590465ea1c70", size = 110375, upload-time = "2025-10-10T03:54:31.941Z" }, + { url = "https://files.pythonhosted.org/packages/cc/cc/10935db22fda0ee34c76f047590ca0a8bd9de531406a3ccb10a90e12ea21/httptools-0.7.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:379b479408b8747f47f3b253326183d7c009a3936518cdb70db58cffd369d9df", size = 456621, upload-time = "2025-10-10T03:54:33.176Z" }, + { url = "https://files.pythonhosted.org/packages/0e/84/875382b10d271b0c11aa5d414b44f92f8dd53e9b658aec338a79164fa548/httptools-0.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cad6b591a682dcc6cf1397c3900527f9affef1e55a06c4547264796bbd17cf5e", size = 454954, upload-time = "2025-10-10T03:54:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/30/e1/44f89b280f7e46c0b1b2ccee5737d46b3bb13136383958f20b580a821ca0/httptools-0.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:eb844698d11433d2139bbeeb56499102143beb582bd6c194e3ba69c22f25c274", size = 440175, upload-time = "2025-10-10T03:54:35.942Z" }, + { url = "https://files.pythonhosted.org/packages/6f/7e/b9287763159e700e335028bc1824359dc736fa9b829dacedace91a39b37e/httptools-0.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f65744d7a8bdb4bda5e1fa23e4ba16832860606fcc09d674d56e425e991539ec", size = 440310, upload-time = "2025-10-10T03:54:37.1Z" }, + { url = "https://files.pythonhosted.org/packages/b3/07/5b614f592868e07f5c94b1f301b5e14a21df4e8076215a3bccb830a687d8/httptools-0.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:135fbe974b3718eada677229312e97f3b31f8a9c8ffa3ae6f565bf808d5b6bcb", size = 86875, upload-time = "2025-10-10T03:54:38.421Z" }, + { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" }, + { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" }, + { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" }, + { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" }, + { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" }, + { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, + { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, + { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" }, + { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, + { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, + { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, + { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" }, + { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" }, + { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" }, + { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" }, + { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[package.optional-dependencies] +http2 = [ + { name = "h2" }, +] + +[[package]] +name = "hyperframe" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566, upload-time = "2025-01-22T21:41:49.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007, upload-time = "2025-01-22T21:41:47.295Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "jiter" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/5e/4ec91646aee381d01cdb9974e30882c9cd3b8c5d1079d6b5ff4af522439a/jiter-0.13.0.tar.gz", hash = "sha256:f2839f9c2c7e2dffc1bc5929a510e14ce0a946be9365fd1219e7ef342dae14f4", size = 164847, upload-time = "2026-02-02T12:37:56.441Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/29/499f8c9eaa8a16751b1c0e45e6f5f1761d180da873d417996cc7bddc8eef/jiter-0.13.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ea026e70a9a28ebbdddcbcf0f1323128a8db66898a06eaad3a4e62d2f554d096", size = 311157, upload-time = "2026-02-02T12:35:37.758Z" }, + { url = "https://files.pythonhosted.org/packages/50/f6/566364c777d2ab450b92100bea11333c64c38d32caf8dc378b48e5b20c46/jiter-0.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:66aa3e663840152d18cc8ff1e4faad3dd181373491b9cfdc6004b92198d67911", size = 319729, upload-time = "2026-02-02T12:35:39.246Z" }, + { url = "https://files.pythonhosted.org/packages/73/dd/560f13ec5e4f116d8ad2658781646cca91b617ae3b8758d4a5076b278f70/jiter-0.13.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3524798e70655ff19aec58c7d05adb1f074fecff62da857ea9be2b908b6d701", size = 354766, upload-time = "2026-02-02T12:35:40.662Z" }, + { url = "https://files.pythonhosted.org/packages/7c/0d/061faffcfe94608cbc28a0d42a77a74222bdf5055ccdbe5fd2292b94f510/jiter-0.13.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ec7e287d7fbd02cb6e22f9a00dd9c9cd504c40a61f2c61e7e1f9690a82726b4c", size = 362587, upload-time = "2026-02-02T12:35:42.025Z" }, + { url = "https://files.pythonhosted.org/packages/92/c9/c66a7864982fd38a9773ec6e932e0398d1262677b8c60faecd02ffb67bf3/jiter-0.13.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:47455245307e4debf2ce6c6e65a717550a0244231240dcf3b8f7d64e4c2f22f4", size = 487537, upload-time = "2026-02-02T12:35:43.459Z" }, + { url = "https://files.pythonhosted.org/packages/6c/86/84eb4352cd3668f16d1a88929b5888a3fe0418ea8c1dfc2ad4e7bf6e069a/jiter-0.13.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ee9da221dca6e0429c2704c1b3655fe7b025204a71d4d9b73390c759d776d165", size = 373717, upload-time = "2026-02-02T12:35:44.928Z" }, + { url = "https://files.pythonhosted.org/packages/6e/09/9fe4c159358176f82d4390407a03f506a8659ed13ca3ac93a843402acecf/jiter-0.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:24ab43126d5e05f3d53a36a8e11eb2f23304c6c1117844aaaf9a0aa5e40b5018", size = 362683, upload-time = "2026-02-02T12:35:46.636Z" }, + { url = "https://files.pythonhosted.org/packages/c9/5e/85f3ab9caca0c1d0897937d378b4a515cae9e119730563572361ea0c48ae/jiter-0.13.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9da38b4fedde4fb528c740c2564628fbab737166a0e73d6d46cb4bb5463ff411", size = 392345, upload-time = "2026-02-02T12:35:48.088Z" }, + { url = "https://files.pythonhosted.org/packages/12/4c/05b8629ad546191939e6f0c2f17e29f542a398f4a52fb987bc70b6d1eb8b/jiter-0.13.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0b34c519e17658ed88d5047999a93547f8889f3c1824120c26ad6be5f27b6cf5", size = 517775, upload-time = "2026-02-02T12:35:49.482Z" }, + { url = "https://files.pythonhosted.org/packages/4d/88/367ea2eb6bc582c7052e4baf5ddf57ebe5ab924a88e0e09830dfb585c02d/jiter-0.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d2a6394e6af690d462310a86b53c47ad75ac8c21dc79f120714ea449979cb1d3", size = 551325, upload-time = "2026-02-02T12:35:51.104Z" }, + { url = "https://files.pythonhosted.org/packages/f3/12/fa377ffb94a2f28c41afaed093e0d70cfe512035d5ecb0cad0ae4792d35e/jiter-0.13.0-cp311-cp311-win32.whl", hash = "sha256:0f0c065695f616a27c920a56ad0d4fc46415ef8b806bf8fc1cacf25002bd24e1", size = 204709, upload-time = "2026-02-02T12:35:52.467Z" }, + { url = "https://files.pythonhosted.org/packages/cb/16/8e8203ce92f844dfcd3d9d6a5a7322c77077248dbb12da52d23193a839cd/jiter-0.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:0733312953b909688ae3c2d58d043aa040f9f1a6a75693defed7bc2cc4bf2654", size = 204560, upload-time = "2026-02-02T12:35:53.925Z" }, + { url = "https://files.pythonhosted.org/packages/44/26/97cc40663deb17b9e13c3a5cf29251788c271b18ee4d262c8f94798b8336/jiter-0.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:5d9b34ad56761b3bf0fbe8f7e55468704107608512350962d3317ffd7a4382d5", size = 189608, upload-time = "2026-02-02T12:35:55.304Z" }, + { url = "https://files.pythonhosted.org/packages/2e/30/7687e4f87086829955013ca12a9233523349767f69653ebc27036313def9/jiter-0.13.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0a2bd69fc1d902e89925fc34d1da51b2128019423d7b339a45d9e99c894e0663", size = 307958, upload-time = "2026-02-02T12:35:57.165Z" }, + { url = "https://files.pythonhosted.org/packages/c3/27/e57f9a783246ed95481e6749cc5002a8a767a73177a83c63ea71f0528b90/jiter-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f917a04240ef31898182f76a332f508f2cc4b57d2b4d7ad2dbfebbfe167eb505", size = 318597, upload-time = "2026-02-02T12:35:58.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/52/e5719a60ac5d4d7c5995461a94ad5ef962a37c8bf5b088390e6fad59b2ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1e2b199f446d3e82246b4fd9236d7cb502dc2222b18698ba0d986d2fecc6152", size = 348821, upload-time = "2026-02-02T12:36:00.093Z" }, + { url = "https://files.pythonhosted.org/packages/61/db/c1efc32b8ba4c740ab3fc2d037d8753f67685f475e26b9d6536a4322bcdd/jiter-0.13.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:04670992b576fa65bd056dbac0c39fe8bd67681c380cb2b48efa885711d9d726", size = 364163, upload-time = "2026-02-02T12:36:01.937Z" }, + { url = "https://files.pythonhosted.org/packages/55/8a/fb75556236047c8806995671a18e4a0ad646ed255276f51a20f32dceaeec/jiter-0.13.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a1aff1fbdb803a376d4d22a8f63f8e7ccbce0b4890c26cc7af9e501ab339ef0", size = 483709, upload-time = "2026-02-02T12:36:03.41Z" }, + { url = "https://files.pythonhosted.org/packages/7e/16/43512e6ee863875693a8e6f6d532e19d650779d6ba9a81593ae40a9088ff/jiter-0.13.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b3fb8c2053acaef8580809ac1d1f7481a0a0bdc012fd7f5d8b18fb696a5a089", size = 370480, upload-time = "2026-02-02T12:36:04.791Z" }, + { url = "https://files.pythonhosted.org/packages/f8/4c/09b93e30e984a187bc8aaa3510e1ec8dcbdcd71ca05d2f56aac0492453aa/jiter-0.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bdaba7d87e66f26a2c45d8cbadcbfc4bf7884182317907baf39cfe9775bb4d93", size = 360735, upload-time = "2026-02-02T12:36:06.994Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1b/46c5e349019874ec5dfa508c14c37e29864ea108d376ae26d90bee238cd7/jiter-0.13.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b88d649135aca526da172e48083da915ec086b54e8e73a425ba50999468cc08", size = 391814, upload-time = "2026-02-02T12:36:08.368Z" }, + { url = "https://files.pythonhosted.org/packages/15/9e/26184760e85baee7162ad37b7912797d2077718476bf91517641c92b3639/jiter-0.13.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:e404ea551d35438013c64b4f357b0474c7abf9f781c06d44fcaf7a14c69ff9e2", size = 513990, upload-time = "2026-02-02T12:36:09.993Z" }, + { url = "https://files.pythonhosted.org/packages/e9/34/2c9355247d6debad57a0a15e76ab1566ab799388042743656e566b3b7de1/jiter-0.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1f4748aad1b4a93c8bdd70f604d0f748cdc0e8744c5547798acfa52f10e79228", size = 548021, upload-time = "2026-02-02T12:36:11.376Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4a/9f2c23255d04a834398b9c2e0e665382116911dc4d06b795710503cdad25/jiter-0.13.0-cp312-cp312-win32.whl", hash = "sha256:0bf670e3b1445fc4d31612199f1744f67f889ee1bbae703c4b54dc097e5dd394", size = 203024, upload-time = "2026-02-02T12:36:12.682Z" }, + { url = "https://files.pythonhosted.org/packages/09/ee/f0ae675a957ae5a8f160be3e87acea6b11dc7b89f6b7ab057e77b2d2b13a/jiter-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:15db60e121e11fe186c0b15236bd5d18381b9ddacdcf4e659feb96fc6c969c92", size = 205424, upload-time = "2026-02-02T12:36:13.93Z" }, + { url = "https://files.pythonhosted.org/packages/1b/02/ae611edf913d3cbf02c97cdb90374af2082c48d7190d74c1111dde08bcdd/jiter-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:41f92313d17989102f3cb5dd533a02787cdb99454d494344b0361355da52fcb9", size = 186818, upload-time = "2026-02-02T12:36:15.308Z" }, + { url = "https://files.pythonhosted.org/packages/91/9c/7ee5a6ff4b9991e1a45263bfc46731634c4a2bde27dfda6c8251df2d958c/jiter-0.13.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1f8a55b848cbabf97d861495cd65f1e5c590246fabca8b48e1747c4dfc8f85bf", size = 306897, upload-time = "2026-02-02T12:36:16.748Z" }, + { url = "https://files.pythonhosted.org/packages/7c/02/be5b870d1d2be5dd6a91bdfb90f248fbb7dcbd21338f092c6b89817c3dbf/jiter-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f556aa591c00f2c45eb1b89f68f52441a016034d18b65da60e2d2875bbbf344a", size = 317507, upload-time = "2026-02-02T12:36:18.351Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/b25d2ec333615f5f284f3a4024f7ce68cfa0604c322c6808b2344c7f5d2b/jiter-0.13.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f7e1d61da332ec412350463891923f960c3073cf1aae93b538f0bb4c8cd46efb", size = 350560, upload-time = "2026-02-02T12:36:19.746Z" }, + { url = "https://files.pythonhosted.org/packages/be/ec/74dcb99fef0aca9fbe56b303bf79f6bd839010cb18ad41000bf6cc71eec0/jiter-0.13.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3097d665a27bc96fd9bbf7f86178037db139f319f785e4757ce7ccbf390db6c2", size = 363232, upload-time = "2026-02-02T12:36:21.243Z" }, + { url = "https://files.pythonhosted.org/packages/1b/37/f17375e0bb2f6a812d4dd92d7616e41917f740f3e71343627da9db2824ce/jiter-0.13.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d01ecc3a8cbdb6f25a37bd500510550b64ddf9f7d64a107d92f3ccb25035d0f", size = 483727, upload-time = "2026-02-02T12:36:22.688Z" }, + { url = "https://files.pythonhosted.org/packages/77/d2/a71160a5ae1a1e66c1395b37ef77da67513b0adba73b993a27fbe47eb048/jiter-0.13.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed9bbc30f5d60a3bdf63ae76beb3f9db280d7f195dfcfa61af792d6ce912d159", size = 370799, upload-time = "2026-02-02T12:36:24.106Z" }, + { url = "https://files.pythonhosted.org/packages/01/99/ed5e478ff0eb4e8aa5fd998f9d69603c9fd3f32de3bd16c2b1194f68361c/jiter-0.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98fbafb6e88256f4454de33c1f40203d09fc33ed19162a68b3b257b29ca7f663", size = 359120, upload-time = "2026-02-02T12:36:25.519Z" }, + { url = "https://files.pythonhosted.org/packages/16/be/7ffd08203277a813f732ba897352797fa9493faf8dc7995b31f3d9cb9488/jiter-0.13.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5467696f6b827f1116556cb0db620440380434591e93ecee7fd14d1a491b6daa", size = 390664, upload-time = "2026-02-02T12:36:26.866Z" }, + { url = "https://files.pythonhosted.org/packages/d1/84/e0787856196d6d346264d6dcccb01f741e5f0bd014c1d9a2ebe149caf4f3/jiter-0.13.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:2d08c9475d48b92892583df9da592a0e2ac49bcd41fae1fec4f39ba6cf107820", size = 513543, upload-time = "2026-02-02T12:36:28.217Z" }, + { url = "https://files.pythonhosted.org/packages/65/50/ecbd258181c4313cf79bca6c88fb63207d04d5bf5e4f65174114d072aa55/jiter-0.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:aed40e099404721d7fcaf5b89bd3b4568a4666358bcac7b6b15c09fb6252ab68", size = 547262, upload-time = "2026-02-02T12:36:29.678Z" }, + { url = "https://files.pythonhosted.org/packages/27/da/68f38d12e7111d2016cd198161b36e1f042bd115c169255bcb7ec823a3bf/jiter-0.13.0-cp313-cp313-win32.whl", hash = "sha256:36ebfbcffafb146d0e6ffb3e74d51e03d9c35ce7c625c8066cdbfc7b953bdc72", size = 200630, upload-time = "2026-02-02T12:36:31.808Z" }, + { url = "https://files.pythonhosted.org/packages/25/65/3bd1a972c9a08ecd22eb3b08a95d1941ebe6938aea620c246cf426ae09c2/jiter-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:8d76029f077379374cf0dbc78dbe45b38dec4a2eb78b08b5194ce836b2517afc", size = 202602, upload-time = "2026-02-02T12:36:33.679Z" }, + { url = "https://files.pythonhosted.org/packages/15/fe/13bd3678a311aa67686bb303654792c48206a112068f8b0b21426eb6851e/jiter-0.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:bb7613e1a427cfcb6ea4544f9ac566b93d5bf67e0d48c787eca673ff9c9dff2b", size = 185939, upload-time = "2026-02-02T12:36:35.065Z" }, + { url = "https://files.pythonhosted.org/packages/49/19/a929ec002ad3228bc97ca01dbb14f7632fffdc84a95ec92ceaf4145688ae/jiter-0.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fa476ab5dd49f3bf3a168e05f89358c75a17608dbabb080ef65f96b27c19ab10", size = 316616, upload-time = "2026-02-02T12:36:36.579Z" }, + { url = "https://files.pythonhosted.org/packages/52/56/d19a9a194afa37c1728831e5fb81b7722c3de18a3109e8f282bfc23e587a/jiter-0.13.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ade8cb6ff5632a62b7dbd4757d8c5573f7a2e9ae285d6b5b841707d8363205ef", size = 346850, upload-time = "2026-02-02T12:36:38.058Z" }, + { url = "https://files.pythonhosted.org/packages/36/4a/94e831c6bf287754a8a019cb966ed39ff8be6ab78cadecf08df3bb02d505/jiter-0.13.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9950290340acc1adaded363edd94baebcee7dabdfa8bee4790794cd5cfad2af6", size = 358551, upload-time = "2026-02-02T12:36:39.417Z" }, + { url = "https://files.pythonhosted.org/packages/a2/ec/a4c72c822695fa80e55d2b4142b73f0012035d9fcf90eccc56bc060db37c/jiter-0.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:2b4972c6df33731aac0742b64fd0d18e0a69bc7d6e03108ce7d40c85fd9e3e6d", size = 201950, upload-time = "2026-02-02T12:36:40.791Z" }, + { url = "https://files.pythonhosted.org/packages/b6/00/393553ec27b824fbc29047e9c7cd4a3951d7fbe4a76743f17e44034fa4e4/jiter-0.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:701a1e77d1e593c1b435315ff625fd071f0998c5f02792038a5ca98899261b7d", size = 185852, upload-time = "2026-02-02T12:36:42.077Z" }, + { url = "https://files.pythonhosted.org/packages/6e/f5/f1997e987211f6f9bd71b8083047b316208b4aca0b529bb5f8c96c89ef3e/jiter-0.13.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:cc5223ab19fe25e2f0bf2643204ad7318896fe3729bf12fde41b77bfc4fafff0", size = 308804, upload-time = "2026-02-02T12:36:43.496Z" }, + { url = "https://files.pythonhosted.org/packages/cd/8f/5482a7677731fd44881f0204981ce2d7175db271f82cba2085dd2212e095/jiter-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9776ebe51713acf438fd9b4405fcd86893ae5d03487546dae7f34993217f8a91", size = 318787, upload-time = "2026-02-02T12:36:45.071Z" }, + { url = "https://files.pythonhosted.org/packages/f3/b9/7257ac59778f1cd025b26a23c5520a36a424f7f1b068f2442a5b499b7464/jiter-0.13.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:879e768938e7b49b5e90b7e3fecc0dbec01b8cb89595861fb39a8967c5220d09", size = 353880, upload-time = "2026-02-02T12:36:47.365Z" }, + { url = "https://files.pythonhosted.org/packages/c3/87/719eec4a3f0841dad99e3d3604ee4cba36af4419a76f3cb0b8e2e691ad67/jiter-0.13.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:682161a67adea11e3aae9038c06c8b4a9a71023228767477d683f69903ebc607", size = 366702, upload-time = "2026-02-02T12:36:48.871Z" }, + { url = "https://files.pythonhosted.org/packages/d2/65/415f0a75cf6921e43365a1bc227c565cb949caca8b7532776e430cbaa530/jiter-0.13.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a13b68cd1cd8cc9de8f244ebae18ccb3e4067ad205220ef324c39181e23bbf66", size = 486319, upload-time = "2026-02-02T12:36:53.006Z" }, + { url = "https://files.pythonhosted.org/packages/54/a2/9e12b48e82c6bbc6081fd81abf915e1443add1b13d8fc586e1d90bb02bb8/jiter-0.13.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87ce0f14c6c08892b610686ae8be350bf368467b6acd5085a5b65441e2bf36d2", size = 372289, upload-time = "2026-02-02T12:36:54.593Z" }, + { url = "https://files.pythonhosted.org/packages/4e/c1/e4693f107a1789a239c759a432e9afc592366f04e901470c2af89cfd28e1/jiter-0.13.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c365005b05505a90d1c47856420980d0237adf82f70c4aff7aebd3c1cc143ad", size = 360165, upload-time = "2026-02-02T12:36:56.112Z" }, + { url = "https://files.pythonhosted.org/packages/17/08/91b9ea976c1c758240614bd88442681a87672eebc3d9a6dde476874e706b/jiter-0.13.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1317fdffd16f5873e46ce27d0e0f7f4f90f0cdf1d86bf6abeaea9f63ca2c401d", size = 389634, upload-time = "2026-02-02T12:36:57.495Z" }, + { url = "https://files.pythonhosted.org/packages/18/23/58325ef99390d6d40427ed6005bf1ad54f2577866594bcf13ce55675f87d/jiter-0.13.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:c05b450d37ba0c9e21c77fef1f205f56bcee2330bddca68d344baebfc55ae0df", size = 514933, upload-time = "2026-02-02T12:36:58.909Z" }, + { url = "https://files.pythonhosted.org/packages/5b/25/69f1120c7c395fd276c3996bb8adefa9c6b84c12bb7111e5c6ccdcd8526d/jiter-0.13.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:775e10de3849d0631a97c603f996f518159272db00fdda0a780f81752255ee9d", size = 548842, upload-time = "2026-02-02T12:37:00.433Z" }, + { url = "https://files.pythonhosted.org/packages/18/05/981c9669d86850c5fbb0d9e62bba144787f9fba84546ba43d624ee27ef29/jiter-0.13.0-cp314-cp314-win32.whl", hash = "sha256:632bf7c1d28421c00dd8bbb8a3bac5663e1f57d5cd5ed962bce3c73bf62608e6", size = 202108, upload-time = "2026-02-02T12:37:01.718Z" }, + { url = "https://files.pythonhosted.org/packages/8d/96/cdcf54dd0b0341db7d25413229888a346c7130bd20820530905fdb65727b/jiter-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:f22ef501c3f87ede88f23f9b11e608581c14f04db59b6a801f354397ae13739f", size = 204027, upload-time = "2026-02-02T12:37:03.075Z" }, + { url = "https://files.pythonhosted.org/packages/fb/f9/724bcaaab7a3cd727031fe4f6995cb86c4bd344909177c186699c8dec51a/jiter-0.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:07b75fe09a4ee8e0c606200622e571e44943f47254f95e2436c8bdcaceb36d7d", size = 187199, upload-time = "2026-02-02T12:37:04.414Z" }, + { url = "https://files.pythonhosted.org/packages/62/92/1661d8b9fd6a3d7a2d89831db26fe3c1509a287d83ad7838831c7b7a5c7e/jiter-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:964538479359059a35fb400e769295d4b315ae61e4105396d355a12f7fef09f0", size = 318423, upload-time = "2026-02-02T12:37:05.806Z" }, + { url = "https://files.pythonhosted.org/packages/4f/3b/f77d342a54d4ebcd128e520fc58ec2f5b30a423b0fd26acdfc0c6fef8e26/jiter-0.13.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e104da1db1c0991b3eaed391ccd650ae8d947eab1480c733e5a3fb28d4313e40", size = 351438, upload-time = "2026-02-02T12:37:07.189Z" }, + { url = "https://files.pythonhosted.org/packages/76/b3/ba9a69f0e4209bd3331470c723c2f5509e6f0482e416b612431a5061ed71/jiter-0.13.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e3a5f0cde8ff433b8e88e41aa40131455420fb3649a3c7abdda6145f8cb7202", size = 364774, upload-time = "2026-02-02T12:37:08.579Z" }, + { url = "https://files.pythonhosted.org/packages/b3/16/6cdb31fa342932602458dbb631bfbd47f601e03d2e4950740e0b2100b570/jiter-0.13.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:57aab48f40be1db920a582b30b116fe2435d184f77f0e4226f546794cedd9cf0", size = 487238, upload-time = "2026-02-02T12:37:10.066Z" }, + { url = "https://files.pythonhosted.org/packages/ed/b1/956cc7abaca8d95c13aa8d6c9b3f3797241c246cd6e792934cc4c8b250d2/jiter-0.13.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7772115877c53f62beeb8fd853cab692dbc04374ef623b30f997959a4c0e7e95", size = 372892, upload-time = "2026-02-02T12:37:11.656Z" }, + { url = "https://files.pythonhosted.org/packages/26/c4/97ecde8b1e74f67b8598c57c6fccf6df86ea7861ed29da84629cdbba76c4/jiter-0.13.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1211427574b17b633cfceba5040de8081e5abf114f7a7602f73d2e16f9fdaa59", size = 360309, upload-time = "2026-02-02T12:37:13.244Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d7/eabe3cf46715854ccc80be2cd78dd4c36aedeb30751dbf85a1d08c14373c/jiter-0.13.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7beae3a3d3b5212d3a55d2961db3c292e02e302feb43fce6a3f7a31b90ea6dfe", size = 389607, upload-time = "2026-02-02T12:37:14.881Z" }, + { url = "https://files.pythonhosted.org/packages/df/2d/03963fc0804e6109b82decfb9974eb92df3797fe7222428cae12f8ccaa0c/jiter-0.13.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:e5562a0f0e90a6223b704163ea28e831bd3a9faa3512a711f031611e6b06c939", size = 514986, upload-time = "2026-02-02T12:37:16.326Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6c/8c83b45eb3eb1c1e18d841fe30b4b5bc5619d781267ca9bc03e005d8fd0a/jiter-0.13.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:6c26a424569a59140fb51160a56df13f438a2b0967365e987889186d5fc2f6f9", size = 548756, upload-time = "2026-02-02T12:37:17.736Z" }, + { url = "https://files.pythonhosted.org/packages/47/66/eea81dfff765ed66c68fd2ed8c96245109e13c896c2a5015c7839c92367e/jiter-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:24dc96eca9f84da4131cdf87a95e6ce36765c3b156fc9ae33280873b1c32d5f6", size = 201196, upload-time = "2026-02-02T12:37:19.101Z" }, + { url = "https://files.pythonhosted.org/packages/ff/32/4ac9c7a76402f8f00d00842a7f6b83b284d0cf7c1e9d4227bc95aa6d17fa/jiter-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0a8d76c7524087272c8ae913f5d9d608bd839154b62c4322ef65723d2e5bb0b8", size = 204215, upload-time = "2026-02-02T12:37:20.495Z" }, + { url = "https://files.pythonhosted.org/packages/f9/8e/7def204fea9f9be8b3c21a6f2dd6c020cf56c7d5ff753e0e23ed7f9ea57e/jiter-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2c26cf47e2cad140fa23b6d58d435a7c0161f5c514284802f25e87fddfe11024", size = 187152, upload-time = "2026-02-02T12:37:22.124Z" }, + { url = "https://files.pythonhosted.org/packages/79/b3/3c29819a27178d0e461a8571fb63c6ae38be6dc36b78b3ec2876bbd6a910/jiter-0.13.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b1cbfa133241d0e6bdab48dcdc2604e8ba81512f6bbd68ec3e8e1357dd3c316c", size = 307016, upload-time = "2026-02-02T12:37:42.755Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ae/60993e4b07b1ac5ebe46da7aa99fdbb802eb986c38d26e3883ac0125c4e0/jiter-0.13.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:db367d8be9fad6e8ebbac4a7578b7af562e506211036cba2c06c3b998603c3d2", size = 305024, upload-time = "2026-02-02T12:37:44.774Z" }, + { url = "https://files.pythonhosted.org/packages/77/fa/2227e590e9cf98803db2811f172b2d6460a21539ab73006f251c66f44b14/jiter-0.13.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45f6f8efb2f3b0603092401dc2df79fa89ccbc027aaba4174d2d4133ed661434", size = 339337, upload-time = "2026-02-02T12:37:46.668Z" }, + { url = "https://files.pythonhosted.org/packages/2d/92/015173281f7eb96c0ef580c997da8ef50870d4f7f4c9e03c845a1d62ae04/jiter-0.13.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:597245258e6ad085d064780abfb23a284d418d3e61c57362d9449c6c7317ee2d", size = 346395, upload-time = "2026-02-02T12:37:48.09Z" }, + { url = "https://files.pythonhosted.org/packages/80/60/e50fa45dd7e2eae049f0ce964663849e897300433921198aef94b6ffa23a/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:3d744a6061afba08dd7ae375dcde870cffb14429b7477e10f67e9e6d68772a0a", size = 305169, upload-time = "2026-02-02T12:37:50.376Z" }, + { url = "https://files.pythonhosted.org/packages/d2/73/a009f41c5eed71c49bec53036c4b33555afcdee70682a18c6f66e396c039/jiter-0.13.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:ff732bd0a0e778f43d5009840f20b935e79087b4dc65bd36f1cd0f9b04b8ff7f", size = 303808, upload-time = "2026-02-02T12:37:52.092Z" }, + { url = "https://files.pythonhosted.org/packages/c4/10/528b439290763bff3d939268085d03382471b442f212dca4ff5f12802d43/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ab44b178f7981fcaea7e0a5df20e773c663d06ffda0198f1a524e91b2fde7e59", size = 337384, upload-time = "2026-02-02T12:37:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/67/8a/a342b2f0251f3dac4ca17618265d93bf244a2a4d089126e81e4c1056ac50/jiter-0.13.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7bb00b6d26db67a05fe3e12c76edc75f32077fb51deed13822dc648fa373bc19", size = 343768, upload-time = "2026-02-02T12:37:55.055Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mmh3" +version = "5.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/91/1a/edb23803a168f070ded7a3014c6d706f63b90c84ccc024f89d794a3b7a6d/mmh3-5.2.1.tar.gz", hash = "sha256:bbea5b775f0ac84945191fb83f845a6fd9a21a03ea7f2e187defac7e401616ad", size = 33775, upload-time = "2026-03-05T15:55:57.716Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/65/d7/3312a59df3c1cdd783f4cf0c4ee8e9decff9c5466937182e4cc7dbbfe6c5/mmh3-5.2.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:dae0f0bd7d30c0ad61b9a504e8e272cb8391eed3f1587edf933f4f6b33437450", size = 56082, upload-time = "2026-03-05T15:53:59.702Z" }, + { url = "https://files.pythonhosted.org/packages/61/96/6f617baa098ca0d2989bfec6d28b5719532cd8d8848782662f5b755f657f/mmh3-5.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9aeaf53eaa075dd63e81512522fd180097312fb2c9f476333309184285c49ce0", size = 40458, upload-time = "2026-03-05T15:54:01.548Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b4/9cd284bd6062d711e13d26c04d4778ab3f690c1c38a4563e3c767ec8802e/mmh3-5.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0634581290e6714c068f4aa24020acf7880927d1f0084fa753d9799ae9610082", size = 40079, upload-time = "2026-03-05T15:54:02.743Z" }, + { url = "https://files.pythonhosted.org/packages/f6/09/a806334ce1d3d50bf782b95fcee8b3648e1e170327d4bb7b4bad2ad7d956/mmh3-5.2.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e080c0637aea036f35507e803a4778f119a9b436617694ae1c5c366805f1e997", size = 97242, upload-time = "2026-03-05T15:54:04.536Z" }, + { url = "https://files.pythonhosted.org/packages/ee/93/723e317dd9e041c4dc4566a2eb53b01ad94de31750e0b834f1643905e97c/mmh3-5.2.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:db0562c5f71d18596dcd45e854cf2eeba27d7543e1a3acdafb7eef728f7fe85d", size = 103082, upload-time = "2026-03-05T15:54:06.387Z" }, + { url = "https://files.pythonhosted.org/packages/61/b5/f96121e69cc48696075071531cf574f112e1ffd08059f4bffb41210e6fc5/mmh3-5.2.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d9f9a3ce559a5267014b04b82956993270f63ec91765e13e9fd73daf2d2738e", size = 106054, upload-time = "2026-03-05T15:54:07.506Z" }, + { url = "https://files.pythonhosted.org/packages/82/49/192b987ec48d0b2aecf8ac285a9b11fbc00030f6b9c694664ae923458dde/mmh3-5.2.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:960b1b3efa39872ac8b6cc3a556edd6fb90ed74f08c9c45e028f1005b26aa55d", size = 112910, upload-time = "2026-03-05T15:54:09.403Z" }, + { url = "https://files.pythonhosted.org/packages/cf/a1/03e91fd334ed0144b83343a76eb11f17434cd08f746401488cfeafb2d241/mmh3-5.2.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d30b650595fdbe32366b94cb14f30bb2b625e512bd4e1df00611f99dc5c27fd4", size = 120551, upload-time = "2026-03-05T15:54:10.587Z" }, + { url = "https://files.pythonhosted.org/packages/93/b9/b89a71d2ff35c3a764d1c066c7313fc62c7cc48fa48a4b3b0304a4a0146f/mmh3-5.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:82f3802bfc4751f420d591c5c864de538b71cea117fce67e4595c2afede08a15", size = 99096, upload-time = "2026-03-05T15:54:11.76Z" }, + { url = "https://files.pythonhosted.org/packages/36/b5/613772c1c6ed5f7b63df55eb131e887cc43720fec392777b95a79d34e640/mmh3-5.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:915e7a2418f10bd1151b1953df06d896db9783c9cfdb9a8ee1f9b3a4331ab503", size = 98524, upload-time = "2026-03-05T15:54:13.122Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0e/1524566fe8eaf871e4f7bc44095929fcd2620488f402822d848df19d679c/mmh3-5.2.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:fc78739b5ec6e4fb02301984a3d442a91406e7700efbe305071e7fd1c78278f2", size = 106239, upload-time = "2026-03-05T15:54:14.601Z" }, + { url = "https://files.pythonhosted.org/packages/04/94/21adfa7d90a7a697137ad6de33eeff6445420ca55e433a5d4919c79bc3b5/mmh3-5.2.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:41aac7002a749f08727cb91babff1daf8deac317c0b1f317adc69be0e6c375d1", size = 109797, upload-time = "2026-03-05T15:54:15.819Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e6/1aacc3a219e1aa62fa65669995d4a3562b35be5200ec03680c7e4bec9676/mmh3-5.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9d8089d853c7963a8ce87fff93e2a67075c0bc08684a08ea6ad13577c38ffc38", size = 97228, upload-time = "2026-03-05T15:54:16.992Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b9/5e4cca8dcccf298add0a27f3c357bc8cf8baf821d35cdc6165e4bd5a48b0/mmh3-5.2.1-cp311-cp311-win32.whl", hash = "sha256:baeb47635cb33375dee4924cd93d7f5dcaa786c740b08423b0209b824a1ee728", size = 40751, upload-time = "2026-03-05T15:54:18.714Z" }, + { url = "https://files.pythonhosted.org/packages/72/fc/5b11d49247f499bcda591171e9cf3b6ee422b19e70aa2cef2e0ae65ca3b9/mmh3-5.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:1e4ecee40ba19e6975e1120829796770325841c2f153c0e9aecca927194c6a2a", size = 41517, upload-time = "2026-03-05T15:54:19.764Z" }, + { url = "https://files.pythonhosted.org/packages/8a/5f/2a511ee8a1c2a527c77726d5231685b72312c5a1a1b7639ad66a9652aa84/mmh3-5.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:c302245fd6c33d96bd169c7ccf2513c20f4c1e417c07ce9dce107c8bc3f8411f", size = 39287, upload-time = "2026-03-05T15:54:20.904Z" }, + { url = "https://files.pythonhosted.org/packages/92/94/bc5c3b573b40a328c4d141c20e399039ada95e5e2a661df3425c5165fd84/mmh3-5.2.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0cc21533878e5586b80d74c281d7f8da7932bc8ace50b8d5f6dbf7e3935f63f1", size = 56087, upload-time = "2026-03-05T15:54:21.92Z" }, + { url = "https://files.pythonhosted.org/packages/f6/80/64a02cc3e95c3af0aaa2590849d9ed24a9f14bb93537addde688e039b7c3/mmh3-5.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4eda76074cfca2787c8cf1bec603eaebdddd8b061ad5502f85cddae998d54f00", size = 40500, upload-time = "2026-03-05T15:54:22.953Z" }, + { url = "https://files.pythonhosted.org/packages/8b/72/e6d6602ce18adf4ddcd0e48f2e13590cc92a536199e52109f46f259d3c46/mmh3-5.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:eee884572b06bbe8a2b54f424dbd996139442cf83c76478e1ec162512e0dd2c7", size = 40034, upload-time = "2026-03-05T15:54:23.943Z" }, + { url = "https://files.pythonhosted.org/packages/59/c2/bf4537a8e58e21886ef16477041238cab5095c836496e19fafc34b7445d2/mmh3-5.2.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0d0b7e803191db5f714d264044e06189c8ccd3219e936cc184f07106bd17fd7b", size = 97292, upload-time = "2026-03-05T15:54:25.335Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e2/51ed62063b44d10b06d975ac87af287729eeb5e3ed9772f7584a17983e90/mmh3-5.2.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8e6c219e375f6341d0959af814296372d265a8ca1af63825f65e2e87c618f006", size = 103274, upload-time = "2026-03-05T15:54:26.44Z" }, + { url = "https://files.pythonhosted.org/packages/75/ce/12a7524dca59eec92e5b31fdb13ede1e98eda277cf2b786cf73bfbc24e81/mmh3-5.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:26fb5b9c3946bf7f1daed7b37e0c03898a6f062149127570f8ede346390a0825", size = 106158, upload-time = "2026-03-05T15:54:28.578Z" }, + { url = "https://files.pythonhosted.org/packages/86/1f/d3ba6dd322d01ab5d44c46c8f0c38ab6bbbf9b5e20e666dfc05bf4a23604/mmh3-5.2.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3c38d142c706201db5b2345166eeef1e7740e3e2422b470b8ba5c8727a9b4c7a", size = 113005, upload-time = "2026-03-05T15:54:29.767Z" }, + { url = "https://files.pythonhosted.org/packages/b6/a9/15d6b6f913294ea41b44d901741298e3718e1cb89ee626b3694625826a43/mmh3-5.2.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50885073e2909251d4718634a191c49ae5f527e5e1736d738e365c3e8be8f22b", size = 120744, upload-time = "2026-03-05T15:54:30.931Z" }, + { url = "https://files.pythonhosted.org/packages/76/b3/70b73923fd0284c439860ff5c871b20210dfdbe9a6b9dd0ee6496d77f174/mmh3-5.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b3f99e1756fc48ad507b95e5d86f2fb21b3d495012ff13e6592ebac14033f166", size = 99111, upload-time = "2026-03-05T15:54:32.353Z" }, + { url = "https://files.pythonhosted.org/packages/dd/38/99f7f75cd27d10d8b899a1caafb9d531f3903e4d54d572220e3d8ac35e89/mmh3-5.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62815d2c67f2dd1be76a253d88af4e1da19aeaa1820146dec52cf8bee2958b16", size = 98623, upload-time = "2026-03-05T15:54:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/fd/68/6e292c0853e204c44d2f03ea5f090be3317a0e2d9417ecb62c9eb27687df/mmh3-5.2.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8f767ba0911602ddef289404e33835a61168314ebd3c729833db2ed685824211", size = 106437, upload-time = "2026-03-05T15:54:35.177Z" }, + { url = "https://files.pythonhosted.org/packages/dd/c6/fedd7284c459cfb58721d461fcf5607a4c1f5d9ab195d113d51d10164d16/mmh3-5.2.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:67e41a497bac88cc1de96eeba56eeb933c39d54bc227352f8455aa87c4ca4000", size = 110002, upload-time = "2026-03-05T15:54:36.673Z" }, + { url = "https://files.pythonhosted.org/packages/3b/ac/ca8e0c19a34f5b71390171d2ff0b9f7f187550d66801a731bb68925126a4/mmh3-5.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3d74a03fb57757ece25aa4b3c1c60157a1cece37a020542785f942e2f827eed5", size = 97507, upload-time = "2026-03-05T15:54:37.804Z" }, + { url = "https://files.pythonhosted.org/packages/df/94/6ebb9094cfc7ac5e7950776b9d13a66bb4a34f83814f32ba2abc9494fc68/mmh3-5.2.1-cp312-cp312-win32.whl", hash = "sha256:7374d6e3ef72afe49697ecd683f3da12f4fc06af2d75433d0580c6746d2fa025", size = 40773, upload-time = "2026-03-05T15:54:40.077Z" }, + { url = "https://files.pythonhosted.org/packages/5b/3c/cd3527198cf159495966551c84a5f36805a10ac17b294f41f67b83f6a4d6/mmh3-5.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:3a9fed49c6ce4ed7e73f13182760c65c816da006debe67f37635580dfb0fae00", size = 41560, upload-time = "2026-03-05T15:54:41.148Z" }, + { url = "https://files.pythonhosted.org/packages/15/96/6fe5ebd0f970a076e3ed5512871ce7569447b962e96c125528a2f9724470/mmh3-5.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:bbfcb95d9a744e6e2827dfc66ad10e1020e0cac255eb7f85652832d5a264c2fc", size = 39313, upload-time = "2026-03-05T15:54:42.171Z" }, + { url = "https://files.pythonhosted.org/packages/25/a5/9daa0508a1569a54130f6198d5462a92deda870043624aa3ea72721aa765/mmh3-5.2.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:723b2681ed4cc07d3401bbea9c201ad4f2a4ca6ba8cddaff6789f715dd2b391e", size = 40832, upload-time = "2026-03-05T15:54:43.212Z" }, + { url = "https://files.pythonhosted.org/packages/0a/6b/3230c6d80c1f4b766dedf280a92c2241e99f87c1504ff74205ec8cebe451/mmh3-5.2.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:3619473a0e0d329fd4aec8075628f8f616be2da41605300696206d6f36920c3d", size = 41964, upload-time = "2026-03-05T15:54:44.204Z" }, + { url = "https://files.pythonhosted.org/packages/62/fb/648bfddb74a872004b6ee751551bfdda783fe6d70d2e9723bad84dbe5311/mmh3-5.2.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:e48d4dbe0f88e53081da605ae68644e5182752803bbc2beb228cca7f1c4454d6", size = 39114, upload-time = "2026-03-05T15:54:45.205Z" }, + { url = "https://files.pythonhosted.org/packages/95/c2/ab7901f87af438468b496728d11264cb397b3574d41506e71b92128e0373/mmh3-5.2.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a482ac121de6973897c92c2f31defc6bafb11c83825109275cffce54bb64933f", size = 39819, upload-time = "2026-03-05T15:54:46.509Z" }, + { url = "https://files.pythonhosted.org/packages/2f/ed/6f88dda0df67de1612f2e130ffea34cf84aaee5bff5b0aff4dbff2babe34/mmh3-5.2.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:17fbb47f0885ace8327ce1235d0416dc86a211dcd8cc1e703f41523be32cfec8", size = 40330, upload-time = "2026-03-05T15:54:47.864Z" }, + { url = "https://files.pythonhosted.org/packages/3d/66/7516d23f53cdf90f43fce24ab80c28f45e6851d78b46bef8c02084edf583/mmh3-5.2.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d51fde50a77f81330523562e3c2734ffdca9c4c9e9d355478117905e1cfe16c6", size = 56078, upload-time = "2026-03-05T15:54:48.9Z" }, + { url = "https://files.pythonhosted.org/packages/bc/34/4d152fdf4a91a132cb226b671f11c6b796eada9ab78080fb5ce1e95adaab/mmh3-5.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:19bbd3b841174ae6ed588536ab5e1b1fe83d046e668602c20266547298d939a9", size = 40498, upload-time = "2026-03-05T15:54:49.942Z" }, + { url = "https://files.pythonhosted.org/packages/d4/4c/8e3af1b6d85a299767ec97bd923f12b06267089c1472c27c1696870d1175/mmh3-5.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be77c402d5e882b6fbacfd90823f13da8e0a69658405a39a569c6b58fdb17b03", size = 40033, upload-time = "2026-03-05T15:54:50.994Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f2/966ea560e32578d453c9e9db53d602cbb1d0da27317e232afa7c38ceba11/mmh3-5.2.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fd96476f04db5ceba1cfa0f21228f67c1f7402296f0e73fee3513aa680ad237b", size = 97320, upload-time = "2026-03-05T15:54:52.072Z" }, + { url = "https://files.pythonhosted.org/packages/bb/0d/2c5f9893b38aeb6b034d1a44ecd55a010148054f6a516abe53b5e4057297/mmh3-5.2.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:707151644085dd0f20fe4f4b573d28e5130c4aaa5f587e95b60989c5926653b5", size = 103299, upload-time = "2026-03-05T15:54:53.569Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fc/2ebaef4a4d4376f89761274dc274035ffd96006ab496b4ee5af9b08f21a9/mmh3-5.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3737303ca9ea0f7cb83028781148fcda4f1dac7821db0c47672971dabcf63593", size = 106222, upload-time = "2026-03-05T15:54:55.092Z" }, + { url = "https://files.pythonhosted.org/packages/57/09/ea7ffe126d0ba0406622602a2d05e1e1a6841cc92fc322eb576c95b27fad/mmh3-5.2.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2778fed822d7db23ac5008b181441af0c869455b2e7d001f4019636ac31b6fe4", size = 113048, upload-time = "2026-03-05T15:54:56.305Z" }, + { url = "https://files.pythonhosted.org/packages/85/57/9447032edf93a64aa9bef4d9aa596400b1756f40411890f77a284f6293ca/mmh3-5.2.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d57dea657357230cc780e13920d7fa7db059d58fe721c80020f94476da4ca0a1", size = 120742, upload-time = "2026-03-05T15:54:57.453Z" }, + { url = "https://files.pythonhosted.org/packages/53/82/a86cc87cc88c92e9e1a598fee509f0409435b57879a6129bf3b3e40513c7/mmh3-5.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:169e0d178cb59314456ab30772429a802b25d13227088085b0d49b9fe1533104", size = 99132, upload-time = "2026-03-05T15:54:58.583Z" }, + { url = "https://files.pythonhosted.org/packages/54/f7/6b16eb1b40ee89bb740698735574536bc20d6cdafc65ae702ea235578e05/mmh3-5.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7e4e1f580033335c6f76d1e0d6b56baf009d1a64d6a4816347e4271ba951f46d", size = 98686, upload-time = "2026-03-05T15:55:00.078Z" }, + { url = "https://files.pythonhosted.org/packages/e8/88/a601e9f32ad1410f438a6d0544298ea621f989bd34a0731a7190f7dec799/mmh3-5.2.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:2bd9f19f7f1fcebd74e830f4af0f28adad4975d40d80620be19ffb2b2af56c9f", size = 106479, upload-time = "2026-03-05T15:55:01.532Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5c/ce29ae3dfc4feec4007a437a1b7435fb9507532a25147602cd5b52be86db/mmh3-5.2.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:c88653877aeb514c089d1b3d473451677b8b9a6d1497dbddf1ae7934518b06d2", size = 110030, upload-time = "2026-03-05T15:55:02.934Z" }, + { url = "https://files.pythonhosted.org/packages/13/30/ae444ef2ff87c805d525da4fa63d27cda4fe8a48e77003a036b8461cfd5c/mmh3-5.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fceef7fe67c81e1585198215e42ad3fdba3a25644beda8fbdaf85f4d7b93175a", size = 97536, upload-time = "2026-03-05T15:55:04.135Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f9/dc3787ee5c813cc27fe79f45ad4500d9b5437f23a7402435cc34e07c7718/mmh3-5.2.1-cp313-cp313-win32.whl", hash = "sha256:54b64fb2433bc71488e7a449603bf8bd31fbcf9cb56fbe1eb6d459e90b86c37b", size = 40769, upload-time = "2026-03-05T15:55:05.277Z" }, + { url = "https://files.pythonhosted.org/packages/43/67/850e0b5a1e97799822ebfc4ca0e8c6ece3ed8baf7dcdf64de817dfdda2ca/mmh3-5.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:cae6383181f1e345317742d2ddd88f9e7d2682fa4c9432e3a74e47d92dce0229", size = 41563, upload-time = "2026-03-05T15:55:06.283Z" }, + { url = "https://files.pythonhosted.org/packages/c0/cc/98c90b28e1da5458e19fbfaf4adb5289208d3bfccd45dd14eab216a2f0bb/mmh3-5.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:022aa1a528604e6c83d0a7705fdef0b5355d897a9e0fa3a8d26709ceaa06965d", size = 39310, upload-time = "2026-03-05T15:55:07.323Z" }, + { url = "https://files.pythonhosted.org/packages/63/b4/65bc1fb2bb7f83e91c30865023b1847cf89a5f237165575e8c83aa536584/mmh3-5.2.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:d771f085fcdf4035786adfb1d8db026df1eb4b41dac1c3d070d1e49512843227", size = 40794, upload-time = "2026-03-05T15:55:09.773Z" }, + { url = "https://files.pythonhosted.org/packages/c4/86/7168b3d83be8eb553897b1fac9da8bbb06568e5cfe555ffc329ebb46f59d/mmh3-5.2.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:7f196cd7910d71e9d9860da0ff7a77f64d22c1ad931f1dd18559a06e03109fc0", size = 41923, upload-time = "2026-03-05T15:55:10.924Z" }, + { url = "https://files.pythonhosted.org/packages/bf/9b/b653ab611c9060ce8ff0ba25c0226757755725e789292f3ca138a58082cd/mmh3-5.2.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b1f12bd684887a0a5d55e6363ca87056f361e45451105012d329b86ec19dbe0b", size = 39131, upload-time = "2026-03-05T15:55:11.961Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b4/5a2e0d34ab4d33543f01121e832395ea510132ea8e52cdf63926d9d81754/mmh3-5.2.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d106493a60dcb4aef35a0fac85105e150a11cf8bc2b0d388f5a33272d756c966", size = 39825, upload-time = "2026-03-05T15:55:13.013Z" }, + { url = "https://files.pythonhosted.org/packages/bd/69/81699a8f39a3f8d368bec6443435c0c392df0d200ad915bf0d222b588e03/mmh3-5.2.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:44983e45310ee5b9f73397350251cdf6e63a466406a105f1d16cb5baa659270b", size = 40344, upload-time = "2026-03-05T15:55:14.026Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b3/71c8c775807606e8fd8acc5c69016e1caf3200d50b50b6dd4b40ce10b76c/mmh3-5.2.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:368625fb01666655985391dbad3860dc0ba7c0d6b9125819f3121ee7292b4ac8", size = 56291, upload-time = "2026-03-05T15:55:15.137Z" }, + { url = "https://files.pythonhosted.org/packages/6f/75/2c24517d4b2ce9e4917362d24f274d3d541346af764430249ddcc4cb3a08/mmh3-5.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:72d1cc63bcc91e14933f77d51b3df899d6a07d184ec515ea7f56bff659e124d7", size = 40575, upload-time = "2026-03-05T15:55:16.518Z" }, + { url = "https://files.pythonhosted.org/packages/bf/b9/e4a360164365ac9f07a25f0f7928e3a66eb9ecc989384060747aa170e6aa/mmh3-5.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e8b4b5580280b9265af3e0409974fb79c64cf7523632d03fbf11df18f8b0181e", size = 40052, upload-time = "2026-03-05T15:55:17.735Z" }, + { url = "https://files.pythonhosted.org/packages/97/ca/120d92223a7546131bbbc31c9174168ee7a73b1366f5463ffe69d9e691fe/mmh3-5.2.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4cbbde66f1183db040daede83dd86c06d663c5bb2af6de1142b7c8c37923dd74", size = 97311, upload-time = "2026-03-05T15:55:18.959Z" }, + { url = "https://files.pythonhosted.org/packages/b6/71/c1a60c1652b8813ef9de6d289784847355417ee0f2980bca002fe87f4ae5/mmh3-5.2.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8ff038d52ef6aa0f309feeba00c5095c9118d0abf787e8e8454d6048db2037fc", size = 103279, upload-time = "2026-03-05T15:55:20.448Z" }, + { url = "https://files.pythonhosted.org/packages/48/29/ad97f4be1509cdcb28ae32c15593ce7c415db47ace37f8fad35b493faa9a/mmh3-5.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a4130d0b9ce5fad6af07421b1aecc7e079519f70d6c05729ab871794eded8617", size = 106290, upload-time = "2026-03-05T15:55:21.6Z" }, + { url = "https://files.pythonhosted.org/packages/77/29/1f86d22e281bd8827ba373600a4a8b0c0eae5ca6aa55b9a8c26d2a34decc/mmh3-5.2.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6e0bfe77d238308839699944164b96a2eeccaf55f2af400f54dc20669d8d5f2", size = 113116, upload-time = "2026-03-05T15:55:22.826Z" }, + { url = "https://files.pythonhosted.org/packages/a7/7c/339971ea7ed4c12d98f421f13db3ea576a9114082ccb59d2d1a0f00ccac1/mmh3-5.2.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f963eafc0a77a6c0562397da004f5876a9bcf7265a7bcc3205e29636bc4a1312", size = 120740, upload-time = "2026-03-05T15:55:24.3Z" }, + { url = "https://files.pythonhosted.org/packages/e4/92/3c7c4bdb8e926bb3c972d1e2907d77960c1c4b250b41e8366cf20c6e4373/mmh3-5.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:92883836caf50d5255be03d988d75bc93e3f86ba247b7ca137347c323f731deb", size = 99143, upload-time = "2026-03-05T15:55:25.456Z" }, + { url = "https://files.pythonhosted.org/packages/df/0a/33dd8706e732458c8375eae63c981292de07a406bad4ec03e5269654aa2c/mmh3-5.2.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:57b52603e89355ff318025dd55158f6e71396c0f1f609d548e9ea9c94cc6ce0a", size = 98703, upload-time = "2026-03-05T15:55:26.723Z" }, + { url = "https://files.pythonhosted.org/packages/51/04/76bbce05df76cbc3d396f13b2ea5b1578ef02b6a5187e132c6c33f99d596/mmh3-5.2.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f40a95186a72fa0b67d15fef0f157bfcda00b4f59c8a07cbe5530d41ac35d105", size = 106484, upload-time = "2026-03-05T15:55:28.214Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8f/c6e204a2c70b719c1f62ffd9da27aef2dddcba875ea9c31ca0e87b975a46/mmh3-5.2.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:58370d05d033ee97224c81263af123dea3d931025030fd34b61227a768a8858a", size = 110012, upload-time = "2026-03-05T15:55:29.532Z" }, + { url = "https://files.pythonhosted.org/packages/e3/37/7181efd8e39db386c1ebc3e6b7d1f702a09d7c1197a6f2742ed6b5c16597/mmh3-5.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7be6dfb49e48fd0a7d91ff758a2b51336f1cd21f9d44b20f6801f072bd080cdd", size = 97508, upload-time = "2026-03-05T15:55:31.01Z" }, + { url = "https://files.pythonhosted.org/packages/42/0f/afa7ca2615fd85e1469474bb860e381443d0b868c083b62b41cb1d7ca32f/mmh3-5.2.1-cp314-cp314-win32.whl", hash = "sha256:54fe8518abe06a4c3852754bfd498b30cc58e667f376c513eac89a244ce781a4", size = 41387, upload-time = "2026-03-05T15:55:32.403Z" }, + { url = "https://files.pythonhosted.org/packages/71/0d/46d42a260ee1357db3d486e6c7a692e303c017968e14865e00efa10d09fc/mmh3-5.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:3f796b535008708846044c43302719c6956f39ca2d93f2edda5319e79a29efbb", size = 42101, upload-time = "2026-03-05T15:55:33.646Z" }, + { url = "https://files.pythonhosted.org/packages/a4/7b/848a8378059d96501a41159fca90d6a99e89736b0afbe8e8edffeac8c74b/mmh3-5.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:cd471ede0d802dd936b6fab28188302b2d497f68436025857ca72cd3810423fe", size = 39836, upload-time = "2026-03-05T15:55:35.026Z" }, + { url = "https://files.pythonhosted.org/packages/27/61/1dabea76c011ba8547c25d30c91c0ec22544487a8750997a27a0c9e1180b/mmh3-5.2.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:5174a697ce042fa77c407e05efe41e03aa56dae9ec67388055820fb48cf4c3ba", size = 57727, upload-time = "2026-03-05T15:55:36.162Z" }, + { url = "https://files.pythonhosted.org/packages/b7/32/731185950d1cf2d5e28979cc8593016ba1619a295faba10dda664a4931b5/mmh3-5.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0a3984146e414684a6be2862d84fcb1035f4984851cb81b26d933bab6119bf00", size = 41308, upload-time = "2026-03-05T15:55:37.254Z" }, + { url = "https://files.pythonhosted.org/packages/76/aa/66c76801c24b8c9418b4edde9b5e57c75e72c94e29c48f707e3962534f18/mmh3-5.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bd6e7d363aa93bd3421b30b6af97064daf47bc96005bddba67c5ffbc6df426b8", size = 40758, upload-time = "2026-03-05T15:55:38.61Z" }, + { url = "https://files.pythonhosted.org/packages/9e/bb/79a1f638a02f0ae389f706d13891e2fbf7d8c0a22ecde67ba828951bb60a/mmh3-5.2.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:113f78e7463a36dbbcea05bfe688efd7fa759d0f0c56e73c974d60dcfec3dfcc", size = 109670, upload-time = "2026-03-05T15:55:40.13Z" }, + { url = "https://files.pythonhosted.org/packages/26/94/8cd0e187a288985bcfc79bf5144d1d712df9dee74365f59d26e3a1865be6/mmh3-5.2.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7e8ec5f606e0809426d2440e0683509fb605a8820a21ebd120dcdba61b74ef7f", size = 117399, upload-time = "2026-03-05T15:55:42.076Z" }, + { url = "https://files.pythonhosted.org/packages/42/94/dfea6059bd5c5beda565f58a4096e43f4858fb6d2862806b8bbd12cbb284/mmh3-5.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22b0f9971ec4e07e8223f2beebe96a6cfc779d940b6f27d26604040dd74d3a44", size = 120386, upload-time = "2026-03-05T15:55:43.481Z" }, + { url = "https://files.pythonhosted.org/packages/47/cb/f9c45e62aaa67220179f487772461d891bb582bb2f9783c944832c60efd9/mmh3-5.2.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:85ffc9920ffc39c5eee1e3ac9100c913a0973996fbad5111f939bbda49204bb7", size = 125924, upload-time = "2026-03-05T15:55:44.638Z" }, + { url = "https://files.pythonhosted.org/packages/a5/83/fe54a4a7c11bc9f623dfc1707decd034245602b076dfc1dcc771a4163170/mmh3-5.2.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7aec798c2b01aaa65a55f1124f3405804184373abb318a3091325aece235f67c", size = 135280, upload-time = "2026-03-05T15:55:45.866Z" }, + { url = "https://files.pythonhosted.org/packages/97/67/fe7e9e9c143daddd210cd22aef89cbc425d58ecf238d2b7d9eb0da974105/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:55dbbd8ffbc40d1697d5e2d0375b08599dae8746b0b08dea05eee4ce81648fac", size = 110050, upload-time = "2026-03-05T15:55:47.074Z" }, + { url = "https://files.pythonhosted.org/packages/43/c4/6d4b09fcbef80794de447c9378e39eefc047156b290fa3dd2d5257ca8227/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:6c85c38a279ca9295a69b9b088a2e48aa49737bb1b34e6a9dc6297c110e8d912", size = 111158, upload-time = "2026-03-05T15:55:48.239Z" }, + { url = "https://files.pythonhosted.org/packages/81/a6/ca51c864bdb30524beb055a6d8826db3906af0834ec8c41d097a6e8573d5/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:6290289fa5fb4c70fd7f72016e03633d60388185483ff3b162912c81205ae2cf", size = 116890, upload-time = "2026-03-05T15:55:49.405Z" }, + { url = "https://files.pythonhosted.org/packages/cc/04/5a1fe2e2ad843d03e89af25238cbc4f6840a8bb6c4329a98ab694c71deda/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:4fc6cd65dc4d2fdb2625e288939a3566e36127a84811a4913f02f3d5931da52d", size = 123121, upload-time = "2026-03-05T15:55:50.61Z" }, + { url = "https://files.pythonhosted.org/packages/af/4d/3c820c6f4897afd25905270a9f2330a23f77a207ea7356f7aadace7273c0/mmh3-5.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:623f938f6a039536cc02b7582a07a080f13fdfd48f87e63201d92d7e34d09a18", size = 110187, upload-time = "2026-03-05T15:55:52.143Z" }, + { url = "https://files.pythonhosted.org/packages/21/54/1d71cd143752361c0aebef16ad3f55926a6faf7b112d355745c1f8a25f7f/mmh3-5.2.1-cp314-cp314t-win32.whl", hash = "sha256:29bc3973676ae334412efdd367fcd11d036b7be3efc1ce2407ef8676dabfeb82", size = 41934, upload-time = "2026-03-05T15:55:53.564Z" }, + { url = "https://files.pythonhosted.org/packages/9d/e4/63a2a88f31d93dea03947cccc2a076946857e799ea4f7acdecbf43b324aa/mmh3-5.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:28cfab66577000b9505a0d068c731aee7ca85cd26d4d63881fab17857e0fe1fb", size = 43036, upload-time = "2026-03-05T15:55:55.252Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0f/59204bf136d1201f8d7884cfbaf7498c5b4674e87a4c693f9bde63741ce1/mmh3-5.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:dfd51b4c56b673dfbc43d7d27ef857dd91124801e2806c69bb45585ce0fa019b", size = 40391, upload-time = "2026-03-05T15:55:56.697Z" }, +] + +[[package]] +name = "multidict" +version = "6.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355, upload-time = "2026-01-26T02:43:31.165Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433, upload-time = "2026-01-26T02:43:32.581Z" }, + { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376, upload-time = "2026-01-26T02:43:34.417Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365, upload-time = "2026-01-26T02:43:35.741Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747, upload-time = "2026-01-26T02:43:36.976Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293, upload-time = "2026-01-26T02:43:38.258Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962, upload-time = "2026-01-26T02:43:40.034Z" }, + { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360, upload-time = "2026-01-26T02:43:41.752Z" }, + { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940, upload-time = "2026-01-26T02:43:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502, upload-time = "2026-01-26T02:43:44.371Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065, upload-time = "2026-01-26T02:43:45.745Z" }, + { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870, upload-time = "2026-01-26T02:43:47.054Z" }, + { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302, upload-time = "2026-01-26T02:43:48.753Z" }, + { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981, upload-time = "2026-01-26T02:43:49.921Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159, upload-time = "2026-01-26T02:43:51.635Z" }, + { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, + { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, + { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, + { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, + { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, + { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, + { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, + { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, + { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, + { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, + { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, + { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, + { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, + { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, + { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, + { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, + { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, + { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, + { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, + { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, + { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/c6/4218570d8c8ecc9704b5157a3348e486e84ef4be0ed3e38218ab473c83d2/numpy-2.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f983334aea213c99992053ede6168500e5f086ce74fbc4acc3f2b00f5762e9db", size = 16976799, upload-time = "2026-03-29T13:18:15.438Z" }, + { url = "https://files.pythonhosted.org/packages/dd/92/b4d922c4a5f5dab9ed44e6153908a5c665b71acf183a83b93b690996e39b/numpy-2.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:72944b19f2324114e9dc86a159787333b77874143efcf89a5167ef83cfee8af0", size = 14971552, upload-time = "2026-03-29T13:18:18.606Z" }, + { url = "https://files.pythonhosted.org/packages/8a/dc/df98c095978fa6ee7b9a9387d1d58cbb3d232d0e69ad169a4ce784bde4fd/numpy-2.4.4-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:86b6f55f5a352b48d7fbfd2dbc3d5b780b2d79f4d3c121f33eb6efb22e9a2015", size = 5476566, upload-time = "2026-03-29T13:18:21.532Z" }, + { url = "https://files.pythonhosted.org/packages/28/34/b3fdcec6e725409223dd27356bdf5a3c2cc2282e428218ecc9cb7acc9763/numpy-2.4.4-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:ba1f4fc670ed79f876f70082eff4f9583c15fb9a4b89d6188412de4d18ae2f40", size = 6806482, upload-time = "2026-03-29T13:18:23.634Z" }, + { url = "https://files.pythonhosted.org/packages/68/62/63417c13aa35d57bee1337c67446761dc25ea6543130cf868eace6e8157b/numpy-2.4.4-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a87ec22c87be071b6bdbd27920b129b94f2fc964358ce38f3822635a3e2e03d", size = 15973376, upload-time = "2026-03-29T13:18:26.677Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c5/9fcb7e0e69cef59cf10c746b84f7d58b08bc66a6b7d459783c5a4f6101a6/numpy-2.4.4-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df3775294accfdd75f32c74ae39fcba920c9a378a2fc18a12b6820aa8c1fb502", size = 16925137, upload-time = "2026-03-29T13:18:30.14Z" }, + { url = "https://files.pythonhosted.org/packages/7e/43/80020edacb3f84b9efdd1591120a4296462c23fd8db0dde1666f6ef66f13/numpy-2.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0d4e437e295f18ec29bc79daf55e8a47a9113df44d66f702f02a293d93a2d6dd", size = 17329414, upload-time = "2026-03-29T13:18:33.733Z" }, + { url = "https://files.pythonhosted.org/packages/fd/06/af0658593b18a5f73532d377188b964f239eb0894e664a6c12f484472f97/numpy-2.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6aa3236c78803afbcb255045fbef97a9e25a1f6c9888357d205ddc42f4d6eba5", size = 18658397, upload-time = "2026-03-29T13:18:37.511Z" }, + { url = "https://files.pythonhosted.org/packages/e6/ce/13a09ed65f5d0ce5c7dd0669250374c6e379910f97af2c08c57b0608eee4/numpy-2.4.4-cp311-cp311-win32.whl", hash = "sha256:30caa73029a225b2d40d9fae193e008e24b2026b7ee1a867b7ee8d96ca1a448e", size = 6239499, upload-time = "2026-03-29T13:18:40.372Z" }, + { url = "https://files.pythonhosted.org/packages/bd/63/05d193dbb4b5eec1eca73822d80da98b511f8328ad4ae3ca4caf0f4db91d/numpy-2.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:6bbe4eb67390b0a0265a2c25458f6b90a409d5d069f1041e6aff1e27e3d9a79e", size = 12614257, upload-time = "2026-03-29T13:18:42.95Z" }, + { url = "https://files.pythonhosted.org/packages/87/c5/8168052f080c26fa984c413305012be54741c9d0d74abd7fbeeccae3889f/numpy-2.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:fcfe2045fd2e8f3cb0ce9d4ba6dba6333b8fa05bb8a4939c908cd43322d14c7e", size = 10486775, upload-time = "2026-03-29T13:18:45.835Z" }, + { url = "https://files.pythonhosted.org/packages/28/05/32396bec30fb2263770ee910142f49c1476d08e8ad41abf8403806b520ce/numpy-2.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b", size = 16689272, upload-time = "2026-03-29T13:18:49.223Z" }, + { url = "https://files.pythonhosted.org/packages/c5/f3/a983d28637bfcd763a9c7aafdb6d5c0ebf3d487d1e1459ffdb57e2f01117/numpy-2.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e", size = 14699573, upload-time = "2026-03-29T13:18:52.629Z" }, + { url = "https://files.pythonhosted.org/packages/9b/fd/e5ecca1e78c05106d98028114f5c00d3eddb41207686b2b7de3e477b0e22/numpy-2.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842", size = 5204782, upload-time = "2026-03-29T13:18:55.579Z" }, + { url = "https://files.pythonhosted.org/packages/de/2f/702a4594413c1a8632092beae8aba00f1d67947389369b3777aed783fdca/numpy-2.4.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8", size = 6552038, upload-time = "2026-03-29T13:18:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/7f/37/eed308a8f56cba4d1fdf467a4fc67ef4ff4bf1c888f5fc980481890104b1/numpy-2.4.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121", size = 15670666, upload-time = "2026-03-29T13:19:00.341Z" }, + { url = "https://files.pythonhosted.org/packages/0a/0d/0e3ecece05b7a7e87ab9fb587855548da437a061326fff64a223b6dcb78a/numpy-2.4.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e", size = 16645480, upload-time = "2026-03-29T13:19:03.63Z" }, + { url = "https://files.pythonhosted.org/packages/34/49/f2312c154b82a286758ee2f1743336d50651f8b5195db18cdb63675ff649/numpy-2.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44", size = 17020036, upload-time = "2026-03-29T13:19:07.428Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e9/736d17bd77f1b0ec4f9901aaec129c00d59f5d84d5e79bba540ef12c2330/numpy-2.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d", size = 18368643, upload-time = "2026-03-29T13:19:10.775Z" }, + { url = "https://files.pythonhosted.org/packages/63/f6/d417977c5f519b17c8a5c3bc9e8304b0908b0e21136fe43bf628a1343914/numpy-2.4.4-cp312-cp312-win32.whl", hash = "sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827", size = 5961117, upload-time = "2026-03-29T13:19:13.464Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5b/e1deebf88ff431b01b7406ca3583ab2bbb90972bbe1c568732e49c844f7e/numpy-2.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a", size = 12320584, upload-time = "2026-03-29T13:19:16.155Z" }, + { url = "https://files.pythonhosted.org/packages/58/89/e4e856ac82a68c3ed64486a544977d0e7bdd18b8da75b78a577ca31c4395/numpy-2.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec", size = 10221450, upload-time = "2026-03-29T13:19:18.994Z" }, + { url = "https://files.pythonhosted.org/packages/14/1d/d0a583ce4fefcc3308806a749a536c201ed6b5ad6e1322e227ee4848979d/numpy-2.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:08f2e31ed5e6f04b118e49821397f12767934cfdd12a1ce86a058f91e004ee50", size = 16684933, upload-time = "2026-03-29T13:19:22.47Z" }, + { url = "https://files.pythonhosted.org/packages/c1/62/2b7a48fbb745d344742c0277f01286dead15f3f68e4f359fbfcf7b48f70f/numpy-2.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e823b8b6edc81e747526f70f71a9c0a07ac4e7ad13020aa736bb7c9d67196115", size = 14694532, upload-time = "2026-03-29T13:19:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/e5/87/499737bfba066b4a3bebff24a8f1c5b2dee410b209bc6668c9be692580f0/numpy-2.4.4-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:4a19d9dba1a76618dd86b164d608566f393f8ec6ac7c44f0cc879011c45e65af", size = 5199661, upload-time = "2026-03-29T13:19:28.31Z" }, + { url = "https://files.pythonhosted.org/packages/cd/da/464d551604320d1491bc345efed99b4b7034143a85787aab78d5691d5a0e/numpy-2.4.4-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d2a8490669bfe99a233298348acc2d824d496dee0e66e31b66a6022c2ad74a5c", size = 6547539, upload-time = "2026-03-29T13:19:30.97Z" }, + { url = "https://files.pythonhosted.org/packages/7d/90/8d23e3b0dafd024bf31bdec225b3bb5c2dbfa6912f8a53b8659f21216cbf/numpy-2.4.4-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45dbed2ab436a9e826e302fcdcbe9133f9b0006e5af7168afb8963a6520da103", size = 15668806, upload-time = "2026-03-29T13:19:33.887Z" }, + { url = "https://files.pythonhosted.org/packages/d1/73/a9d864e42a01896bb5974475438f16086be9ba1f0d19d0bb7a07427c4a8b/numpy-2.4.4-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c901b15172510173f5cb310eae652908340f8dede90fff9e3bf6c0d8dfd92f83", size = 16632682, upload-time = "2026-03-29T13:19:37.336Z" }, + { url = "https://files.pythonhosted.org/packages/34/fb/14570d65c3bde4e202a031210475ae9cde9b7686a2e7dc97ee67d2833b35/numpy-2.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:99d838547ace2c4aace6c4f76e879ddfe02bb58a80c1549928477862b7a6d6ed", size = 17019810, upload-time = "2026-03-29T13:19:40.963Z" }, + { url = "https://files.pythonhosted.org/packages/8a/77/2ba9d87081fd41f6d640c83f26fb7351e536b7ce6dd9061b6af5904e8e46/numpy-2.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0aec54fd785890ecca25a6003fd9a5aed47ad607bbac5cd64f836ad8666f4959", size = 18357394, upload-time = "2026-03-29T13:19:44.859Z" }, + { url = "https://files.pythonhosted.org/packages/a2/23/52666c9a41708b0853fa3b1a12c90da38c507a3074883823126d4e9d5b30/numpy-2.4.4-cp313-cp313-win32.whl", hash = "sha256:07077278157d02f65c43b1b26a3886bce886f95d20aabd11f87932750dfb14ed", size = 5959556, upload-time = "2026-03-29T13:19:47.661Z" }, + { url = "https://files.pythonhosted.org/packages/57/fb/48649b4971cde70d817cf97a2a2fdc0b4d8308569f1dd2f2611959d2e0cf/numpy-2.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:5c70f1cc1c4efbe316a572e2d8b9b9cc44e89b95f79ca3331553fbb63716e2bf", size = 12317311, upload-time = "2026-03-29T13:19:50.67Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d8/11490cddd564eb4de97b4579ef6bfe6a736cc07e94c1598590ae25415e01/numpy-2.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:ef4059d6e5152fa1a39f888e344c73fdc926e1b2dd58c771d67b0acfbf2aa67d", size = 10222060, upload-time = "2026-03-29T13:19:54.229Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/dab4339177a905aad3e2221c915b35202f1ec30d750dd2e5e9d9a72b804b/numpy-2.4.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4bbc7f303d125971f60ec0aaad5e12c62d0d2c925f0ab1273debd0e4ba37aba5", size = 14822302, upload-time = "2026-03-29T13:19:57.585Z" }, + { url = "https://files.pythonhosted.org/packages/eb/e4/0564a65e7d3d97562ed6f9b0fd0fb0a6f559ee444092f105938b50043876/numpy-2.4.4-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:4d6d57903571f86180eb98f8f0c839fa9ebbfb031356d87f1361be91e433f5b7", size = 5327407, upload-time = "2026-03-29T13:20:00.601Z" }, + { url = "https://files.pythonhosted.org/packages/29/8d/35a3a6ce5ad371afa58b4700f1c820f8f279948cca32524e0a695b0ded83/numpy-2.4.4-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:4636de7fd195197b7535f231b5de9e4b36d2c440b6e566d2e4e4746e6af0ca93", size = 6647631, upload-time = "2026-03-29T13:20:02.855Z" }, + { url = "https://files.pythonhosted.org/packages/f4/da/477731acbd5a58a946c736edfdabb2ac5b34c3d08d1ba1a7b437fa0884df/numpy-2.4.4-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad2e2ef14e0b04e544ea2fa0a36463f847f113d314aa02e5b402fdf910ef309e", size = 15727691, upload-time = "2026-03-29T13:20:06.004Z" }, + { url = "https://files.pythonhosted.org/packages/e6/db/338535d9b152beabeb511579598418ba0212ce77cf9718edd70262cc4370/numpy-2.4.4-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a285b3b96f951841799528cd1f4f01cd70e7e0204b4abebac9463eecfcf2a40", size = 16681241, upload-time = "2026-03-29T13:20:09.417Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a9/ad248e8f58beb7a0219b413c9c7d8151c5d285f7f946c3e26695bdbbe2df/numpy-2.4.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f8474c4241bc18b750be2abea9d7a9ec84f46ef861dbacf86a4f6e043401f79e", size = 17085767, upload-time = "2026-03-29T13:20:13.126Z" }, + { url = "https://files.pythonhosted.org/packages/b5/1a/3b88ccd3694681356f70da841630e4725a7264d6a885c8d442a697e1146b/numpy-2.4.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4e874c976154687c1f71715b034739b45c7711bec81db01914770373d125e392", size = 18403169, upload-time = "2026-03-29T13:20:17.096Z" }, + { url = "https://files.pythonhosted.org/packages/c2/c9/fcfd5d0639222c6eac7f304829b04892ef51c96a75d479214d77e3ce6e33/numpy-2.4.4-cp313-cp313t-win32.whl", hash = "sha256:9c585a1790d5436a5374bac930dad6ed244c046ed91b2b2a3634eb2971d21008", size = 6083477, upload-time = "2026-03-29T13:20:20.195Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e3/3938a61d1c538aaec8ed6fd6323f57b0c2d2d2219512434c5c878db76553/numpy-2.4.4-cp313-cp313t-win_amd64.whl", hash = "sha256:93e15038125dc1e5345d9b5b68aa7f996ec33b98118d18c6ca0d0b7d6198b7e8", size = 12457487, upload-time = "2026-03-29T13:20:22.946Z" }, + { url = "https://files.pythonhosted.org/packages/97/6a/7e345032cc60501721ef94e0e30b60f6b0bd601f9174ebd36389a2b86d40/numpy-2.4.4-cp313-cp313t-win_arm64.whl", hash = "sha256:0dfd3f9d3adbe2920b68b5cd3d51444e13a10792ec7154cd0a2f6e74d4ab3233", size = 10292002, upload-time = "2026-03-29T13:20:25.909Z" }, + { url = "https://files.pythonhosted.org/packages/6e/06/c54062f85f673dd5c04cbe2f14c3acb8c8b95e3384869bb8cc9bff8cb9df/numpy-2.4.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f169b9a863d34f5d11b8698ead99febeaa17a13ca044961aa8e2662a6c7766a0", size = 16684353, upload-time = "2026-03-29T13:20:29.504Z" }, + { url = "https://files.pythonhosted.org/packages/4c/39/8a320264a84404c74cc7e79715de85d6130fa07a0898f67fb5cd5bd79908/numpy-2.4.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2483e4584a1cb3092da4470b38866634bafb223cbcd551ee047633fd2584599a", size = 14704914, upload-time = "2026-03-29T13:20:33.547Z" }, + { url = "https://files.pythonhosted.org/packages/91/fb/287076b2614e1d1044235f50f03748f31fa287e3dbe6abeb35cdfa351eca/numpy-2.4.4-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:2d19e6e2095506d1736b7d80595e0f252d76b89f5e715c35e06e937679ea7d7a", size = 5210005, upload-time = "2026-03-29T13:20:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/63/eb/fcc338595309910de6ecabfcef2419a9ce24399680bfb149421fa2df1280/numpy-2.4.4-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:6a246d5914aa1c820c9443ddcee9c02bec3e203b0c080349533fae17727dfd1b", size = 6544974, upload-time = "2026-03-29T13:20:39.014Z" }, + { url = "https://files.pythonhosted.org/packages/44/5d/e7e9044032a716cdfaa3fba27a8e874bf1c5f1912a1ddd4ed071bf8a14a6/numpy-2.4.4-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989824e9faf85f96ec9c7761cd8d29c531ad857bfa1daa930cba85baaecf1a9a", size = 15684591, upload-time = "2026-03-29T13:20:42.146Z" }, + { url = "https://files.pythonhosted.org/packages/98/7c/21252050676612625449b4807d6b695b9ce8a7c9e1c197ee6216c8a65c7c/numpy-2.4.4-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:27a8d92cd10f1382a67d7cf4db7ce18341b66438bdd9f691d7b0e48d104c2a9d", size = 16637700, upload-time = "2026-03-29T13:20:46.204Z" }, + { url = "https://files.pythonhosted.org/packages/b1/29/56d2bbef9465db24ef25393383d761a1af4f446a1df9b8cded4fe3a5a5d7/numpy-2.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e44319a2953c738205bf3354537979eaa3998ed673395b964c1176083dd46252", size = 17035781, upload-time = "2026-03-29T13:20:50.242Z" }, + { url = "https://files.pythonhosted.org/packages/e3/2b/a35a6d7589d21f44cea7d0a98de5ddcbb3d421b2622a5c96b1edf18707c3/numpy-2.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e892aff75639bbef0d2a2cfd55535510df26ff92f63c92cd84ef8d4ba5a5557f", size = 18362959, upload-time = "2026-03-29T13:20:54.019Z" }, + { url = "https://files.pythonhosted.org/packages/64/c9/d52ec581f2390e0f5f85cbfd80fb83d965fc15e9f0e1aec2195faa142cde/numpy-2.4.4-cp314-cp314-win32.whl", hash = "sha256:1378871da56ca8943c2ba674530924bb8ca40cd228358a3b5f302ad60cf875fc", size = 6008768, upload-time = "2026-03-29T13:20:56.912Z" }, + { url = "https://files.pythonhosted.org/packages/fa/22/4cc31a62a6c7b74a8730e31a4274c5dc80e005751e277a2ce38e675e4923/numpy-2.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:715d1c092715954784bc79e1174fc2a90093dc4dc84ea15eb14dad8abdcdeb74", size = 12449181, upload-time = "2026-03-29T13:20:59.548Z" }, + { url = "https://files.pythonhosted.org/packages/70/2e/14cda6f4d8e396c612d1bf97f22958e92148801d7e4f110cabebdc0eef4b/numpy-2.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:2c194dd721e54ecad9ad387c1d35e63dce5c4450c6dc7dd5611283dda239aabb", size = 10496035, upload-time = "2026-03-29T13:21:02.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e8/8fed8c8d848d7ecea092dc3469643f9d10bc3a134a815a3b033da1d2039b/numpy-2.4.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2aa0613a5177c264ff5921051a5719d20095ea586ca88cc802c5c218d1c67d3e", size = 14824958, upload-time = "2026-03-29T13:21:05.671Z" }, + { url = "https://files.pythonhosted.org/packages/05/1a/d8007a5138c179c2bf33ef44503e83d70434d2642877ee8fbb230e7c0548/numpy-2.4.4-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:42c16925aa5a02362f986765f9ebabf20de75cdefdca827d14315c568dcab113", size = 5330020, upload-time = "2026-03-29T13:21:08.635Z" }, + { url = "https://files.pythonhosted.org/packages/99/64/ffb99ac6ae93faf117bcbd5c7ba48a7f45364a33e8e458545d3633615dda/numpy-2.4.4-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:874f200b2a981c647340f841730fc3a2b54c9d940566a3c4149099591e2c4c3d", size = 6650758, upload-time = "2026-03-29T13:21:10.949Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6e/795cc078b78a384052e73b2f6281ff7a700e9bf53bcce2ee579d4f6dd879/numpy-2.4.4-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9b39d38a9bd2ae1becd7eac1303d031c5c110ad31f2b319c6e7d98b135c934d", size = 15729948, upload-time = "2026-03-29T13:21:14.047Z" }, + { url = "https://files.pythonhosted.org/packages/5f/86/2acbda8cc2af5f3d7bfc791192863b9e3e19674da7b5e533fded124d1299/numpy-2.4.4-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b268594bccac7d7cf5844c7732e3f20c50921d94e36d7ec9b79e9857694b1b2f", size = 16679325, upload-time = "2026-03-29T13:21:17.561Z" }, + { url = "https://files.pythonhosted.org/packages/bc/59/cafd83018f4aa55e0ac6fa92aa066c0a1877b77a615ceff1711c260ffae8/numpy-2.4.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ac6b31e35612a26483e20750126d30d0941f949426974cace8e6b5c58a3657b0", size = 17084883, upload-time = "2026-03-29T13:21:21.106Z" }, + { url = "https://files.pythonhosted.org/packages/f0/85/a42548db84e65ece46ab2caea3d3f78b416a47af387fcbb47ec28e660dc2/numpy-2.4.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8e3ed142f2728df44263aaf5fb1f5b0b99f4070c553a0d7f033be65338329150", size = 18403474, upload-time = "2026-03-29T13:21:24.828Z" }, + { url = "https://files.pythonhosted.org/packages/ed/ad/483d9e262f4b831000062e5d8a45e342166ec8aaa1195264982bca267e62/numpy-2.4.4-cp314-cp314t-win32.whl", hash = "sha256:dddbbd259598d7240b18c9d87c56a9d2fb3b02fe266f49a7c101532e78c1d871", size = 6155500, upload-time = "2026-03-29T13:21:28.205Z" }, + { url = "https://files.pythonhosted.org/packages/c7/03/2fc4e14c7bd4ff2964b74ba90ecb8552540b6315f201df70f137faa5c589/numpy-2.4.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a7164afb23be6e37ad90b2f10426149fd75aee07ca55653d2aa41e66c4ef697e", size = 12637755, upload-time = "2026-03-29T13:21:31.107Z" }, + { url = "https://files.pythonhosted.org/packages/58/78/548fb8e07b1a341746bfbecb32f2c268470f45fa028aacdbd10d9bc73aab/numpy-2.4.4-cp314-cp314t-win_arm64.whl", hash = "sha256:ba203255017337d39f89bdd58417f03c4426f12beed0440cfd933cb15f8669c7", size = 10566643, upload-time = "2026-03-29T13:21:34.339Z" }, + { url = "https://files.pythonhosted.org/packages/6b/33/8fae8f964a4f63ed528264ddf25d2b683d0b663e3cba26961eb838a7c1bd/numpy-2.4.4-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:58c8b5929fcb8287cbd6f0a3fae19c6e03a5c48402ae792962ac465224a629a4", size = 16854491, upload-time = "2026-03-29T13:21:38.03Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d0/1aabee441380b981cf8cdda3ae7a46aa827d1b5a8cce84d14598bc94d6d9/numpy-2.4.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:eea7ac5d2dce4189771cedb559c738a71512768210dc4e4753b107a2048b3d0e", size = 14895830, upload-time = "2026-03-29T13:21:41.509Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b8/aafb0d1065416894fccf4df6b49ef22b8db045187949545bced89c034b8e/numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:51fc224f7ca4d92656d5a5eb315f12eb5fe2c97a66249aa7b5f562528a3be38c", size = 5400927, upload-time = "2026-03-29T13:21:44.747Z" }, + { url = "https://files.pythonhosted.org/packages/d6/77/063baa20b08b431038c7f9ff5435540c7b7265c78cf56012a483019ca72d/numpy-2.4.4-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:28a650663f7314afc3e6ec620f44f333c386aad9f6fc472030865dc0ebb26ee3", size = 6715557, upload-time = "2026-03-29T13:21:47.406Z" }, + { url = "https://files.pythonhosted.org/packages/c7/a8/379542d45a14f149444c5c4c4e7714707239ce9cc1de8c2803958889da14/numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:19710a9ca9992d7174e9c52f643d4272dcd1558c5f7af7f6f8190f633bd651a7", size = 15804253, upload-time = "2026-03-29T13:21:50.753Z" }, + { url = "https://files.pythonhosted.org/packages/a2/c8/f0a45426d6d21e7ea3310a15cf90c43a14d9232c31a837702dba437f3373/numpy-2.4.4-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b2aec6af35c113b05695ebb5749a787acd63cafc83086a05771d1e1cd1e555f", size = 16753552, upload-time = "2026-03-29T13:21:54.344Z" }, + { url = "https://files.pythonhosted.org/packages/04/74/f4c001f4714c3ad9ce037e18cf2b9c64871a84951eaa0baf683a9ca9301c/numpy-2.4.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2cf083b324a467e1ab358c105f6cad5ea950f50524668a80c486ff1db24e119", size = 12509075, upload-time = "2026-03-29T13:21:57.644Z" }, +] + +[[package]] +name = "openai" +version = "2.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d7/91/2a06c4e9597c338cac1e5e5a8dd6f29e1836fc229c4c523529dca387fda8/openai-2.26.0.tar.gz", hash = "sha256:b41f37c140ae0034a6e92b0c509376d907f3a66109935fba2c1b471a7c05a8fb", size = 666702, upload-time = "2026-03-05T23:17:35.874Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/2e/3f73e8ca53718952222cacd0cf7eecc9db439d020f0c1fe7ae717e4e199a/openai-2.26.0-py3-none-any.whl", hash = "sha256:6151bf8f83802f036117f06cc8a57b3a4da60da9926826cc96747888b57f394f", size = 1136409, upload-time = "2026-03-05T23:17:34.072Z" }, +] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pastpaper-master-backend" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "fastapi" }, + { name = "httpx" }, + { name = "numpy" }, + { name = "openai" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pymupdf" }, + { name = "python-dotenv" }, + { name = "python-multipart" }, + { name = "supabase" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", specifier = ">=0.115.0" }, + { name = "httpx", specifier = ">=0.27.0" }, + { name = "numpy", specifier = ">=2.4.4" }, + { name = "openai", specifier = ">=1.50.0" }, + { name = "pydantic", specifier = ">=2.0.0" }, + { name = "pydantic-settings", specifier = ">=2.0.0" }, + { name = "pymupdf", specifier = ">=1.24.0" }, + { name = "python-dotenv", specifier = ">=1.0.0" }, + { name = "python-multipart", specifier = ">=0.0.9" }, + { name = "supabase", specifier = ">=2.0.0" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.30.0" }, +] + +[[package]] +name = "postgrest" +version = "2.28.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecation" }, + { name = "httpx", extra = ["http2"] }, + { name = "pydantic" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/75/96/52f4ce2123fed5cda50ede3b04135fc163944c6776a8315da8e2e38e0931/postgrest-2.28.0.tar.gz", hash = "sha256:c36b38646d25ea4255321d3d924ce70f8d20ec7799cb42c1221d6a818d4f6515", size = 13841, upload-time = "2026-02-10T13:17:00.648Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/47/43deadb113d8730e59d5045eb0968eb2ca8ccbad7506bd4fc4a18294e114/postgrest-2.28.0-py3-none-any.whl", hash = "sha256:7bca2f24dd1a1bf8a3d586c7482aba6cd41662da6733045fad585b63b7f7df75", size = 22008, upload-time = "2026-02-10T13:16:59.307Z" }, +] + +[[package]] +name = "propcache" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8c/d4/4e2c9aaf7ac2242b9358f98dccd8f90f2605402f5afeff6c578682c2c491/propcache-0.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:60a8fda9644b7dfd5dece8c61d8a85e271cb958075bfc4e01083c148b61a7caf", size = 80208, upload-time = "2025-10-08T19:46:24.597Z" }, + { url = "https://files.pythonhosted.org/packages/c2/21/d7b68e911f9c8e18e4ae43bdbc1e1e9bbd971f8866eb81608947b6f585ff/propcache-0.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c30b53e7e6bda1d547cabb47c825f3843a0a1a42b0496087bb58d8fedf9f41b5", size = 45777, upload-time = "2025-10-08T19:46:25.733Z" }, + { url = "https://files.pythonhosted.org/packages/d3/1d/11605e99ac8ea9435651ee71ab4cb4bf03f0949586246476a25aadfec54a/propcache-0.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6918ecbd897443087a3b7cd978d56546a812517dcaaca51b49526720571fa93e", size = 47647, upload-time = "2025-10-08T19:46:27.304Z" }, + { url = "https://files.pythonhosted.org/packages/58/1a/3c62c127a8466c9c843bccb503d40a273e5cc69838805f322e2826509e0d/propcache-0.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d902a36df4e5989763425a8ab9e98cd8ad5c52c823b34ee7ef307fd50582566", size = 214929, upload-time = "2025-10-08T19:46:28.62Z" }, + { url = "https://files.pythonhosted.org/packages/56/b9/8fa98f850960b367c4b8fe0592e7fc341daa7a9462e925228f10a60cf74f/propcache-0.4.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a9695397f85973bb40427dedddf70d8dc4a44b22f1650dd4af9eedf443d45165", size = 221778, upload-time = "2025-10-08T19:46:30.358Z" }, + { url = "https://files.pythonhosted.org/packages/46/a6/0ab4f660eb59649d14b3d3d65c439421cf2f87fe5dd68591cbe3c1e78a89/propcache-0.4.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2bb07ffd7eaad486576430c89f9b215f9e4be68c4866a96e97db9e97fead85dc", size = 228144, upload-time = "2025-10-08T19:46:32.607Z" }, + { url = "https://files.pythonhosted.org/packages/52/6a/57f43e054fb3d3a56ac9fc532bc684fc6169a26c75c353e65425b3e56eef/propcache-0.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fd6f30fdcf9ae2a70abd34da54f18da086160e4d7d9251f81f3da0ff84fc5a48", size = 210030, upload-time = "2025-10-08T19:46:33.969Z" }, + { url = "https://files.pythonhosted.org/packages/40/e2/27e6feebb5f6b8408fa29f5efbb765cd54c153ac77314d27e457a3e993b7/propcache-0.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:fc38cba02d1acba4e2869eef1a57a43dfbd3d49a59bf90dda7444ec2be6a5570", size = 208252, upload-time = "2025-10-08T19:46:35.309Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f8/91c27b22ccda1dbc7967f921c42825564fa5336a01ecd72eb78a9f4f53c2/propcache-0.4.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:67fad6162281e80e882fb3ec355398cf72864a54069d060321f6cd0ade95fe85", size = 202064, upload-time = "2025-10-08T19:46:36.993Z" }, + { url = "https://files.pythonhosted.org/packages/f2/26/7f00bd6bd1adba5aafe5f4a66390f243acab58eab24ff1a08bebb2ef9d40/propcache-0.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f10207adf04d08bec185bae14d9606a1444715bc99180f9331c9c02093e1959e", size = 212429, upload-time = "2025-10-08T19:46:38.398Z" }, + { url = "https://files.pythonhosted.org/packages/84/89/fd108ba7815c1117ddca79c228f3f8a15fc82a73bca8b142eb5de13b2785/propcache-0.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e9b0d8d0845bbc4cfcdcbcdbf5086886bc8157aa963c31c777ceff7846c77757", size = 216727, upload-time = "2025-10-08T19:46:39.732Z" }, + { url = "https://files.pythonhosted.org/packages/79/37/3ec3f7e3173e73f1d600495d8b545b53802cbf35506e5732dd8578db3724/propcache-0.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:981333cb2f4c1896a12f4ab92a9cc8f09ea664e9b7dbdc4eff74627af3a11c0f", size = 205097, upload-time = "2025-10-08T19:46:41.025Z" }, + { url = "https://files.pythonhosted.org/packages/61/b0/b2631c19793f869d35f47d5a3a56fb19e9160d3c119f15ac7344fc3ccae7/propcache-0.4.1-cp311-cp311-win32.whl", hash = "sha256:f1d2f90aeec838a52f1c1a32fe9a619fefd5e411721a9117fbf82aea638fe8a1", size = 38084, upload-time = "2025-10-08T19:46:42.693Z" }, + { url = "https://files.pythonhosted.org/packages/f4/78/6cce448e2098e9f3bfc91bb877f06aa24b6ccace872e39c53b2f707c4648/propcache-0.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:364426a62660f3f699949ac8c621aad6977be7126c5807ce48c0aeb8e7333ea6", size = 41637, upload-time = "2025-10-08T19:46:43.778Z" }, + { url = "https://files.pythonhosted.org/packages/9c/e9/754f180cccd7f51a39913782c74717c581b9cc8177ad0e949f4d51812383/propcache-0.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:e53f3a38d3510c11953f3e6a33f205c6d1b001129f972805ca9b42fc308bc239", size = 38064, upload-time = "2025-10-08T19:46:44.872Z" }, + { url = "https://files.pythonhosted.org/packages/a2/0f/f17b1b2b221d5ca28b4b876e8bb046ac40466513960646bda8e1853cdfa2/propcache-0.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e153e9cd40cc8945138822807139367f256f89c6810c2634a4f6902b52d3b4e2", size = 80061, upload-time = "2025-10-08T19:46:46.075Z" }, + { url = "https://files.pythonhosted.org/packages/76/47/8ccf75935f51448ba9a16a71b783eb7ef6b9ee60f5d14c7f8a8a79fbeed7/propcache-0.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd547953428f7abb73c5ad82cbb32109566204260d98e41e5dfdc682eb7f8403", size = 46037, upload-time = "2025-10-08T19:46:47.23Z" }, + { url = "https://files.pythonhosted.org/packages/0a/b6/5c9a0e42df4d00bfb4a3cbbe5cf9f54260300c88a0e9af1f47ca5ce17ac0/propcache-0.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f048da1b4f243fc44f205dfd320933a951b8d89e0afd4c7cacc762a8b9165207", size = 47324, upload-time = "2025-10-08T19:46:48.384Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d3/6c7ee328b39a81ee877c962469f1e795f9db87f925251efeb0545e0020d0/propcache-0.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec17c65562a827bba85e3872ead335f95405ea1674860d96483a02f5c698fa72", size = 225505, upload-time = "2025-10-08T19:46:50.055Z" }, + { url = "https://files.pythonhosted.org/packages/01/5d/1c53f4563490b1d06a684742cc6076ef944bc6457df6051b7d1a877c057b/propcache-0.4.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:405aac25c6394ef275dee4c709be43745d36674b223ba4eb7144bf4d691b7367", size = 230242, upload-time = "2025-10-08T19:46:51.815Z" }, + { url = "https://files.pythonhosted.org/packages/20/e1/ce4620633b0e2422207c3cb774a0ee61cac13abc6217763a7b9e2e3f4a12/propcache-0.4.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0013cb6f8dde4b2a2f66903b8ba740bdfe378c943c4377a200551ceb27f379e4", size = 238474, upload-time = "2025-10-08T19:46:53.208Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/3aae6835b8e5f44ea6a68348ad90f78134047b503765087be2f9912140ea/propcache-0.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15932ab57837c3368b024473a525e25d316d8353016e7cc0e5ba9eb343fbb1cf", size = 221575, upload-time = "2025-10-08T19:46:54.511Z" }, + { url = "https://files.pythonhosted.org/packages/6e/a5/8a5e8678bcc9d3a1a15b9a29165640d64762d424a16af543f00629c87338/propcache-0.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:031dce78b9dc099f4c29785d9cf5577a3faf9ebf74ecbd3c856a7b92768c3df3", size = 216736, upload-time = "2025-10-08T19:46:56.212Z" }, + { url = "https://files.pythonhosted.org/packages/f1/63/b7b215eddeac83ca1c6b934f89d09a625aa9ee4ba158338854c87210cc36/propcache-0.4.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:ab08df6c9a035bee56e31af99be621526bd237bea9f32def431c656b29e41778", size = 213019, upload-time = "2025-10-08T19:46:57.595Z" }, + { url = "https://files.pythonhosted.org/packages/57/74/f580099a58c8af587cac7ba19ee7cb418506342fbbe2d4a4401661cca886/propcache-0.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4d7af63f9f93fe593afbf104c21b3b15868efb2c21d07d8732c0c4287e66b6a6", size = 220376, upload-time = "2025-10-08T19:46:59.067Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ee/542f1313aff7eaf19c2bb758c5d0560d2683dac001a1c96d0774af799843/propcache-0.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:cfc27c945f422e8b5071b6e93169679e4eb5bf73bbcbf1ba3ae3a83d2f78ebd9", size = 226988, upload-time = "2025-10-08T19:47:00.544Z" }, + { url = "https://files.pythonhosted.org/packages/8f/18/9c6b015dd9c6930f6ce2229e1f02fb35298b847f2087ea2b436a5bfa7287/propcache-0.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:35c3277624a080cc6ec6f847cbbbb5b49affa3598c4535a0a4682a697aaa5c75", size = 215615, upload-time = "2025-10-08T19:47:01.968Z" }, + { url = "https://files.pythonhosted.org/packages/80/9e/e7b85720b98c45a45e1fca6a177024934dc9bc5f4d5dd04207f216fc33ed/propcache-0.4.1-cp312-cp312-win32.whl", hash = "sha256:671538c2262dadb5ba6395e26c1731e1d52534bfe9ae56d0b5573ce539266aa8", size = 38066, upload-time = "2025-10-08T19:47:03.503Z" }, + { url = "https://files.pythonhosted.org/packages/54/09/d19cff2a5aaac632ec8fc03737b223597b1e347416934c1b3a7df079784c/propcache-0.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:cb2d222e72399fcf5890d1d5cc1060857b9b236adff2792ff48ca2dfd46c81db", size = 41655, upload-time = "2025-10-08T19:47:04.973Z" }, + { url = "https://files.pythonhosted.org/packages/68/ab/6b5c191bb5de08036a8c697b265d4ca76148efb10fa162f14af14fb5f076/propcache-0.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:204483131fb222bdaaeeea9f9e6c6ed0cac32731f75dfc1d4a567fc1926477c1", size = 37789, upload-time = "2025-10-08T19:47:06.077Z" }, + { url = "https://files.pythonhosted.org/packages/bf/df/6d9c1b6ac12b003837dde8a10231a7344512186e87b36e855bef32241942/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", size = 77750, upload-time = "2025-10-08T19:47:07.648Z" }, + { url = "https://files.pythonhosted.org/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", size = 44780, upload-time = "2025-10-08T19:47:08.851Z" }, + { url = "https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", size = 46308, upload-time = "2025-10-08T19:47:09.982Z" }, + { url = "https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", size = 208182, upload-time = "2025-10-08T19:47:11.319Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0c/cd762dd011a9287389a6a3eb43aa30207bde253610cca06824aeabfe9653/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", size = 211215, upload-time = "2025-10-08T19:47:13.146Z" }, + { url = "https://files.pythonhosted.org/packages/30/3e/49861e90233ba36890ae0ca4c660e95df565b2cd15d4a68556ab5865974e/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", size = 218112, upload-time = "2025-10-08T19:47:14.913Z" }, + { url = "https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", size = 204442, upload-time = "2025-10-08T19:47:16.277Z" }, + { url = "https://files.pythonhosted.org/packages/50/a6/4282772fd016a76d3e5c0df58380a5ea64900afd836cec2c2f662d1b9bb3/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", size = 199398, upload-time = "2025-10-08T19:47:17.962Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ec/d8a7cd406ee1ddb705db2139f8a10a8a427100347bd698e7014351c7af09/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", size = 196920, upload-time = "2025-10-08T19:47:19.355Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748, upload-time = "2025-10-08T19:47:21.338Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877, upload-time = "2025-10-08T19:47:23.059Z" }, + { url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437, upload-time = "2025-10-08T19:47:24.445Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586, upload-time = "2025-10-08T19:47:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790, upload-time = "2025-10-08T19:47:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158, upload-time = "2025-10-08T19:47:27.961Z" }, + { url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451, upload-time = "2025-10-08T19:47:29.445Z" }, + { url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374, upload-time = "2025-10-08T19:47:30.579Z" }, + { url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396, upload-time = "2025-10-08T19:47:31.79Z" }, + { url = "https://files.pythonhosted.org/packages/f4/04/5b4c54a103d480e978d3c8a76073502b18db0c4bc17ab91b3cb5092ad949/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", size = 275950, upload-time = "2025-10-08T19:47:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c1/86f846827fb969c4b78b0af79bba1d1ea2156492e1b83dea8b8a6ae27395/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", size = 273856, upload-time = "2025-10-08T19:47:34.906Z" }, + { url = "https://files.pythonhosted.org/packages/36/1d/fc272a63c8d3bbad6878c336c7a7dea15e8f2d23a544bda43205dfa83ada/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", size = 280420, upload-time = "2025-10-08T19:47:36.338Z" }, + { url = "https://files.pythonhosted.org/packages/07/0c/01f2219d39f7e53d52e5173bcb09c976609ba30209912a0680adfb8c593a/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", size = 263254, upload-time = "2025-10-08T19:47:37.692Z" }, + { url = "https://files.pythonhosted.org/packages/2d/18/cd28081658ce597898f0c4d174d4d0f3c5b6d4dc27ffafeef835c95eb359/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", size = 261205, upload-time = "2025-10-08T19:47:39.659Z" }, + { url = "https://files.pythonhosted.org/packages/7a/71/1f9e22eb8b8316701c2a19fa1f388c8a3185082607da8e406a803c9b954e/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", size = 247873, upload-time = "2025-10-08T19:47:41.084Z" }, + { url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739, upload-time = "2025-10-08T19:47:42.51Z" }, + { url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514, upload-time = "2025-10-08T19:47:43.927Z" }, + { url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781, upload-time = "2025-10-08T19:47:45.448Z" }, + { url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396, upload-time = "2025-10-08T19:47:47.202Z" }, + { url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897, upload-time = "2025-10-08T19:47:48.336Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789, upload-time = "2025-10-08T19:47:49.876Z" }, + { url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" }, + { url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" }, + { url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" }, + { url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" }, + { url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" }, + { url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" }, + { url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" }, + { url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" }, + { url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" }, + { url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" }, + { url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" }, + { url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" }, + { url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" }, + { url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" }, + { url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" }, + { url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" }, + { url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" }, + { url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" }, + { url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" }, + { url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" }, + { url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" }, + { url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" }, + { url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" }, + { url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.13.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/6d/fffca34caecc4a3f97bda81b2098da5e8ab7efc9a66e819074a11955d87e/pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025", size = 223826, upload-time = "2026-02-19T13:45:08.055Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/4b/ccc026168948fec4f7555b9164c724cf4125eac006e176541483d2c959be/pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237", size = 58929, upload-time = "2026-02-19T13:45:06.034Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pyiceberg" +version = "0.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cachetools" }, + { name = "click" }, + { name = "fsspec" }, + { name = "mmh3" }, + { name = "pydantic" }, + { name = "pyparsing" }, + { name = "pyroaring" }, + { name = "requests" }, + { name = "rich" }, + { name = "strictyaml" }, + { name = "tenacity" }, + { name = "zstandard" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/f0/7616676603fdbd05ab97816337a9b31be08a5f9e1ffd636260812b217e0f/pyiceberg-0.11.1.tar.gz", hash = "sha256:366fe0d5a74e3cf1d4e7cbf3c49e308da60e7835ea268667be9185388f05d7a5", size = 1076075, upload-time = "2026-03-03T00:10:27.61Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/f7/3b7fee2ecc021f0526f23ef4ae5dcc8e0ed26062c35890ad25d39c53fb3b/pyiceberg-0.11.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ba98d6a41ec0b7c81dd85d764f15653d6abbbbd69d92630677c43f92dd50d924", size = 532406, upload-time = "2026-03-03T00:09:59.647Z" }, + { url = "https://files.pythonhosted.org/packages/94/25/324030b13d91b7b564fb7342bf3fcdbf76eed2672964b273b156bf84d6e5/pyiceberg-0.11.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6400774e6820760eb6c322f6feace43fe7267deb9f8d508f10bf258887a9c4d5", size = 533368, upload-time = "2026-03-03T00:10:01.264Z" }, + { url = "https://files.pythonhosted.org/packages/1c/83/6a43d06a079292c4fc7815b4de3e90a05ded90031c35d0a1b037659f722b/pyiceberg-0.11.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1663d79fc8400903992c63f79b3908b9298c623138e8929bf36c559231e082d3", size = 722886, upload-time = "2026-03-03T00:10:02.558Z" }, + { url = "https://files.pythonhosted.org/packages/ce/5c/53807036b63bb810f2c56b4b5576e79b721ba93f1c16bd0dd49ecfe41055/pyiceberg-0.11.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:856c7fca5ed780ed44f60bceb92d6b311ebc008a2249415b8f6045201d4f5530", size = 721212, upload-time = "2026-03-03T00:10:03.694Z" }, + { url = "https://files.pythonhosted.org/packages/db/11/65e25d6016e3844c516c9f04041853115711f64af1ee184a2320c9ceab4c/pyiceberg-0.11.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6cf94756fb6a822d20a5a64f44840e6633ebf8b1deb3ce01057bff1cc03b01c2", size = 717978, upload-time = "2026-03-03T00:10:05.011Z" }, + { url = "https://files.pythonhosted.org/packages/52/0b/33b4ea9dc7f0c496900f5fc6da79e8587e94b88e2244ff02b786016cc649/pyiceberg-0.11.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8bd52c1891ae74cee21a4ebe8325953310a2e0af5352d70f47f5461422fcce2d", size = 718747, upload-time = "2026-03-03T00:10:06.269Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b6/5e133c435efc577afea5be303d7123264a5176a3ce1e2d3dc3a691049eaf/pyiceberg-0.11.1-cp311-cp311-win_amd64.whl", hash = "sha256:65a7ad892a570045b0de2db6af17119162880aebc05a0c125ce2db7dab36f17e", size = 531081, upload-time = "2026-03-03T00:10:07.352Z" }, + { url = "https://files.pythonhosted.org/packages/8f/84/a140466b7e0841207e6b77042e03d4ab3a4f9d47e00f0bbbcc5420792bbb/pyiceberg-0.11.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cd423b8ee2f75fc9db09158875abe5e2c952a26ae5e521c3265ab2f9d3511ddf", size = 532981, upload-time = "2026-03-03T00:10:08.906Z" }, + { url = "https://files.pythonhosted.org/packages/17/10/6bedd784010f707680ffd0606d4d11394cf915f4f9f54ae16e8007e00ad4/pyiceberg-0.11.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e273242cdca56029af694d7ce18075d47a74d034326d663ff6dd2655a6f44825", size = 533188, upload-time = "2026-03-03T00:10:10.086Z" }, + { url = "https://files.pythonhosted.org/packages/f1/a3/79db617c3cffc963efa8a332707079d3f22fd58067b31a208d358dd89b39/pyiceberg-0.11.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b347d3cc8510f8fbe191956fcda7da372ebb3302789acefca08e352345959003", size = 729546, upload-time = "2026-03-03T00:10:11.413Z" }, + { url = "https://files.pythonhosted.org/packages/06/64/acc11d230c33817bced80d9d947bb49e7bb3a429d76d906523e3df86faf8/pyiceberg-0.11.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bba3a35b4648694783aeae5b77c235a57191c8b1b375c8602b03ae56a6cf4fe7", size = 730263, upload-time = "2026-03-03T00:10:13.283Z" }, + { url = "https://files.pythonhosted.org/packages/8d/1a/fb067d5150c7309fbf5dd126c648a6afed6259e7bc924ba3c65d0f87a333/pyiceberg-0.11.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0f958cbca18d05846e3081dfff8575e73d45595441d659847479656dc76f91d", size = 724064, upload-time = "2026-03-03T00:10:14.55Z" }, + { url = "https://files.pythonhosted.org/packages/c1/71/103fdba5b144d55f3bb07347893737cc1d8fd71308108a77b7817c92c544/pyiceberg-0.11.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8c62636a1e9d8a1fc74ffb70383939b9cd93f2c9ee8e12015a50dd75c98a989e", size = 727239, upload-time = "2026-03-03T00:10:16.204Z" }, + { url = "https://files.pythonhosted.org/packages/18/c3/4db64429304c58c039f8e842cd37a9a1c472f596c2868ed2a5d2907b17ed/pyiceberg-0.11.1-cp312-cp312-win_amd64.whl", hash = "sha256:1d6b6f0c1e7dd8357f1ba56524bfc870d04ad3c00979db291784a7145497ad3b", size = 531309, upload-time = "2026-03-03T00:10:17.561Z" }, + { url = "https://files.pythonhosted.org/packages/35/4c/a122d80d98cb6125d87024681263406433f0c25c699d503f5633521e6809/pyiceberg-0.11.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b7ec5db19feab98a31fcd5caccf4a9a4e83f96933d1ca393ba7aea665710c2bb", size = 532644, upload-time = "2026-03-03T00:10:18.574Z" }, + { url = "https://files.pythonhosted.org/packages/10/94/9a8fa5fc580e6dccd34bbbf51e7658cd7b49540e2458783addeff5e22a91/pyiceberg-0.11.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cec0616d2ba6e7dda6327089a2f34ec723aa9ac2c389857ef0b83f65fb135dd6", size = 532787, upload-time = "2026-03-03T00:10:19.656Z" }, + { url = "https://files.pythonhosted.org/packages/b3/ab/ab7c88828bc17d77dbbc5a765419dfec2135629e1d74cdd0762cd38ad867/pyiceberg-0.11.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ddb360da76c62c7c23ec3da40e1af48e6712a563905fea2d1a8911ff7a3b6c4d", size = 722202, upload-time = "2026-03-03T00:10:21.012Z" }, + { url = "https://files.pythonhosted.org/packages/df/38/079cf1c0bf86da315472a926eec0dba10135f43374a2e267336eb98d8c76/pyiceberg-0.11.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d8790f420ebc484236017edba59182cf2a21bd3e4224a0bd0760a9c7268e96a", size = 724037, upload-time = "2026-03-03T00:10:22.176Z" }, + { url = "https://files.pythonhosted.org/packages/08/6b/08eaef477debb110438d943ef3f5985096f660ccb735d6344701cbd075a9/pyiceberg-0.11.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ae27ba4d37925d5b2cff192acaa70c8bb114d632bbc527cc91fea0370702b866", size = 716035, upload-time = "2026-03-03T00:10:23.789Z" }, + { url = "https://files.pythonhosted.org/packages/0b/59/7671d6a630ab1d85c6e7ca8ddf438dc63a0b0dd183bc4be69bf25c0fa5f6/pyiceberg-0.11.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:db66a4e0fdfbf4090631d59c3f65e960d9a5561e9259f6f3993cbe91e396837e", size = 720887, upload-time = "2026-03-03T00:10:24.824Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2b/5c8ad37807efaedb14b20f01f36462684468c80da5b74f4018fb4c1804b5/pyiceberg-0.11.1-cp313-cp313-win_amd64.whl", hash = "sha256:eb3a0a3e630ee89758eb96b39b456f4697732351fb0c080e9498ea578f9b71f9", size = 530923, upload-time = "2026-03-03T00:10:26.196Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a8/10/e8192be5f38f3e8e7e046716de4cae33d56fd5ae08927a823bb916be36c1/pyjwt-2.12.0.tar.gz", hash = "sha256:2f62390b667cd8257de560b850bb5a883102a388829274147f1d724453f8fb02", size = 102511, upload-time = "2026-03-12T17:15:30.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/70/70f895f404d363d291dcf62c12c85fdd47619ad9674ac0f53364d035925a/pyjwt-2.12.0-py3-none-any.whl", hash = "sha256:9bb459d1bdd0387967d287f5656bf7ec2b9a26645d1961628cda1764e087fd6e", size = 29700, upload-time = "2026-03-12T17:15:29.257Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pymupdf" +version = "1.27.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/fb/d80374ab091ab7ad5a5e7981a45c877ae094db668c1ab4d30f1109a4ec6a/pymupdf-1.27.2.tar.gz", hash = "sha256:37fc9cedeafb40839f86a074d4d9feab725144bdd4bbfd20308ff8957e2b10af", size = 85353104, upload-time = "2026-03-10T12:53:01.697Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/ee/2c10b6bde83ee42f5150b690ace952a802a7e632776dadd42bbfe5b68601/pymupdf-1.27.2-cp310-abi3-macosx_10_9_x86_64.whl", hash = "sha256:a60ff9010d7025428e31d92ac2c9b4218c7c4844409d0b31a050565ea0a955fd", size = 23987468, upload-time = "2026-03-10T12:37:06.593Z" }, + { url = "https://files.pythonhosted.org/packages/44/06/c8cc8c8ade83f5a75ac0f543edc2bc3c52d8c38c1d55d1e0713558258540/pymupdf-1.27.2-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:5095efb242cfe1c46fec1c864a13f000098564829c98366582dde7ad9e61aa32", size = 23262964, upload-time = "2026-03-10T12:37:23.915Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8e/df2ab91a680a77c82bc4501cdca60767b3758d75552e4d2849647a16cbc0/pymupdf-1.27.2-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:1081235fcfad268d801cd73a7b69c629939e2c46ed4d97035cb1bb7b5b90dc54", size = 24318675, upload-time = "2026-03-10T12:37:42.249Z" }, + { url = "https://files.pythonhosted.org/packages/ab/56/c6c16fa2dcfe2476ec28a9aaaca773dc35c593699e81e573211c91442770/pymupdf-1.27.2-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:917f4dd52daea504d5c60e1430c17d637b5014a43e66d068b4b356effe087dba", size = 24947974, upload-time = "2026-03-10T12:38:00.779Z" }, + { url = "https://files.pythonhosted.org/packages/7b/4f/1659f1d80b5d2f5aad134c2ca63894c63daf47a3ffb7e18987fe25e49097/pymupdf-1.27.2-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9617d5e71c334937c804544fa201946c5f73d0a97b5842b96857bdabfefbc343", size = 25169417, upload-time = "2026-03-10T12:38:18.912Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/e34d704f7242885dd1d67cfbe1040051a04b4b7e2cf1cbd27af9bd4500a3/pymupdf-1.27.2-cp310-abi3-win32.whl", hash = "sha256:6deef49e06c9a5d8670bf5835a911ab887dac4b3ed4bd60ab7d93da6aa8ff6f1", size = 18008725, upload-time = "2026-03-10T12:38:31.915Z" }, + { url = "https://files.pythonhosted.org/packages/f5/fb/a3f1f8813f6e93c65d1f7ebca6530a889f1ae109229b537f7a617b2aab57/pymupdf-1.27.2-cp310-abi3-win_amd64.whl", hash = "sha256:acdfdb7329882246545a0f6bc85f91739e2773ed81f9301c1687cffb826470f3", size = 19237944, upload-time = "2026-03-10T12:38:45.603Z" }, + { url = "https://files.pythonhosted.org/packages/e6/a4/e9257882f0569a21d51207a58f7586a799e76dc6b4008029a04f2329194c/pymupdf-1.27.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:261c916915cede4c546559810d3210277f86f31b52dd3de138f1e12d95a4c6b6", size = 24985149, upload-time = "2026-03-10T12:39:02.636Z" }, +] + +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + +[[package]] +name = "pyroaring" +version = "1.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/e4/975f0fa77fc3590820b4a3ac49704644b389795409bc12eb91729f845812/pyroaring-1.0.3.tar.gz", hash = "sha256:cd7392d1c010c9e41c11c62cd0610c8852e7e9698b1f7f6c2fcdefe50e7ef6da", size = 188688, upload-time = "2025-10-09T09:08:22.448Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/ed/5e555dd99b12318ea1c7666b773fc4f097aeb609eeb1c1b3da519d445f71/pyroaring-1.0.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:755cdac1f9a1b7b5c621e570d4f6dbcf3b8e4a1e35a66f976104ecb35dce4ed2", size = 675916, upload-time = "2025-10-09T09:06:53.174Z" }, + { url = "https://files.pythonhosted.org/packages/da/06/dd8a9a87b90c4560f8384ab1dbafcd40c2a16f6777a07334a8e341bd7383/pyroaring-1.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ebab073db620f26f0ba11e13fa2f35e3b1298209fba47b6bc8cb6f0e2c9627f9", size = 369743, upload-time = "2025-10-09T09:06:54.421Z" }, + { url = "https://files.pythonhosted.org/packages/35/aa/da882011045ddacffe818a4fcbdd7e609a15f9c83d536222ec5b17af4aa9/pyroaring-1.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:684fb8dffe19bdb7f91897c65eac6eee23b1e46043c47eb24288f28a1170fe04", size = 313981, upload-time = "2025-10-09T09:06:55.514Z" }, + { url = "https://files.pythonhosted.org/packages/ed/3c/f6534844b02e2505ccdc9aae461c9838ab96f72b5688c045448761735512/pyroaring-1.0.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:678d31fc24e82945a1bfb14816c77823983382ffea76985d494782aa2f058427", size = 1923181, upload-time = "2025-10-09T09:06:56.897Z" }, + { url = "https://files.pythonhosted.org/packages/ea/82/9f1a85ba33e3d89b9cdb8183fb2fd2f25720d10742dd8827508ccccc13ae/pyroaring-1.0.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d815f624e0285db3669f673d1725cb754b120ec70d0032d7c7166103a96c96d", size = 2113222, upload-time = "2025-10-09T09:06:58.388Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f8/4d4340971cbc1379f987c847080bcb7f9765a57e122f392c3a3485c9587e/pyroaring-1.0.3-cp311-cp311-manylinux_2_24_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:57fd5b80dacb8e888402b6b7508a734c6a527063e4e24e882ff2e0fd90721ada", size = 1837385, upload-time = "2025-10-09T09:06:59.449Z" }, + { url = "https://files.pythonhosted.org/packages/c6/58/d14cc561685e4c224af26b4fdb4f6c7e643294ac5a4b29f178b5cbb71af1/pyroaring-1.0.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ab26a7a45a0bb46c00394d1a60a9f2d57c220f84586e30d59b39784b0f94aee6", size = 1856170, upload-time = "2025-10-09T09:07:00.608Z" }, + { url = "https://files.pythonhosted.org/packages/d1/d2/d2d9790c373f6438d4d0958bc4c79f3dc77826d8553743ff3f64acdc9ab3/pyroaring-1.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9232f3f606315d59049c128154100fd05008d5c5c211e48b21848cd41ee64d26", size = 2909282, upload-time = "2025-10-09T09:07:02.124Z" }, + { url = "https://files.pythonhosted.org/packages/bc/28/4b2277982302b5b406998064ca1eaef1a79e4ea87185f511e33e7a7e3511/pyroaring-1.0.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f34b44b3ec3df97b978799f2901fefb2a48d367496fd1cde3cc5fe8b3bc13510", size = 2701034, upload-time = "2025-10-09T09:07:03.403Z" }, + { url = "https://files.pythonhosted.org/packages/d2/91/b2340193825fa2431cf735f0ecb23206fb31f386fecca38336935a294513/pyroaring-1.0.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:25a83ec6bac3106568bd3fdd316f0fee52aa0be8c72da565ad02b10ae7905924", size = 3028962, upload-time = "2025-10-09T09:07:05.558Z" }, + { url = "https://files.pythonhosted.org/packages/07/ea/ad79073cc5d8dcca35d1a955bb886d96905e9dacc58d1971fda012a5ad18/pyroaring-1.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c17d4ec53b5b6b333d9a9515051213a691293ada785dc8c025d3641482597ed3", size = 3152109, upload-time = "2025-10-09T09:07:06.887Z" }, + { url = "https://files.pythonhosted.org/packages/9a/de/f55a1093acb16d25ff9811546823e59078e4a3e56d2eb0ff5d10f696933d/pyroaring-1.0.3-cp311-cp311-win32.whl", hash = "sha256:d54024459ace600f1d1ffbc6dc3c60eb47cca3b678701f06148f59e10f6f8d7b", size = 204246, upload-time = "2025-10-09T09:07:08.036Z" }, + { url = "https://files.pythonhosted.org/packages/c6/e5/36bf3039733b8e00732892c9334b2f5309f38e72af0b3b40b8729b5857a3/pyroaring-1.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:c28750148ef579a7447a8cb60b39e5943e03f8c29bce8f2788728f6f23d1887a", size = 254637, upload-time = "2025-10-09T09:07:09.103Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e8/e2b78e595b5a82a6014af327614756a55f17ec4120a2ab197f1762641316/pyroaring-1.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:535d8deccbd8db2c6bf38629243e9646756905574a742b2a72ff51d6461d616c", size = 219597, upload-time = "2025-10-09T09:07:10.38Z" }, + { url = "https://files.pythonhosted.org/packages/dd/09/a5376d55672e0535019ba1469888909d0046cea0cfb969a4aa1f99caaf22/pyroaring-1.0.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:add3e4c78eb590a76526ecce8d1566eecdd5822e351c36b3697997f4a80ed808", size = 681056, upload-time = "2025-10-09T09:07:11.497Z" }, + { url = "https://files.pythonhosted.org/packages/23/dd/78f59d361bd9ebf8de3660408b0c48664ade0a057ebcf4b207d99ac1a698/pyroaring-1.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ebaffe846cf4ba4f00ce6b8a9f39613f24e2d09447e77be4fa6e898bc36451b6", size = 375111, upload-time = "2025-10-09T09:07:12.597Z" }, + { url = "https://files.pythonhosted.org/packages/bf/03/10dc93f83a5453eb40a69c79106a8385b40aa12cf4531ca72bd9d7f45cb2/pyroaring-1.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a9459f27498f97d08031a34a5ead230b77eb0ab3cc3d85b7f54faa2fd548acd6", size = 314319, upload-time = "2025-10-09T09:07:13.579Z" }, + { url = "https://files.pythonhosted.org/packages/86/9e/b00c38a7e62a73e152055f593595c37152e61fc2896fd11538a7c71fbe4e/pyroaring-1.0.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f2b2eb8bd1c35c772994889be9f7dda09477475d7aa1e2af9ab4ef18619326f6", size = 1869251, upload-time = "2025-10-09T09:07:14.584Z" }, + { url = "https://files.pythonhosted.org/packages/4f/33/f32d00ca105b66303deab43d027c3574c8ade8525dac0e5b50a9fb4d1b76/pyroaring-1.0.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d31f4c1c906f1af14ce61a3959d04a14a64c594f8a768399146a45bbd341f21f", size = 2071551, upload-time = "2025-10-09T09:07:15.713Z" }, + { url = "https://files.pythonhosted.org/packages/5d/89/e953cae181ba4c7523334855a1ca0ae8eeea3cee8d7cd39c56bd99709d3f/pyroaring-1.0.3-cp312-cp312-manylinux_2_24_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53be988fc86698d56c11049bfe5113a2f6990adb1fa2782b29636509808b6aa7", size = 1781071, upload-time = "2025-10-09T09:07:17.19Z" }, + { url = "https://files.pythonhosted.org/packages/fa/db/65d4be532e68b62a84a9c89b24d0a1394f452f484fa29392142d9a3b9c48/pyroaring-1.0.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7df84d223424523b19a23781f4246cc247fd6d821e1bc0853c2f25669136f7d0", size = 1795670, upload-time = "2025-10-09T09:07:18.524Z" }, + { url = "https://files.pythonhosted.org/packages/f5/9e/684ea0568ce7d30fc4e01ad1c666e9ce1a5b1702fa630231f4f6bdb96539/pyroaring-1.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:34a781f1f9766897f63ef18be129827340ae37764015b83fdcff1efb9e29136d", size = 2849305, upload-time = "2025-10-09T09:07:20.388Z" }, + { url = "https://files.pythonhosted.org/packages/7c/fd/d7773a2adf91f45d8924197954c66b1694325afd2f27e02edaac07338402/pyroaring-1.0.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1f414343b4ed0756734328cdf2a91022fc54503769e3f8d79bd0b672ea815a16", size = 2692843, upload-time = "2025-10-09T09:07:22.042Z" }, + { url = "https://files.pythonhosted.org/packages/13/72/b8a99ba138eebd8ff9bf8d15f3942e9e43e8e45723e2e6b7b09e542b7448/pyroaring-1.0.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d16ae185c72dc64f76335dbe53e53a892e78115adc92194957d1b7ef74d230b9", size = 2983440, upload-time = "2025-10-09T09:07:23.419Z" }, + { url = "https://files.pythonhosted.org/packages/ca/94/e6ed1f682d850e039c71b2032bacdefc5082dc809796cf34b9e6f24c604d/pyroaring-1.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f888447bf22dde7759108bfe6dfbeb6bbb61b14948de9c4cb6843c4dd57e2215", size = 3117542, upload-time = "2025-10-09T09:07:25.104Z" }, + { url = "https://files.pythonhosted.org/packages/8f/89/d55b0ed3e098ef89c421b43b748afe3d90eb250cab50b9e53e3a3449ac58/pyroaring-1.0.3-cp312-cp312-win32.whl", hash = "sha256:fbbdc44c51a0a3efd7be3dbe04466278ce098fcd101aa1905849319042159770", size = 205118, upload-time = "2025-10-09T09:07:26.532Z" }, + { url = "https://files.pythonhosted.org/packages/c8/e1/b71fef6a73efb50110d33d714235ff7059f4ebae98dc474b6549b322f48f/pyroaring-1.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:3b217c4b3ad953b4c759a0d2f9bd95316f0c345b9f7adb49e6ded7a1f5106bd4", size = 260629, upload-time = "2025-10-09T09:07:27.528Z" }, + { url = "https://files.pythonhosted.org/packages/57/33/66ee872079c9c47512d6e17d374bcad8d91350c24dc20fbe678c34b33745/pyroaring-1.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:e6bcf838564c21bab8fe6c2748b4990d4cd90612d8c470c04889def7bb5114ea", size = 219032, upload-time = "2025-10-09T09:07:28.754Z" }, + { url = "https://files.pythonhosted.org/packages/1f/95/97142ee32587ddda9e2cd614b865eeb5c0ee91006a51928f4074cd6e8e5f/pyroaring-1.0.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:20bc947054b197d1baa76cd05d70b8e04f95b82e698266e2f8f2f4b36d764477", size = 678813, upload-time = "2025-10-09T09:07:29.936Z" }, + { url = "https://files.pythonhosted.org/packages/70/5e/cff22be3a76a80024bdf00a9decdffedc6e80f037328a58b58c1b521442d/pyroaring-1.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ba5909b4c66bb85cab345e2f3a87e5ce671509c94b8c9823d8db64e107cbe854", size = 373661, upload-time = "2025-10-09T09:07:30.983Z" }, + { url = "https://files.pythonhosted.org/packages/86/73/fc406a67cd49e1707d1c3d08214458959dd579eff88c28587b356dfa068b/pyroaring-1.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b744746ba5da27fad760067f12633f5d384db6a1e65648d00244ceacbbd87731", size = 313559, upload-time = "2025-10-09T09:07:32.099Z" }, + { url = "https://files.pythonhosted.org/packages/f9/64/c7fe510523445f27e2cb04de6ffd3137f9d72db438b62db2bfa3dafcf4fc/pyroaring-1.0.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5b16c2a2791a5a09c4b59c0e1069ac1c877d0df25cae3155579c7eac8844676e", size = 1875926, upload-time = "2025-10-09T09:07:33.701Z" }, + { url = "https://files.pythonhosted.org/packages/47/74/da9b8ad2ca9ce6af1377f2cffdad6582a51a5f5df4f26df5c41810c9de5b/pyroaring-1.0.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7f68dfcf8d01177267f4bc06c4960fe8e39577470d1b52c9af8b61a72ca8767", size = 2064377, upload-time = "2025-10-09T09:07:35.273Z" }, + { url = "https://files.pythonhosted.org/packages/99/e3/8a70c5a5f7821c63709e2769aeccda8ae87a192198374bc475cbee543a22/pyroaring-1.0.3-cp313-cp313-manylinux_2_24_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:dba4e4700030182a981a3c887aa73887697145fc9ffb192f908aa59b718fbbdd", size = 1778320, upload-time = "2025-10-09T09:07:36.782Z" }, + { url = "https://files.pythonhosted.org/packages/04/4c/08159a07c3723a2775064887543766b6115b4975e7baaa4d51e5580701a4/pyroaring-1.0.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e26dd1dc1edba02288902914bdb559e53e346e9155defa43c31fcab831b55342", size = 1786569, upload-time = "2025-10-09T09:07:38.473Z" }, + { url = "https://files.pythonhosted.org/packages/e5/ff/55a18d0e7e0dc4cd9f43988b746e788234a8d660fa17367c5ed9fa799348/pyroaring-1.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6eb98d2cacfc6d51c6a69893f04075e07b3df761eac71ba162c43b9b4c4452ad", size = 2852766, upload-time = "2025-10-09T09:07:39.633Z" }, + { url = "https://files.pythonhosted.org/packages/24/3c/419e25c51843dd40975ae37d67dea4f2f256554b5bec32237f607ec8ef21/pyroaring-1.0.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a967e9eddb9485cbdd95d6371e3dada67880844d836c0283d3b11efe9225d1b7", size = 2683904, upload-time = "2025-10-09T09:07:41.139Z" }, + { url = "https://files.pythonhosted.org/packages/75/64/8d91f1b85b42925af632fc2c1047bb314be622dce890a4181a0a8d6e498d/pyroaring-1.0.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b12ef7f992ba7be865f91c7c098fd8ac6c413563aaa14d5b1e2bcb8cb43a4614", size = 2973884, upload-time = "2025-10-09T09:07:42.34Z" }, + { url = "https://files.pythonhosted.org/packages/61/6d/c867625549df0dc9ad675424ecf989fa2f08f0571bd46dfc4f7218737dd2/pyroaring-1.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:82ca5be174b85c40be7b00bc6bf39b2931a1b4a465f3af17ec6b9c48e9aa6fe0", size = 3103671, upload-time = "2025-10-09T09:07:44.055Z" }, + { url = "https://files.pythonhosted.org/packages/59/b1/d47c5ec2b2580d0b94f42575be8f49907a0f4aa396fdc18660f3b5060d54/pyroaring-1.0.3-cp313-cp313-win32.whl", hash = "sha256:f758c681e63ffe74b20423695e71f0410920f41b075cee679ffb5bc2bf38440b", size = 205153, upload-time = "2025-10-09T09:07:45.496Z" }, + { url = "https://files.pythonhosted.org/packages/c4/92/3600486936eebab747ae1462d231d7f87d234da24a04e82e1915c00f4427/pyroaring-1.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:428c3bb384fe4c483feb5cf7aa3aef1621fb0a5c4f3d391da67b2c4a43f08a10", size = 260349, upload-time = "2025-10-09T09:07:46.524Z" }, + { url = "https://files.pythonhosted.org/packages/77/96/8dde074f1ad2a1c3d2091b22de80d1b3007824e649e06eeeebded83f4d48/pyroaring-1.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:9c0c856e8aa5606e8aed5f30201286e404fdc9093f81fefe82d2e79e67472bb2", size = 218775, upload-time = "2025-10-09T09:07:47.558Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/01/979e98d542a70714b0cb2b6728ed0b7c46792b695e3eaec3e20711271ca3/python_multipart-0.0.22.tar.gz", hash = "sha256:7340bef99a7e0032613f56dc36027b959fd3b30a787ed62d310e951f7c3a3a58", size = 37612, upload-time = "2026-01-25T10:15:56.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/d0/397f9626e711ff749a95d96b7af99b9c566a9bb5129b8e4c10fc4d100304/python_multipart-0.0.22-py3-none-any.whl", hash = "sha256:2b2cd894c83d21bf49d702499531c7bafd057d730c201782048f7945d82de155", size = 24579, upload-time = "2026-01-25T10:15:54.811Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "realtime" +version = "2.28.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, + { name = "websockets" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d7/0c/a3f34afadd988a99b86b842b334ad1036ae32672e2e5ad89a0b450dc6926/realtime-2.28.0.tar.gz", hash = "sha256:d18cedcebd6a8f22fcd509bc767f639761eb218b7b2b6f14fc4205b6259b50fc", size = 18726, upload-time = "2026-02-10T13:17:02.755Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/04/dd8409d015a872bc1763a87d5d4e82d82c3eac99e9045f2fceab7f38b4b2/realtime-2.28.0-py3-none-any.whl", hash = "sha256:db1bd59bab9b1fcc9f9d3b1a073bed35bf4994d720e6751f10031a58d57a3836", size = 22375, upload-time = "2026-02-10T13:17:01.412Z" }, +] + +[[package]] +name = "requests" +version = "2.32.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, +] + +[[package]] +name = "rich" +version = "14.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/c6/f3b320c27991c46f43ee9d856302c70dc2d0fb2dba4842ff739d5f46b393/rich-14.3.3.tar.gz", hash = "sha256:b8daa0b9e4eef54dd8cf7c86c03713f53241884e814f4e2f5fb342fe520f639b", size = 230582, upload-time = "2026-02-19T17:23:12.474Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/25/b208c5683343959b670dc001595f2f3737e051da617f66c31f7c4fa93abc/rich-14.3.3-py3-none-any.whl", hash = "sha256:793431c1f8619afa7d3b52b2cdec859562b950ea0d4b6b505397612db8d5362d", size = 310458, upload-time = "2026-02-19T17:23:13.732Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "starlette" +version = "0.52.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/68/79977123bb7be889ad680d79a40f339082c1978b5cfcf62c2d8d196873ac/starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933", size = 2653702, upload-time = "2026-01-18T13:34:11.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/0d/13d1d239a25cbfb19e740db83143e95c772a1fe10202dda4b76792b114dd/starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74", size = 74272, upload-time = "2026-01-18T13:34:09.188Z" }, +] + +[[package]] +name = "storage3" +version = "2.28.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecation" }, + { name = "httpx", extra = ["http2"] }, + { name = "pydantic" }, + { name = "pyiceberg" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/84/3b/63bddc4d09aa7bdb46366fcc1bc96c6aef5d4de40ec8e0000d7b30f41534/storage3-2.28.0.tar.gz", hash = "sha256:bc1d008aff67de7a0f2bd867baee7aadbcdb6f78f5a310b4f7a38e8c13c19865", size = 20104, upload-time = "2026-02-10T13:17:04.758Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/10/adf75d912429398f626df1dad61e8c4225a5b8fdf0db8588277a77b26e5c/storage3-2.28.0-py3-none-any.whl", hash = "sha256:ecb50efd2ac71dabbdf97e99ad346eafa630c4c627a8e5a138ceb5fbbadae716", size = 28239, upload-time = "2026-02-10T13:17:03.572Z" }, +] + +[[package]] +name = "strenum" +version = "0.4.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/ad/430fb60d90e1d112a62ff57bdd1f286ec73a2a0331272febfddd21f330e1/StrEnum-0.4.15.tar.gz", hash = "sha256:878fb5ab705442070e4dd1929bb5e2249511c0bcf2b0eeacf3bcd80875c82eff", size = 23384, upload-time = "2023-06-29T22:02:58.399Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/69/297302c5f5f59c862faa31e6cb9a4cd74721cd1e052b38e464c5b402df8b/StrEnum-0.4.15-py3-none-any.whl", hash = "sha256:a30cda4af7cc6b5bf52c8055bc4bf4b2b6b14a93b574626da33df53cf7740659", size = 8851, upload-time = "2023-06-29T22:02:56.947Z" }, +] + +[[package]] +name = "strictyaml" +version = "1.7.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/08/efd28d49162ce89c2ad61a88bd80e11fb77bc9f6c145402589112d38f8af/strictyaml-1.7.3.tar.gz", hash = "sha256:22f854a5fcab42b5ddba8030a0e4be51ca89af0267961c8d6cfa86395586c407", size = 115206, upload-time = "2023-03-10T12:50:27.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/96/7c/a81ef5ef10978dd073a854e0fa93b5d8021d0594b639cc8f6453c3c78a1d/strictyaml-1.7.3-py3-none-any.whl", hash = "sha256:fb5c8a4edb43bebb765959e420f9b3978d7f1af88c80606c03fb420888f5d1c7", size = 123917, upload-time = "2023-03-10T12:50:17.242Z" }, +] + +[[package]] +name = "supabase" +version = "2.28.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "postgrest" }, + { name = "realtime" }, + { name = "storage3" }, + { name = "supabase-auth" }, + { name = "supabase-functions" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/87/05ee1feadf8ca34479707123b3e8b60d84776fd5c53676f03fe459fe9b44/supabase-2.28.0.tar.gz", hash = "sha256:aea299aaab2a2eed3c57e0be7fc035c6807214194cce795a3575add20268ece1", size = 9693, upload-time = "2026-02-10T13:17:06.539Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/11/8e/a94600a09b5243b86f6f79f86b0fdfe1e5ea7815f00dfa2035bf2ba2b4f7/supabase-2.28.0-py3-none-any.whl", hash = "sha256:42776971c7d0ccca16034df1ab96a31c50228eb1eb19da4249ad2f756fc20272", size = 16635, upload-time = "2026-02-10T13:17:05.714Z" }, +] + +[[package]] +name = "supabase-auth" +version = "2.28.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx", extra = ["http2"] }, + { name = "pydantic" }, + { name = "pyjwt", extra = ["crypto"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/03/2c53c436799911a664e4aecea5bdcf34c92382c180a829557babcc2ab9c4/supabase_auth-2.28.0.tar.gz", hash = "sha256:2bb8f18ff39934e44b28f10918db965659f3735cd6fbfcc022fe0b82dbf8233e", size = 39279, upload-time = "2026-02-10T13:17:09.143Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/94/6a947240e5ed98f9c1199283838793ab1c1c8a8141d669c38b1f35332291/supabase_auth-2.28.0-py3-none-any.whl", hash = "sha256:2ac85026cc285054c7fa6d41924f3a333e9ec298c013e5b5e1754039ba7caec9", size = 48516, upload-time = "2026-02-10T13:17:08.223Z" }, +] + +[[package]] +name = "supabase-functions" +version = "2.28.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx", extra = ["http2"] }, + { name = "strenum" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c5/ad/80014166587af84169bc0720d625e747f03815244efa29bfc461b82dbb87/supabase_functions-2.28.0.tar.gz", hash = "sha256:db3dddfc37aca5858819eb461130968473bd8c75bd284581013958526dac718b", size = 4677, upload-time = "2026-02-10T13:17:10.534Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/04/0b18abbcb5dcc4630637d08df91610d7513ae36727f7181b9a7536850003/supabase_functions-2.28.0-py3-none-any.whl", hash = "sha256:30bf2d586f8df285faf0621bb5d5bb3ec3157234fc820553ca156f009475e4ae", size = 8800, upload-time = "2026-02-10T13:17:09.798Z" }, +] + +[[package]] +name = "tenacity" +version = "9.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" }, +] + +[[package]] +name = "tqdm" +version = "4.67.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.41.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/32/ce/eeb58ae4ac36fe09e3842eb02e0eb676bf2c53ae062b98f1b2531673efdd/uvicorn-0.41.0.tar.gz", hash = "sha256:09d11cf7008da33113824ee5a1c6422d89fbc2ff476540d69a34c87fab8b571a", size = 82633, upload-time = "2026-02-16T23:07:24.1Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/e4/d04a086285c20886c0daad0e026f250869201013d18f81d9ff5eada73a88/uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187", size = 68783, upload-time = "2026-02-16T23:07:22.357Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/d5/69900f7883235562f1f50d8184bb7dd84a2fb61e9ec63f3782546fdbd057/uvloop-0.22.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9", size = 1352420, upload-time = "2025-10-16T22:16:21.187Z" }, + { url = "https://files.pythonhosted.org/packages/a8/73/c4e271b3bce59724e291465cc936c37758886a4868787da0278b3b56b905/uvloop-0.22.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77", size = 748677, upload-time = "2025-10-16T22:16:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/86/94/9fb7fad2f824d25f8ecac0d70b94d0d48107ad5ece03769a9c543444f78a/uvloop-0.22.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21", size = 3753819, upload-time = "2025-10-16T22:16:23.903Z" }, + { url = "https://files.pythonhosted.org/packages/74/4f/256aca690709e9b008b7108bc85fba619a2bc37c6d80743d18abad16ee09/uvloop-0.22.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702", size = 3804529, upload-time = "2025-10-16T22:16:25.246Z" }, + { url = "https://files.pythonhosted.org/packages/7f/74/03c05ae4737e871923d21a76fe28b6aad57f5c03b6e6bfcfa5ad616013e4/uvloop-0.22.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733", size = 3621267, upload-time = "2025-10-16T22:16:26.819Z" }, + { url = "https://files.pythonhosted.org/packages/75/be/f8e590fe61d18b4a92070905497aec4c0e64ae1761498cad09023f3f4b3e/uvloop-0.22.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473", size = 3723105, upload-time = "2025-10-16T22:16:28.252Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/f8/2c5f479fb531ce2f0564eda479faecf253d886b1ab3630a39b7bf7362d46/watchfiles-1.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5", size = 406529, upload-time = "2025-10-14T15:04:32.899Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cd/f515660b1f32f65df671ddf6f85bfaca621aee177712874dc30a97397977/watchfiles-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741", size = 394384, upload-time = "2025-10-14T15:04:33.761Z" }, + { url = "https://files.pythonhosted.org/packages/7b/c3/28b7dc99733eab43fca2d10f55c86e03bd6ab11ca31b802abac26b23d161/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6", size = 448789, upload-time = "2025-10-14T15:04:34.679Z" }, + { url = "https://files.pythonhosted.org/packages/4a/24/33e71113b320030011c8e4316ccca04194bf0cbbaeee207f00cbc7d6b9f5/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b", size = 460521, upload-time = "2025-10-14T15:04:35.963Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c3/3c9a55f255aa57b91579ae9e98c88704955fa9dac3e5614fb378291155df/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14", size = 488722, upload-time = "2025-10-14T15:04:37.091Z" }, + { url = "https://files.pythonhosted.org/packages/49/36/506447b73eb46c120169dc1717fe2eff07c234bb3232a7200b5f5bd816e9/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d", size = 596088, upload-time = "2025-10-14T15:04:38.39Z" }, + { url = "https://files.pythonhosted.org/packages/82/ab/5f39e752a9838ec4d52e9b87c1e80f1ee3ccdbe92e183c15b6577ab9de16/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff", size = 472923, upload-time = "2025-10-14T15:04:39.666Z" }, + { url = "https://files.pythonhosted.org/packages/af/b9/a419292f05e302dea372fa7e6fda5178a92998411f8581b9830d28fb9edb/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606", size = 456080, upload-time = "2025-10-14T15:04:40.643Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c3/d5932fd62bde1a30c36e10c409dc5d54506726f08cb3e1d8d0ba5e2bc8db/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701", size = 629432, upload-time = "2025-10-14T15:04:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/f7/77/16bddd9779fafb795f1a94319dc965209c5641db5bf1edbbccace6d1b3c0/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10", size = 623046, upload-time = "2025-10-14T15:04:42.718Z" }, + { url = "https://files.pythonhosted.org/packages/46/ef/f2ecb9a0f342b4bfad13a2787155c6ee7ce792140eac63a34676a2feeef2/watchfiles-1.1.1-cp311-cp311-win32.whl", hash = "sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849", size = 271473, upload-time = "2025-10-14T15:04:43.624Z" }, + { url = "https://files.pythonhosted.org/packages/94/bc/f42d71125f19731ea435c3948cad148d31a64fccde3867e5ba4edee901f9/watchfiles-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4", size = 287598, upload-time = "2025-10-14T15:04:44.516Z" }, + { url = "https://files.pythonhosted.org/packages/57/c9/a30f897351f95bbbfb6abcadafbaca711ce1162f4db95fc908c98a9165f3/watchfiles-1.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e", size = 277210, upload-time = "2025-10-14T15:04:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" }, + { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" }, + { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" }, + { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" }, + { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" }, + { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" }, + { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, + { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, + { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, + { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, + { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, + { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, + { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, + { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, + { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, + { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, + { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, + { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, + { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, + { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, + { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, + { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, + { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" }, + { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, + { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, + { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, + { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, + { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, + { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, + { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8e/e500f8b0b77be4ff753ac94dc06b33d8f0d839377fee1b78e8c8d8f031bf/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88", size = 408250, upload-time = "2025-10-14T15:06:10.264Z" }, + { url = "https://files.pythonhosted.org/packages/bd/95/615e72cd27b85b61eec764a5ca51bd94d40b5adea5ff47567d9ebc4d275a/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336", size = 396117, upload-time = "2025-10-14T15:06:11.28Z" }, + { url = "https://files.pythonhosted.org/packages/c9/81/e7fe958ce8a7fb5c73cc9fb07f5aeaf755e6aa72498c57d760af760c91f8/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24", size = 450493, upload-time = "2025-10-14T15:06:12.321Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", size = 457546, upload-time = "2025-10-14T15:06:13.372Z" }, +] + +[[package]] +name = "websockets" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" }, + { url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" }, + { url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" }, + { url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" }, + { url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" }, + { url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" }, + { url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" }, + { url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" }, + { url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, +] + +[[package]] +name = "yarl" +version = "1.23.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/aa/60da938b8f0997ba3a911263c40d82b6f645a67902a490b46f3355e10fae/yarl-1.23.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b35d13d549077713e4414f927cdc388d62e543987c572baee613bf82f11a4b99", size = 123641, upload-time = "2026-03-01T22:04:42.841Z" }, + { url = "https://files.pythonhosted.org/packages/24/84/e237607faf4e099dbb8a4f511cfd5efcb5f75918baad200ff7380635631b/yarl-1.23.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cbb0fef01f0c6b38cb0f39b1f78fc90b807e0e3c86a7ff3ce74ad77ce5c7880c", size = 86248, upload-time = "2026-03-01T22:04:44.757Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0d/71ceabc14c146ba8ee3804ca7b3d42b1664c8440439de5214d366fec7d3a/yarl-1.23.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc52310451fc7c629e13c4e061cbe2dd01684d91f2f8ee2821b083c58bd72432", size = 85988, upload-time = "2026-03-01T22:04:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/8c/6c/4a90d59c572e46b270ca132aca66954f1175abd691f74c1ef4c6711828e2/yarl-1.23.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2c6b50c7b0464165472b56b42d4c76a7b864597007d9c085e8b63e185cf4a7a", size = 100566, upload-time = "2026-03-01T22:04:47.639Z" }, + { url = "https://files.pythonhosted.org/packages/49/fb/c438fb5108047e629f6282a371e6e91cf3f97ee087c4fb748a1f32ceef55/yarl-1.23.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:aafe5dcfda86c8af00386d7781d4c2181b5011b7be3f2add5e99899ea925df05", size = 92079, upload-time = "2026-03-01T22:04:48.925Z" }, + { url = "https://files.pythonhosted.org/packages/d9/13/d269aa1aed3e4f50a5a103f96327210cc5fa5dd2d50882778f13c7a14606/yarl-1.23.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9ee33b875f0b390564c1fb7bc528abf18c8ee6073b201c6ae8524aca778e2d83", size = 108741, upload-time = "2026-03-01T22:04:50.838Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/115b16f22c37ea4437d323e472945bea97301c8ec6089868fa560abab590/yarl-1.23.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4c41e021bc6d7affb3364dc1e1e5fa9582b470f283748784bd6ea0558f87f42c", size = 108099, upload-time = "2026-03-01T22:04:52.499Z" }, + { url = "https://files.pythonhosted.org/packages/9a/64/c53487d9f4968045b8afa51aed7ca44f58b2589e772f32745f3744476c82/yarl-1.23.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99c8a9ed30f4164bc4c14b37a90208836cbf50d4ce2a57c71d0f52c7fb4f7598", size = 102678, upload-time = "2026-03-01T22:04:55.176Z" }, + { url = "https://files.pythonhosted.org/packages/85/59/cd98e556fbb2bf8fab29c1a722f67ad45c5f3447cac798ab85620d1e70af/yarl-1.23.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2af5c81a1f124609d5f33507082fc3f739959d4719b56877ab1ee7e7b3d602b", size = 100803, upload-time = "2026-03-01T22:04:56.588Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c0/b39770b56d4a9f0bb5f77e2f1763cd2d75cc2f6c0131e3b4c360348fcd65/yarl-1.23.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6b41389c19b07c760c7e427a3462e8ab83c4bb087d127f0e854c706ce1b9215c", size = 100163, upload-time = "2026-03-01T22:04:58.492Z" }, + { url = "https://files.pythonhosted.org/packages/e7/64/6980f99ab00e1f0ff67cb84766c93d595b067eed07439cfccfc8fb28c1a6/yarl-1.23.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:1dc702e42d0684f42d6519c8d581e49c96cefaaab16691f03566d30658ee8788", size = 93859, upload-time = "2026-03-01T22:05:00.268Z" }, + { url = "https://files.pythonhosted.org/packages/38/69/912e6c5e146793e5d4b5fe39ff5b00f4d22463dfd5a162bec565ac757673/yarl-1.23.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0e40111274f340d32ebcc0a5668d54d2b552a6cca84c9475859d364b380e3222", size = 108202, upload-time = "2026-03-01T22:05:02.273Z" }, + { url = "https://files.pythonhosted.org/packages/59/97/35ca6767524687ad64e5f5c31ad54bc76d585585a9fcb40f649e7e82ffed/yarl-1.23.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:4764a6a7588561a9aef92f65bda2c4fb58fe7c675c0883862e6df97559de0bfb", size = 99866, upload-time = "2026-03-01T22:05:03.597Z" }, + { url = "https://files.pythonhosted.org/packages/d3/1c/1a3387ee6d73589f6f2a220ae06f2984f6c20b40c734989b0a44f5987308/yarl-1.23.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:03214408cfa590df47728b84c679ae4ef00be2428e11630277be0727eba2d7cc", size = 107852, upload-time = "2026-03-01T22:05:04.986Z" }, + { url = "https://files.pythonhosted.org/packages/a4/b8/35c0750fcd5a3f781058bfd954515dd4b1eab45e218cbb85cf11132215f1/yarl-1.23.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:170e26584b060879e29fac213e4228ef063f39128723807a312e5c7fec28eff2", size = 102919, upload-time = "2026-03-01T22:05:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1c/9a1979aec4a81896d597bcb2177827f2dbee3f5b7cc48b2d0dadb644b41d/yarl-1.23.0-cp311-cp311-win32.whl", hash = "sha256:51430653db848d258336cfa0244427b17d12db63d42603a55f0d4546f50f25b5", size = 82602, upload-time = "2026-03-01T22:05:08.444Z" }, + { url = "https://files.pythonhosted.org/packages/93/22/b85eca6fa2ad9491af48c973e4c8cf6b103a73dbb271fe3346949449fca0/yarl-1.23.0-cp311-cp311-win_amd64.whl", hash = "sha256:bf49a3ae946a87083ef3a34c8f677ae4243f5b824bfc4c69672e72b3d6719d46", size = 87461, upload-time = "2026-03-01T22:05:10.145Z" }, + { url = "https://files.pythonhosted.org/packages/93/95/07e3553fe6f113e6864a20bdc53a78113cda3b9ced8784ee52a52c9f80d8/yarl-1.23.0-cp311-cp311-win_arm64.whl", hash = "sha256:b39cb32a6582750b6cc77bfb3c49c0f8760dc18dc96ec9fb55fbb0f04e08b928", size = 82336, upload-time = "2026-03-01T22:05:11.554Z" }, + { url = "https://files.pythonhosted.org/packages/88/8a/94615bc31022f711add374097ad4144d569e95ff3c38d39215d07ac153a0/yarl-1.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1932b6b8bba8d0160a9d1078aae5838a66039e8832d41d2992daa9a3a08f7860", size = 124737, upload-time = "2026-03-01T22:05:12.897Z" }, + { url = "https://files.pythonhosted.org/packages/e3/6f/c6554045d59d64052698add01226bc867b52fe4a12373415d7991fdca95d/yarl-1.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:411225bae281f114067578891bc75534cfb3d92a3b4dfef7a6ca78ba354e6069", size = 87029, upload-time = "2026-03-01T22:05:14.376Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/725ecc166d53438bc88f76822ed4b1e3b10756e790bafd7b523fe97c322d/yarl-1.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:13a563739ae600a631c36ce096615fe307f131344588b0bc0daec108cdb47b25", size = 86310, upload-time = "2026-03-01T22:05:15.71Z" }, + { url = "https://files.pythonhosted.org/packages/99/30/58260ed98e6ff7f90ba84442c1ddd758c9170d70327394a6227b310cd60f/yarl-1.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cbf44c5cb4a7633d078788e1b56387e3d3cf2b8139a3be38040b22d6c3221c8", size = 97587, upload-time = "2026-03-01T22:05:17.384Z" }, + { url = "https://files.pythonhosted.org/packages/76/0a/8b08aac08b50682e65759f7f8dde98ae8168f72487e7357a5d684c581ef9/yarl-1.23.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53ad387048f6f09a8969631e4de3f1bf70c50e93545d64af4f751b2498755072", size = 92528, upload-time = "2026-03-01T22:05:18.804Z" }, + { url = "https://files.pythonhosted.org/packages/52/07/0b7179101fe5f8385ec6c6bb5d0cb9f76bd9fb4a769591ab6fb5cdbfc69a/yarl-1.23.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4a59ba56f340334766f3a4442e0efd0af895fae9e2b204741ef885c446b3a1a8", size = 105339, upload-time = "2026-03-01T22:05:20.235Z" }, + { url = "https://files.pythonhosted.org/packages/d3/8a/36d82869ab5ec829ca8574dfcb92b51286fcfb1e9c7a73659616362dc880/yarl-1.23.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:803a3c3ce4acc62eaf01eaca1208dcf0783025ef27572c3336502b9c232005e7", size = 105061, upload-time = "2026-03-01T22:05:22.268Z" }, + { url = "https://files.pythonhosted.org/packages/66/3e/868e5c3364b6cee19ff3e1a122194fa4ce51def02c61023970442162859e/yarl-1.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3d2bff8f37f8d0f96c7ec554d16945050d54462d6e95414babaa18bfafc7f51", size = 100132, upload-time = "2026-03-01T22:05:23.638Z" }, + { url = "https://files.pythonhosted.org/packages/cf/26/9c89acf82f08a52cb52d6d39454f8d18af15f9d386a23795389d1d423823/yarl-1.23.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c75eb09e8d55bceb4367e83496ff8ef2bc7ea6960efb38e978e8073ea59ecb67", size = 99289, upload-time = "2026-03-01T22:05:25.749Z" }, + { url = "https://files.pythonhosted.org/packages/6f/54/5b0db00d2cb056922356104468019c0a132e89c8d3ab67d8ede9f4483d2a/yarl-1.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877b0738624280e34c55680d6054a307aa94f7d52fa0e3034a9cc6e790871da7", size = 96950, upload-time = "2026-03-01T22:05:27.318Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/10fa93811fd439341fad7e0718a86aca0de9548023bbb403668d6555acab/yarl-1.23.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b5405bb8f0e783a988172993cfc627e4d9d00432d6bbac65a923041edacf997d", size = 93960, upload-time = "2026-03-01T22:05:28.738Z" }, + { url = "https://files.pythonhosted.org/packages/bc/d2/8ae2e6cd77d0805f4526e30ec43b6f9a3dfc542d401ac4990d178e4bf0cf/yarl-1.23.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c3a3598a832590c5a3ce56ab5576361b5688c12cb1d39429cf5dba30b510760", size = 104703, upload-time = "2026-03-01T22:05:30.438Z" }, + { url = "https://files.pythonhosted.org/packages/2f/0c/b3ceacf82c3fe21183ce35fa2acf5320af003d52bc1fcf5915077681142e/yarl-1.23.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8419ebd326430d1cbb7efb5292330a2cf39114e82df5cc3d83c9a0d5ebeaf2f2", size = 98325, upload-time = "2026-03-01T22:05:31.835Z" }, + { url = "https://files.pythonhosted.org/packages/9d/e0/12900edd28bdab91a69bd2554b85ad7b151f64e8b521fe16f9ad2f56477a/yarl-1.23.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:be61f6fff406ca40e3b1d84716fde398fc08bc63dd96d15f3a14230a0973ed86", size = 105067, upload-time = "2026-03-01T22:05:33.358Z" }, + { url = "https://files.pythonhosted.org/packages/15/61/74bb1182cf79c9bbe4eb6b1f14a57a22d7a0be5e9cedf8e2d5c2086474c3/yarl-1.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ceb13c5c858d01321b5d9bb65e4cf37a92169ea470b70fec6f236b2c9dd7e34", size = 100285, upload-time = "2026-03-01T22:05:35.4Z" }, + { url = "https://files.pythonhosted.org/packages/69/7f/cd5ef733f2550de6241bd8bd8c3febc78158b9d75f197d9c7baa113436af/yarl-1.23.0-cp312-cp312-win32.whl", hash = "sha256:fffc45637bcd6538de8b85f51e3df3223e4ad89bccbfca0481c08c7fc8b7ed7d", size = 82359, upload-time = "2026-03-01T22:05:36.811Z" }, + { url = "https://files.pythonhosted.org/packages/f5/be/25216a49daeeb7af2bec0db22d5e7df08ed1d7c9f65d78b14f3b74fd72fc/yarl-1.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:f69f57305656a4852f2a7203efc661d8c042e6cc67f7acd97d8667fb448a426e", size = 87674, upload-time = "2026-03-01T22:05:38.171Z" }, + { url = "https://files.pythonhosted.org/packages/d2/35/aeab955d6c425b227d5b7247eafb24f2653fedc32f95373a001af5dfeb9e/yarl-1.23.0-cp312-cp312-win_arm64.whl", hash = "sha256:6e87a6e8735b44816e7db0b2fbc9686932df473c826b0d9743148432e10bb9b9", size = 81879, upload-time = "2026-03-01T22:05:40.006Z" }, + { url = "https://files.pythonhosted.org/packages/9a/4b/a0a6e5d0ee8a2f3a373ddef8a4097d74ac901ac363eea1440464ccbe0898/yarl-1.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:16c6994ac35c3e74fb0ae93323bf8b9c2a9088d55946109489667c510a7d010e", size = 123796, upload-time = "2026-03-01T22:05:41.412Z" }, + { url = "https://files.pythonhosted.org/packages/67/b6/8925d68af039b835ae876db5838e82e76ec87b9782ecc97e192b809c4831/yarl-1.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4a42e651629dafb64fd5b0286a3580613702b5809ad3f24934ea87595804f2c5", size = 86547, upload-time = "2026-03-01T22:05:42.841Z" }, + { url = "https://files.pythonhosted.org/packages/ae/50/06d511cc4b8e0360d3c94af051a768e84b755c5eb031b12adaaab6dec6e5/yarl-1.23.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c6b9461a2a8b47c65eef63bb1c76a4f1c119618ffa99ea79bc5bb1e46c5821b", size = 85854, upload-time = "2026-03-01T22:05:44.85Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f4/4e30b250927ffdab4db70da08b9b8d2194d7c7b400167b8fbeca1e4701ca/yarl-1.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2569b67d616eab450d262ca7cb9f9e19d2f718c70a8b88712859359d0ab17035", size = 98351, upload-time = "2026-03-01T22:05:46.836Z" }, + { url = "https://files.pythonhosted.org/packages/86/fc/4118c5671ea948208bdb1492d8b76bdf1453d3e73df051f939f563e7dcc5/yarl-1.23.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e9d9a4d06d3481eab79803beb4d9bd6f6a8e781ec078ac70d7ef2dcc29d1bea5", size = 92711, upload-time = "2026-03-01T22:05:48.316Z" }, + { url = "https://files.pythonhosted.org/packages/56/11/1ed91d42bd9e73c13dc9e7eb0dd92298d75e7ac4dd7f046ad0c472e231cd/yarl-1.23.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f514f6474e04179d3d33175ed3f3e31434d3130d42ec153540d5b157deefd735", size = 106014, upload-time = "2026-03-01T22:05:50.028Z" }, + { url = "https://files.pythonhosted.org/packages/ce/c9/74e44e056a23fbc33aca71779ef450ca648a5bc472bdad7a82339918f818/yarl-1.23.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fda207c815b253e34f7e1909840fd14299567b1c0eb4908f8c2ce01a41265401", size = 105557, upload-time = "2026-03-01T22:05:51.416Z" }, + { url = "https://files.pythonhosted.org/packages/66/fe/b1e10b08d287f518994f1e2ff9b6d26f0adeecd8dd7d533b01bab29a3eda/yarl-1.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34b6cf500e61c90f305094911f9acc9c86da1a05a7a3f5be9f68817043f486e4", size = 101559, upload-time = "2026-03-01T22:05:52.872Z" }, + { url = "https://files.pythonhosted.org/packages/72/59/c5b8d94b14e3d3c2a9c20cb100119fd534ab5a14b93673ab4cc4a4141ea5/yarl-1.23.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d7504f2b476d21653e4d143f44a175f7f751cd41233525312696c76aa3dbb23f", size = 100502, upload-time = "2026-03-01T22:05:54.954Z" }, + { url = "https://files.pythonhosted.org/packages/77/4f/96976cb54cbfc5c9fd73ed4c51804f92f209481d1fb190981c0f8a07a1d7/yarl-1.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:578110dd426f0d209d1509244e6d4a3f1a3e9077655d98c5f22583d63252a08a", size = 98027, upload-time = "2026-03-01T22:05:56.409Z" }, + { url = "https://files.pythonhosted.org/packages/63/6e/904c4f476471afdbad6b7e5b70362fb5810e35cd7466529a97322b6f5556/yarl-1.23.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:609d3614d78d74ebe35f54953c5bbd2ac647a7ddb9c30a5d877580f5e86b22f2", size = 95369, upload-time = "2026-03-01T22:05:58.141Z" }, + { url = "https://files.pythonhosted.org/packages/9d/40/acfcdb3b5f9d68ef499e39e04d25e141fe90661f9d54114556cf83be8353/yarl-1.23.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4966242ec68afc74c122f8459abd597afd7d8a60dc93d695c1334c5fd25f762f", size = 105565, upload-time = "2026-03-01T22:06:00.286Z" }, + { url = "https://files.pythonhosted.org/packages/5e/c6/31e28f3a6ba2869c43d124f37ea5260cac9c9281df803c354b31f4dd1f3c/yarl-1.23.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e0fd068364a6759bc794459f0a735ab151d11304346332489c7972bacbe9e72b", size = 99813, upload-time = "2026-03-01T22:06:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/08/1f/6f65f59e72d54aa467119b63fc0b0b1762eff0232db1f4720cd89e2f4a17/yarl-1.23.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:39004f0ad156da43e86aa71f44e033de68a44e5a31fc53507b36dd253970054a", size = 105632, upload-time = "2026-03-01T22:06:03.188Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c4/18b178a69935f9e7a338127d5b77d868fdc0f0e49becd286d51b3a18c61d/yarl-1.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e5723c01a56c5028c807c701aa66722916d2747ad737a046853f6c46f4875543", size = 101895, upload-time = "2026-03-01T22:06:04.651Z" }, + { url = "https://files.pythonhosted.org/packages/8f/54/f5b870b5505663911dba950a8e4776a0dbd51c9c54c0ae88e823e4b874a0/yarl-1.23.0-cp313-cp313-win32.whl", hash = "sha256:1b6b572edd95b4fa8df75de10b04bc81acc87c1c7d16bcdd2035b09d30acc957", size = 82356, upload-time = "2026-03-01T22:06:06.04Z" }, + { url = "https://files.pythonhosted.org/packages/7a/84/266e8da36879c6edcd37b02b547e2d9ecdfea776be49598e75696e3316e1/yarl-1.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:baaf55442359053c7d62f6f8413a62adba3205119bcb6f49594894d8be47e5e3", size = 87515, upload-time = "2026-03-01T22:06:08.107Z" }, + { url = "https://files.pythonhosted.org/packages/00/fd/7e1c66efad35e1649114fa13f17485f62881ad58edeeb7f49f8c5e748bf9/yarl-1.23.0-cp313-cp313-win_arm64.whl", hash = "sha256:fb4948814a2a98e3912505f09c9e7493b1506226afb1f881825368d6fb776ee3", size = 81785, upload-time = "2026-03-01T22:06:10.181Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fc/119dd07004f17ea43bb91e3ece6587759edd7519d6b086d16bfbd3319982/yarl-1.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:aecfed0b41aa72b7881712c65cf764e39ce2ec352324f5e0837c7048d9e6daaa", size = 130719, upload-time = "2026-03-01T22:06:11.708Z" }, + { url = "https://files.pythonhosted.org/packages/e6/0d/9f2348502fbb3af409e8f47730282cd6bc80dec6630c1e06374d882d6eb2/yarl-1.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a41bcf68efd19073376eb8cf948b8d9be0af26256403e512bb18f3966f1f9120", size = 89690, upload-time = "2026-03-01T22:06:13.429Z" }, + { url = "https://files.pythonhosted.org/packages/50/93/e88f3c80971b42cfc83f50a51b9d165a1dbf154b97005f2994a79f212a07/yarl-1.23.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cde9a2ecd91668bcb7f077c4966d8ceddb60af01b52e6e3e2680e4cf00ad1a59", size = 89851, upload-time = "2026-03-01T22:06:15.53Z" }, + { url = "https://files.pythonhosted.org/packages/1c/07/61c9dd8ba8f86473263b4036f70fb594c09e99c0d9737a799dfd8bc85651/yarl-1.23.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5023346c4ee7992febc0068e7593de5fa2bf611848c08404b35ebbb76b1b0512", size = 95874, upload-time = "2026-03-01T22:06:17.553Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e9/f9ff8ceefba599eac6abddcfb0b3bee9b9e636e96dbf54342a8577252379/yarl-1.23.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1009abedb49ae95b136a8904a3f71b342f849ffeced2d3747bf29caeda218c4", size = 88710, upload-time = "2026-03-01T22:06:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/eb/78/0231bfcc5d4c8eec220bc2f9ef82cb4566192ea867a7c5b4148f44f6cbcd/yarl-1.23.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a8d00f29b42f534cc8aa3931cfe773b13b23e561e10d2b26f27a8d309b0e82a1", size = 101033, upload-time = "2026-03-01T22:06:21.203Z" }, + { url = "https://files.pythonhosted.org/packages/cd/9b/30ea5239a61786f18fd25797151a17fbb3be176977187a48d541b5447dd4/yarl-1.23.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:95451e6ce06c3e104556d73b559f5da6c34a069b6b62946d3ad66afcd51642ea", size = 100817, upload-time = "2026-03-01T22:06:22.738Z" }, + { url = "https://files.pythonhosted.org/packages/62/e2/a4980481071791bc83bce2b7a1a1f7adcabfa366007518b4b845e92eeee3/yarl-1.23.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:531ef597132086b6cf96faa7c6c1dcd0361dd5f1694e5cc30375907b9b7d3ea9", size = 97482, upload-time = "2026-03-01T22:06:24.21Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1e/304a00cf5f6100414c4b5a01fc7ff9ee724b62158a08df2f8170dfc72a2d/yarl-1.23.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88f9fb0116fbfcefcab70f85cf4b74a2b6ce5d199c41345296f49d974ddb4123", size = 95949, upload-time = "2026-03-01T22:06:25.697Z" }, + { url = "https://files.pythonhosted.org/packages/68/03/093f4055ed4cae649ac53bca3d180bd37102e9e11d048588e9ab0c0108d0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e7b0460976dc75cb87ad9cc1f9899a4b97751e7d4e77ab840fc9b6d377b8fd24", size = 95839, upload-time = "2026-03-01T22:06:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/b9/28/4c75ebb108f322aa8f917ae10a8ffa4f07cae10a8a627b64e578617df6a0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:115136c4a426f9da976187d238e84139ff6b51a20839aa6e3720cd1026d768de", size = 90696, upload-time = "2026-03-01T22:06:29.048Z" }, + { url = "https://files.pythonhosted.org/packages/23/9c/42c2e2dd91c1a570402f51bdf066bfdb1241c2240ba001967bad778e77b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:ead11956716a940c1abc816b7df3fa2b84d06eaed8832ca32f5c5e058c65506b", size = 100865, upload-time = "2026-03-01T22:06:30.525Z" }, + { url = "https://files.pythonhosted.org/packages/74/05/1bcd60a8a0a914d462c305137246b6f9d167628d73568505fce3f1cb2e65/yarl-1.23.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:fe8f8f5e70e6dbdfca9882cd9deaac058729bcf323cf7a58660901e55c9c94f6", size = 96234, upload-time = "2026-03-01T22:06:32.692Z" }, + { url = "https://files.pythonhosted.org/packages/90/b2/f52381aac396d6778ce516b7bc149c79e65bfc068b5de2857ab69eeea3b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:a0e317df055958a0c1e79e5d2aa5a5eaa4a6d05a20d4b0c9c3f48918139c9fc6", size = 100295, upload-time = "2026-03-01T22:06:34.268Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/638bae5bbf1113a659b2435d8895474598afe38b4a837103764f603aba56/yarl-1.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f0fd84de0c957b2d280143522c4f91a73aada1923caee763e24a2b3fda9f8a5", size = 97784, upload-time = "2026-03-01T22:06:35.864Z" }, + { url = "https://files.pythonhosted.org/packages/80/25/a3892b46182c586c202629fc2159aa13975d3741d52ebd7347fd501d48d5/yarl-1.23.0-cp313-cp313t-win32.whl", hash = "sha256:93a784271881035ab4406a172edb0faecb6e7d00f4b53dc2f55919d6c9688595", size = 88313, upload-time = "2026-03-01T22:06:37.39Z" }, + { url = "https://files.pythonhosted.org/packages/43/68/8c5b36aa5178900b37387937bc2c2fe0e9505537f713495472dcf6f6fccc/yarl-1.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dd00607bffbf30250fe108065f07453ec124dbf223420f57f5e749b04295e090", size = 94932, upload-time = "2026-03-01T22:06:39.579Z" }, + { url = "https://files.pythonhosted.org/packages/c6/cc/d79ba8292f51f81f4dc533a8ccfb9fc6992cabf0998ed3245de7589dc07c/yarl-1.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ac09d42f48f80c9ee1635b2fcaa819496a44502737660d3c0f2ade7526d29144", size = 84786, upload-time = "2026-03-01T22:06:41.988Z" }, + { url = "https://files.pythonhosted.org/packages/90/98/b85a038d65d1b92c3903ab89444f48d3cee490a883477b716d7a24b1a78c/yarl-1.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:21d1b7305a71a15b4794b5ff22e8eef96ff4a6d7f9657155e5aa419444b28912", size = 124455, upload-time = "2026-03-01T22:06:43.615Z" }, + { url = "https://files.pythonhosted.org/packages/39/54/bc2b45559f86543d163b6e294417a107bb87557609007c007ad889afec18/yarl-1.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:85610b4f27f69984932a7abbe52703688de3724d9f72bceb1cca667deff27474", size = 86752, upload-time = "2026-03-01T22:06:45.425Z" }, + { url = "https://files.pythonhosted.org/packages/24/f9/e8242b68362bffe6fb536c8db5076861466fc780f0f1b479fc4ffbebb128/yarl-1.23.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23f371bd662cf44a7630d4d113101eafc0cfa7518a2760d20760b26021454719", size = 86291, upload-time = "2026-03-01T22:06:46.974Z" }, + { url = "https://files.pythonhosted.org/packages/ea/d8/d1cb2378c81dd729e98c716582b1ccb08357e8488e4c24714658cc6630e8/yarl-1.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a80f77dc1acaaa61f0934176fccca7096d9b1ff08c8ba9cddf5ae034a24319", size = 99026, upload-time = "2026-03-01T22:06:48.459Z" }, + { url = "https://files.pythonhosted.org/packages/0a/ff/7196790538f31debe3341283b5b0707e7feb947620fc5e8236ef28d44f72/yarl-1.23.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd654fad46d8d9e823afbb4f87c79160b5a374ed1ff5bde24e542e6ba8f41434", size = 92355, upload-time = "2026-03-01T22:06:50.306Z" }, + { url = "https://files.pythonhosted.org/packages/c1/56/25d58c3eddde825890a5fe6aa1866228377354a3c39262235234ab5f616b/yarl-1.23.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:682bae25f0a0dd23a056739f23a134db9f52a63e2afd6bfb37ddc76292bbd723", size = 106417, upload-time = "2026-03-01T22:06:52.1Z" }, + { url = "https://files.pythonhosted.org/packages/51/8a/882c0e7bc8277eb895b31bce0138f51a1ba551fc2e1ec6753ffc1e7c1377/yarl-1.23.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a82836cab5f197a0514235aaf7ffccdc886ccdaa2324bc0aafdd4ae898103039", size = 106422, upload-time = "2026-03-01T22:06:54.424Z" }, + { url = "https://files.pythonhosted.org/packages/42/2b/fef67d616931055bf3d6764885990a3ac647d68734a2d6a9e1d13de437a2/yarl-1.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c57676bdedc94cd3bc37724cf6f8cd2779f02f6aba48de45feca073e714fe52", size = 101915, upload-time = "2026-03-01T22:06:55.895Z" }, + { url = "https://files.pythonhosted.org/packages/18/6a/530e16aebce27c5937920f3431c628a29a4b6b430fab3fd1c117b26ff3f6/yarl-1.23.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7f8dc16c498ff06497c015642333219871effba93e4a2e8604a06264aca5c5c", size = 100690, upload-time = "2026-03-01T22:06:58.21Z" }, + { url = "https://files.pythonhosted.org/packages/88/08/93749219179a45e27b036e03260fda05190b911de8e18225c294ac95bbc9/yarl-1.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5ee586fb17ff8f90c91cf73c6108a434b02d69925f44f5f8e0d7f2f260607eae", size = 98750, upload-time = "2026-03-01T22:06:59.794Z" }, + { url = "https://files.pythonhosted.org/packages/d9/cf/ea424a004969f5d81a362110a6ac1496d79efdc6d50c2c4b2e3ea0fc2519/yarl-1.23.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:17235362f580149742739cc3828b80e24029d08cbb9c4bda0242c7b5bc610a8e", size = 94685, upload-time = "2026-03-01T22:07:01.375Z" }, + { url = "https://files.pythonhosted.org/packages/e2/b7/14341481fe568e2b0408bcf1484c652accafe06a0ade9387b5d3fd9df446/yarl-1.23.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0793e2bd0cf14234983bbb371591e6bea9e876ddf6896cdcc93450996b0b5c85", size = 106009, upload-time = "2026-03-01T22:07:03.151Z" }, + { url = "https://files.pythonhosted.org/packages/0a/e6/5c744a9b54f4e8007ad35bce96fbc9218338e84812d36f3390cea616881a/yarl-1.23.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3650dc2480f94f7116c364096bc84b1d602f44224ef7d5c7208425915c0475dd", size = 100033, upload-time = "2026-03-01T22:07:04.701Z" }, + { url = "https://files.pythonhosted.org/packages/0c/23/e3bfc188d0b400f025bc49d99793d02c9abe15752138dcc27e4eaf0c4a9e/yarl-1.23.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f40e782d49630ad384db66d4d8b73ff4f1b8955dc12e26b09a3e3af064b3b9d6", size = 106483, upload-time = "2026-03-01T22:07:06.231Z" }, + { url = "https://files.pythonhosted.org/packages/72/42/f0505f949a90b3f8b7a363d6cbdf398f6e6c58946d85c6d3a3bc70595b26/yarl-1.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94f8575fbdf81749008d980c17796097e645574a3b8c28ee313931068dad14fe", size = 102175, upload-time = "2026-03-01T22:07:08.4Z" }, + { url = "https://files.pythonhosted.org/packages/aa/65/b39290f1d892a9dd671d1c722014ca062a9c35d60885d57e5375db0404b5/yarl-1.23.0-cp314-cp314-win32.whl", hash = "sha256:c8aa34a5c864db1087d911a0b902d60d203ea3607d91f615acd3f3108ac32169", size = 83871, upload-time = "2026-03-01T22:07:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/a9/5b/9b92f54c784c26e2a422e55a8d2607ab15b7ea3349e28359282f84f01d43/yarl-1.23.0-cp314-cp314-win_amd64.whl", hash = "sha256:63e92247f383c85ab00dd0091e8c3fa331a96e865459f5ee80353c70a4a42d70", size = 89093, upload-time = "2026-03-01T22:07:11.501Z" }, + { url = "https://files.pythonhosted.org/packages/e0/7d/8a84dc9381fd4412d5e7ff04926f9865f6372b4c2fd91e10092e65d29eb8/yarl-1.23.0-cp314-cp314-win_arm64.whl", hash = "sha256:70efd20be968c76ece7baa8dafe04c5be06abc57f754d6f36f3741f7aa7a208e", size = 83384, upload-time = "2026-03-01T22:07:13.069Z" }, + { url = "https://files.pythonhosted.org/packages/dd/8d/d2fad34b1c08aa161b74394183daa7d800141aaaee207317e82c790b418d/yarl-1.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9a18d6f9359e45722c064c97464ec883eb0e0366d33eda61cb19a244bf222679", size = 131019, upload-time = "2026-03-01T22:07:14.903Z" }, + { url = "https://files.pythonhosted.org/packages/19/ff/33009a39d3ccf4b94d7d7880dfe17fb5816c5a4fe0096d9b56abceea9ac7/yarl-1.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2803ed8b21ca47a43da80a6fd1ed3019d30061f7061daa35ac54f63933409412", size = 89894, upload-time = "2026-03-01T22:07:17.372Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f1/dab7ac5e7306fb79c0190766a3c00b4cb8d09a1f390ded68c85a5934faf5/yarl-1.23.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:394906945aa8b19fc14a61cf69743a868bb8c465efe85eee687109cc540b98f4", size = 89979, upload-time = "2026-03-01T22:07:19.361Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b1/08e95f3caee1fad6e65017b9f26c1d79877b502622d60e517de01e72f95d/yarl-1.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71d006bee8397a4a89f469b8deb22469fe7508132d3c17fa6ed871e79832691c", size = 95943, upload-time = "2026-03-01T22:07:21.266Z" }, + { url = "https://files.pythonhosted.org/packages/c0/cc/6409f9018864a6aa186c61175b977131f373f1988e198e031236916e87e4/yarl-1.23.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:62694e275c93d54f7ccedcfef57d42761b2aad5234b6be1f3e3026cae4001cd4", size = 88786, upload-time = "2026-03-01T22:07:23.129Z" }, + { url = "https://files.pythonhosted.org/packages/76/40/cc22d1d7714b717fde2006fad2ced5efe5580606cb059ae42117542122f3/yarl-1.23.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31de1613658308efdb21ada98cbc86a97c181aa050ba22a808120bb5be3ab94", size = 101307, upload-time = "2026-03-01T22:07:24.689Z" }, + { url = "https://files.pythonhosted.org/packages/8f/0d/476c38e85ddb4c6ec6b20b815bdd779aa386a013f3d8b85516feee55c8dc/yarl-1.23.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb1e8b8d66c278b21d13b0a7ca22c41dd757a7c209c6b12c313e445c31dd3b28", size = 100904, upload-time = "2026-03-01T22:07:26.287Z" }, + { url = "https://files.pythonhosted.org/packages/72/32/0abe4a76d59adf2081dcb0397168553ece4616ada1c54d1c49d8936c74f8/yarl-1.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50f9d8d531dfb767c565f348f33dd5139a6c43f5cbdf3f67da40d54241df93f6", size = 97728, upload-time = "2026-03-01T22:07:27.906Z" }, + { url = "https://files.pythonhosted.org/packages/b7/35/7b30f4810fba112f60f5a43237545867504e15b1c7647a785fbaf588fac2/yarl-1.23.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575aa4405a656e61a540f4a80eaa5260f2a38fff7bfdc4b5f611840d76e9e277", size = 95964, upload-time = "2026-03-01T22:07:30.198Z" }, + { url = "https://files.pythonhosted.org/packages/2d/86/ed7a73ab85ef00e8bb70b0cb5421d8a2a625b81a333941a469a6f4022828/yarl-1.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:041b1a4cefacf65840b4e295c6985f334ba83c30607441ae3cf206a0eed1a2e4", size = 95882, upload-time = "2026-03-01T22:07:32.132Z" }, + { url = "https://files.pythonhosted.org/packages/19/90/d56967f61a29d8498efb7afb651e0b2b422a1e9b47b0ab5f4e40a19b699b/yarl-1.23.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d38c1e8231722c4ce40d7593f28d92b5fc72f3e9774fe73d7e800ec32299f63a", size = 90797, upload-time = "2026-03-01T22:07:34.404Z" }, + { url = "https://files.pythonhosted.org/packages/72/00/8b8f76909259f56647adb1011d7ed8b321bcf97e464515c65016a47ecdf0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d53834e23c015ee83a99377db6e5e37d8484f333edb03bd15b4bc312cc7254fb", size = 101023, upload-time = "2026-03-01T22:07:35.953Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e2/cab11b126fb7d440281b7df8e9ddbe4851e70a4dde47a202b6642586b8d9/yarl-1.23.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2e27c8841126e017dd2a054a95771569e6070b9ee1b133366d8b31beb5018a41", size = 96227, upload-time = "2026-03-01T22:07:37.594Z" }, + { url = "https://files.pythonhosted.org/packages/c2/9b/2c893e16bfc50e6b2edf76c1a9eb6cb0c744346197e74c65e99ad8d634d0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:76855800ac56f878847a09ce6dba727c93ca2d89c9e9d63002d26b916810b0a2", size = 100302, upload-time = "2026-03-01T22:07:39.334Z" }, + { url = "https://files.pythonhosted.org/packages/28/ec/5498c4e3a6d5f1003beb23405671c2eb9cdbf3067d1c80f15eeafe301010/yarl-1.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e09fd068c2e169a7070d83d3bde728a4d48de0549f975290be3c108c02e499b4", size = 98202, upload-time = "2026-03-01T22:07:41.717Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c3/cd737e2d45e70717907f83e146f6949f20cc23cd4bf7b2688727763aa458/yarl-1.23.0-cp314-cp314t-win32.whl", hash = "sha256:73309162a6a571d4cbd3b6a1dcc703c7311843ae0d1578df6f09be4e98df38d4", size = 90558, upload-time = "2026-03-01T22:07:43.433Z" }, + { url = "https://files.pythonhosted.org/packages/e1/19/3774d162f6732d1cfb0b47b4140a942a35ca82bb19b6db1f80e9e7bdc8f8/yarl-1.23.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4503053d296bc6e4cbd1fad61cf3b6e33b939886c4f249ba7c78b602214fabe2", size = 97610, upload-time = "2026-03-01T22:07:45.773Z" }, + { url = "https://files.pythonhosted.org/packages/51/47/3fa2286c3cb162c71cdb34c4224d5745a1ceceb391b2bd9b19b668a8d724/yarl-1.23.0-cp314-cp314t-win_arm64.whl", hash = "sha256:44bb7bef4ea409384e3f8bc36c063d77ea1b8d4a5b2706956c0d6695f07dcc25", size = 86041, upload-time = "2026-03-01T22:07:49.026Z" }, + { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" }, +] + +[[package]] +name = "zstandard" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/83/c3ca27c363d104980f1c9cee1101cc8ba724ac8c28a033ede6aab89585b1/zstandard-0.25.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:933b65d7680ea337180733cf9e87293cc5500cc0eb3fc8769f4d3c88d724ec5c", size = 795254, upload-time = "2025-09-14T22:16:26.137Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4d/e66465c5411a7cf4866aeadc7d108081d8ceba9bc7abe6b14aa21c671ec3/zstandard-0.25.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3f79487c687b1fc69f19e487cd949bf3aae653d181dfb5fde3bf6d18894706f", size = 640559, upload-time = "2025-09-14T22:16:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/12/56/354fe655905f290d3b147b33fe946b0f27e791e4b50a5f004c802cb3eb7b/zstandard-0.25.0-cp311-cp311-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:0bbc9a0c65ce0eea3c34a691e3c4b6889f5f3909ba4822ab385fab9057099431", size = 5348020, upload-time = "2025-09-14T22:16:29.523Z" }, + { url = "https://files.pythonhosted.org/packages/3b/13/2b7ed68bd85e69a2069bcc72141d378f22cae5a0f3b353a2c8f50ef30c1b/zstandard-0.25.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:01582723b3ccd6939ab7b3a78622c573799d5d8737b534b86d0e06ac18dbde4a", size = 5058126, upload-time = "2025-09-14T22:16:31.811Z" }, + { url = "https://files.pythonhosted.org/packages/c9/dd/fdaf0674f4b10d92cb120ccff58bbb6626bf8368f00ebfd2a41ba4a0dc99/zstandard-0.25.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5f1ad7bf88535edcf30038f6919abe087f606f62c00a87d7e33e7fc57cb69fcc", size = 5405390, upload-time = "2025-09-14T22:16:33.486Z" }, + { url = "https://files.pythonhosted.org/packages/0f/67/354d1555575bc2490435f90d67ca4dd65238ff2f119f30f72d5cde09c2ad/zstandard-0.25.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:06acb75eebeedb77b69048031282737717a63e71e4ae3f77cc0c3b9508320df6", size = 5452914, upload-time = "2025-09-14T22:16:35.277Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/e9cfd801a3f9190bf3e759c422bbfd2247db9d7f3d54a56ecde70137791a/zstandard-0.25.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9300d02ea7c6506f00e627e287e0492a5eb0371ec1670ae852fefffa6164b072", size = 5559635, upload-time = "2025-09-14T22:16:37.141Z" }, + { url = "https://files.pythonhosted.org/packages/21/88/5ba550f797ca953a52d708c8e4f380959e7e3280af029e38fbf47b55916e/zstandard-0.25.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bfd06b1c5584b657a2892a6014c2f4c20e0db0208c159148fa78c65f7e0b0277", size = 5048277, upload-time = "2025-09-14T22:16:38.807Z" }, + { url = "https://files.pythonhosted.org/packages/46/c0/ca3e533b4fa03112facbe7fbe7779cb1ebec215688e5df576fe5429172e0/zstandard-0.25.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f373da2c1757bb7f1acaf09369cdc1d51d84131e50d5fa9863982fd626466313", size = 5574377, upload-time = "2025-09-14T22:16:40.523Z" }, + { url = "https://files.pythonhosted.org/packages/12/9b/3fb626390113f272abd0799fd677ea33d5fc3ec185e62e6be534493c4b60/zstandard-0.25.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c0e5a65158a7946e7a7affa6418878ef97ab66636f13353b8502d7ea03c8097", size = 4961493, upload-time = "2025-09-14T22:16:43.3Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d3/23094a6b6a4b1343b27ae68249daa17ae0651fcfec9ed4de09d14b940285/zstandard-0.25.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c8e167d5adf59476fa3e37bee730890e389410c354771a62e3c076c86f9f7778", size = 5269018, upload-time = "2025-09-14T22:16:45.292Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a7/bb5a0c1c0f3f4b5e9d5b55198e39de91e04ba7c205cc46fcb0f95f0383c1/zstandard-0.25.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:98750a309eb2f020da61e727de7d7ba3c57c97cf6213f6f6277bb7fb42a8e065", size = 5443672, upload-time = "2025-09-14T22:16:47.076Z" }, + { url = "https://files.pythonhosted.org/packages/27/22/503347aa08d073993f25109c36c8d9f029c7d5949198050962cb568dfa5e/zstandard-0.25.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:22a086cff1b6ceca18a8dd6096ec631e430e93a8e70a9ca5efa7561a00f826fa", size = 5822753, upload-time = "2025-09-14T22:16:49.316Z" }, + { url = "https://files.pythonhosted.org/packages/e2/be/94267dc6ee64f0f8ba2b2ae7c7a2df934a816baaa7291db9e1aa77394c3c/zstandard-0.25.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:72d35d7aa0bba323965da807a462b0966c91608ef3a48ba761678cb20ce5d8b7", size = 5366047, upload-time = "2025-09-14T22:16:51.328Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a3/732893eab0a3a7aecff8b99052fecf9f605cf0fb5fb6d0290e36beee47a4/zstandard-0.25.0-cp311-cp311-win32.whl", hash = "sha256:f5aeea11ded7320a84dcdd62a3d95b5186834224a9e55b92ccae35d21a8b63d4", size = 436484, upload-time = "2025-09-14T22:16:55.005Z" }, + { url = "https://files.pythonhosted.org/packages/43/a3/c6155f5c1cce691cb80dfd38627046e50af3ee9ddc5d0b45b9b063bfb8c9/zstandard-0.25.0-cp311-cp311-win_amd64.whl", hash = "sha256:daab68faadb847063d0c56f361a289c4f268706b598afbf9ad113cbe5c38b6b2", size = 506183, upload-time = "2025-09-14T22:16:52.753Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3e/8945ab86a0820cc0e0cdbf38086a92868a9172020fdab8a03ac19662b0e5/zstandard-0.25.0-cp311-cp311-win_arm64.whl", hash = "sha256:22a06c5df3751bb7dc67406f5374734ccee8ed37fc5981bf1ad7041831fa1137", size = 462533, upload-time = "2025-09-14T22:16:53.878Z" }, + { url = "https://files.pythonhosted.org/packages/82/fc/f26eb6ef91ae723a03e16eddb198abcfce2bc5a42e224d44cc8b6765e57e/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", size = 795738, upload-time = "2025-09-14T22:16:56.237Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1c/d920d64b22f8dd028a8b90e2d756e431a5d86194caa78e3819c7bf53b4b3/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", size = 640436, upload-time = "2025-09-14T22:16:57.774Z" }, + { url = "https://files.pythonhosted.org/packages/53/6c/288c3f0bd9fcfe9ca41e2c2fbfd17b2097f6af57b62a81161941f09afa76/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", size = 5343019, upload-time = "2025-09-14T22:16:59.302Z" }, + { url = "https://files.pythonhosted.org/packages/1e/15/efef5a2f204a64bdb5571e6161d49f7ef0fffdbca953a615efbec045f60f/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", size = 5063012, upload-time = "2025-09-14T22:17:01.156Z" }, + { url = "https://files.pythonhosted.org/packages/b7/37/a6ce629ffdb43959e92e87ebdaeebb5ac81c944b6a75c9c47e300f85abdf/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", size = 5394148, upload-time = "2025-09-14T22:17:03.091Z" }, + { url = "https://files.pythonhosted.org/packages/e3/79/2bf870b3abeb5c070fe2d670a5a8d1057a8270f125ef7676d29ea900f496/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", size = 5451652, upload-time = "2025-09-14T22:17:04.979Z" }, + { url = "https://files.pythonhosted.org/packages/53/60/7be26e610767316c028a2cbedb9a3beabdbe33e2182c373f71a1c0b88f36/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", size = 5546993, upload-time = "2025-09-14T22:17:06.781Z" }, + { url = "https://files.pythonhosted.org/packages/85/c7/3483ad9ff0662623f3648479b0380d2de5510abf00990468c286c6b04017/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", size = 5046806, upload-time = "2025-09-14T22:17:08.415Z" }, + { url = "https://files.pythonhosted.org/packages/08/b3/206883dd25b8d1591a1caa44b54c2aad84badccf2f1de9e2d60a446f9a25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", size = 5576659, upload-time = "2025-09-14T22:17:10.164Z" }, + { url = "https://files.pythonhosted.org/packages/9d/31/76c0779101453e6c117b0ff22565865c54f48f8bd807df2b00c2c404b8e0/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", size = 4953933, upload-time = "2025-09-14T22:17:11.857Z" }, + { url = "https://files.pythonhosted.org/packages/18/e1/97680c664a1bf9a247a280a053d98e251424af51f1b196c6d52f117c9720/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", size = 5268008, upload-time = "2025-09-14T22:17:13.627Z" }, + { url = "https://files.pythonhosted.org/packages/1e/73/316e4010de585ac798e154e88fd81bb16afc5c5cb1a72eeb16dd37e8024a/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", size = 5433517, upload-time = "2025-09-14T22:17:16.103Z" }, + { url = "https://files.pythonhosted.org/packages/5b/60/dd0f8cfa8129c5a0ce3ea6b7f70be5b33d2618013a161e1ff26c2b39787c/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", size = 5814292, upload-time = "2025-09-14T22:17:17.827Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5f/75aafd4b9d11b5407b641b8e41a57864097663699f23e9ad4dbb91dc6bfe/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", size = 5360237, upload-time = "2025-09-14T22:17:19.954Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", size = 436922, upload-time = "2025-09-14T22:17:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01", size = 506276, upload-time = "2025-09-14T22:17:21.429Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", size = 462679, upload-time = "2025-09-14T22:17:23.147Z" }, + { url = "https://files.pythonhosted.org/packages/35/0b/8df9c4ad06af91d39e94fa96cc010a24ac4ef1378d3efab9223cc8593d40/zstandard-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94", size = 795735, upload-time = "2025-09-14T22:17:26.042Z" }, + { url = "https://files.pythonhosted.org/packages/3f/06/9ae96a3e5dcfd119377ba33d4c42a7d89da1efabd5cb3e366b156c45ff4d/zstandard-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1", size = 640440, upload-time = "2025-09-14T22:17:27.366Z" }, + { url = "https://files.pythonhosted.org/packages/d9/14/933d27204c2bd404229c69f445862454dcc101cd69ef8c6068f15aaec12c/zstandard-0.25.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f", size = 5343070, upload-time = "2025-09-14T22:17:28.896Z" }, + { url = "https://files.pythonhosted.org/packages/6d/db/ddb11011826ed7db9d0e485d13df79b58586bfdec56e5c84a928a9a78c1c/zstandard-0.25.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea", size = 5063001, upload-time = "2025-09-14T22:17:31.044Z" }, + { url = "https://files.pythonhosted.org/packages/db/00/87466ea3f99599d02a5238498b87bf84a6348290c19571051839ca943777/zstandard-0.25.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e", size = 5394120, upload-time = "2025-09-14T22:17:32.711Z" }, + { url = "https://files.pythonhosted.org/packages/2b/95/fc5531d9c618a679a20ff6c29e2b3ef1d1f4ad66c5e161ae6ff847d102a9/zstandard-0.25.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551", size = 5451230, upload-time = "2025-09-14T22:17:34.41Z" }, + { url = "https://files.pythonhosted.org/packages/63/4b/e3678b4e776db00f9f7b2fe58e547e8928ef32727d7a1ff01dea010f3f13/zstandard-0.25.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a", size = 5547173, upload-time = "2025-09-14T22:17:36.084Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d5/ba05ed95c6b8ec30bd468dfeab20589f2cf709b5c940483e31d991f2ca58/zstandard-0.25.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611", size = 5046736, upload-time = "2025-09-14T22:17:37.891Z" }, + { url = "https://files.pythonhosted.org/packages/50/d5/870aa06b3a76c73eced65c044b92286a3c4e00554005ff51962deef28e28/zstandard-0.25.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3", size = 5576368, upload-time = "2025-09-14T22:17:40.206Z" }, + { url = "https://files.pythonhosted.org/packages/5d/35/398dc2ffc89d304d59bc12f0fdd931b4ce455bddf7038a0a67733a25f550/zstandard-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b", size = 4954022, upload-time = "2025-09-14T22:17:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/9a/5c/36ba1e5507d56d2213202ec2b05e8541734af5f2ce378c5d1ceaf4d88dc4/zstandard-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851", size = 5267889, upload-time = "2025-09-14T22:17:43.577Z" }, + { url = "https://files.pythonhosted.org/packages/70/e8/2ec6b6fb7358b2ec0113ae202647ca7c0e9d15b61c005ae5225ad0995df5/zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250", size = 5433952, upload-time = "2025-09-14T22:17:45.271Z" }, + { url = "https://files.pythonhosted.org/packages/7b/01/b5f4d4dbc59ef193e870495c6f1275f5b2928e01ff5a81fecb22a06e22fb/zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98", size = 5814054, upload-time = "2025-09-14T22:17:47.08Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e5/fbd822d5c6f427cf158316d012c5a12f233473c2f9c5fe5ab1ae5d21f3d8/zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf", size = 5360113, upload-time = "2025-09-14T22:17:48.893Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/69a553d2047f9a2c7347caa225bb3a63b6d7704ad74610cb7823baa08ed7/zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09", size = 436936, upload-time = "2025-09-14T22:17:52.658Z" }, + { url = "https://files.pythonhosted.org/packages/d9/82/b9c06c870f3bd8767c201f1edbdf9e8dc34be5b0fbc5682c4f80fe948475/zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5", size = 506232, upload-time = "2025-09-14T22:17:50.402Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/60c3c01243bb81d381c9916e2a6d9e149ab8627c0c7d7abb2d73384b3c0c/zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049", size = 462671, upload-time = "2025-09-14T22:17:51.533Z" }, + { url = "https://files.pythonhosted.org/packages/3d/5c/f8923b595b55fe49e30612987ad8bf053aef555c14f05bb659dd5dbe3e8a/zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3", size = 795887, upload-time = "2025-09-14T22:17:54.198Z" }, + { url = "https://files.pythonhosted.org/packages/8d/09/d0a2a14fc3439c5f874042dca72a79c70a532090b7ba0003be73fee37ae2/zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f", size = 640658, upload-time = "2025-09-14T22:17:55.423Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/8b6b71b1ddd517f68ffb55e10834388d4f793c49c6b83effaaa05785b0b4/zstandard-0.25.0-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c", size = 5379849, upload-time = "2025-09-14T22:17:57.372Z" }, + { url = "https://files.pythonhosted.org/packages/a4/86/a48e56320d0a17189ab7a42645387334fba2200e904ee47fc5a26c1fd8ca/zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439", size = 5058095, upload-time = "2025-09-14T22:17:59.498Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ad/eb659984ee2c0a779f9d06dbfe45e2dc39d99ff40a319895df2d3d9a48e5/zstandard-0.25.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043", size = 5551751, upload-time = "2025-09-14T22:18:01.618Z" }, + { url = "https://files.pythonhosted.org/packages/61/b3/b637faea43677eb7bd42ab204dfb7053bd5c4582bfe6b1baefa80ac0c47b/zstandard-0.25.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859", size = 6364818, upload-time = "2025-09-14T22:18:03.769Z" }, + { url = "https://files.pythonhosted.org/packages/31/dc/cc50210e11e465c975462439a492516a73300ab8caa8f5e0902544fd748b/zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0", size = 5560402, upload-time = "2025-09-14T22:18:05.954Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ae/56523ae9c142f0c08efd5e868a6da613ae76614eca1305259c3bf6a0ed43/zstandard-0.25.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7", size = 4955108, upload-time = "2025-09-14T22:18:07.68Z" }, + { url = "https://files.pythonhosted.org/packages/98/cf/c899f2d6df0840d5e384cf4c4121458c72802e8bda19691f3b16619f51e9/zstandard-0.25.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2", size = 5269248, upload-time = "2025-09-14T22:18:09.753Z" }, + { url = "https://files.pythonhosted.org/packages/1b/c0/59e912a531d91e1c192d3085fc0f6fb2852753c301a812d856d857ea03c6/zstandard-0.25.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344", size = 5430330, upload-time = "2025-09-14T22:18:11.966Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/7e31db1240de2df22a58e2ea9a93fc6e38cc29353e660c0272b6735d6669/zstandard-0.25.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c", size = 5811123, upload-time = "2025-09-14T22:18:13.907Z" }, + { url = "https://files.pythonhosted.org/packages/f6/49/fac46df5ad353d50535e118d6983069df68ca5908d4d65b8c466150a4ff1/zstandard-0.25.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088", size = 5359591, upload-time = "2025-09-14T22:18:16.465Z" }, + { url = "https://files.pythonhosted.org/packages/c2/38/f249a2050ad1eea0bb364046153942e34abba95dd5520af199aed86fbb49/zstandard-0.25.0-cp314-cp314-win32.whl", hash = "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12", size = 444513, upload-time = "2025-09-14T22:18:20.61Z" }, + { url = "https://files.pythonhosted.org/packages/3a/43/241f9615bcf8ba8903b3f0432da069e857fc4fd1783bd26183db53c4804b/zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2", size = 516118, upload-time = "2025-09-14T22:18:17.849Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ef/da163ce2450ed4febf6467d77ccb4cd52c4c30ab45624bad26ca0a27260c/zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d", size = 476940, upload-time = "2025-09-14T22:18:19.088Z" }, +] diff --git a/deploy.md b/deploy.md new file mode 100644 index 0000000..58aff64 --- /dev/null +++ b/deploy.md @@ -0,0 +1,92 @@ +# 部署到腾讯云 + +## 1. 服务器准备 + +```bash +# SSH 登录后安装 Docker +curl -fsSL https://get.docker.com | sh +sudo systemctl enable docker && sudo systemctl start docker + +# 安装 docker-compose +sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose +sudo chmod +x /usr/local/bin/docker-compose +``` + +## 2. 上传代码 + +```bash +# 本地打包(排除 node_modules 和 .venv) +cd "/Users/soda/Desktop/PastPaper Master" +tar --exclude='node_modules' --exclude='.venv' --exclude='__pycache__' --exclude='.git' \ + -czf pastpaper.tar.gz . + +# 上传到服务器 +scp pastpaper.tar.gz root@:/opt/pastpaper/ + +# 服务器上解压 +ssh root@ +cd /opt/pastpaper && tar xzf pastpaper.tar.gz +``` + +## 3. 配置环境变量 + +```bash +# 编辑 .env,确认所有 key 正确 +vi /opt/pastpaper/.env +``` + +需要的变量: +- `SUPABASE_URL`, `SUPABASE_ANON_KEY`, `SUPABASE_SERVICE_ROLE_KEY` +- `DASHSCOPE_BASE_URL`, `DASHSCOPE_API_KEY` +- `DEEPSEEK_BASE_URL`, `DEEPSEEK_API_KEY` +- `LAOZHANG_BASE_URL`, `LAOZHANG_API_KEY`(备用) +- `GOOGLE_GEMINI_API_KEY`(如果服务器地区支持) + +## 4. 构建并启动 + +```bash +cd /opt/pastpaper +docker-compose up -d --build +``` + +## 5. 验证 + +```bash +# 检查容器状态 +docker-compose ps + +# 检查后端健康 +curl http://localhost/health + +# 查看日志 +docker-compose logs -f backend +docker-compose logs -f frontend +``` + +## 6. 域名 + HTTPS(可选) + +如果有域名,在腾讯云控制台配置 DNS → 服务器 IP,然后: + +```bash +# 安装 certbot +apt install -y certbot python3-certbot-nginx + +# 获取证书(先把 nginx.conf 里 server_name 改成你的域名) +certbot --nginx -d your-domain.com +``` + +## 常用运维命令 + +```bash +# 重启 +docker-compose restart + +# 更新代码后重新构建 +docker-compose up -d --build + +# 查看后端日志 +docker-compose logs -f backend + +# 进入后端容器 +docker-compose exec backend bash +``` diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ce3f6f5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,10 @@ +services: + backend: + build: ./backend + env_file: .env + ports: + - "8001:8000" + restart: unless-stopped + dns: + - 8.8.8.8 + - 1.1.1.1 diff --git a/docs/PAGE_NUMBER_BACKFILL.md b/docs/PAGE_NUMBER_BACKFILL.md new file mode 100644 index 0000000..7bb7893 --- /dev/null +++ b/docs/PAGE_NUMBER_BACKFILL.md @@ -0,0 +1,152 @@ +# Sub-question Page Number Backfill — Requirements + +## Problem + +All six `split_comp2211_*.py` scripts create sub-questions by inheriting `page_number` +from their parent question: + +```python +"page_number": parent.get("page_number"), +``` + +This is wrong for sub-questions that span multiple pages. For example, Q1 True/False +has 10 statements (a–j); if (a)–(f) are on page 1 and (g)–(j) are on page 2, all ten +inherit page 1 from the parent. Clicking Q1h in the UI scrolls to page 1 instead of page 2. + +## Goal + +Every `ChildSpec` in every split script should carry its own correct `page_number`. +When the script runs, it writes that page number to the database instead of inheriting +from the parent. + +## Files to modify + +``` +backend/split_comp2211_2022_fall_midterm.py ← does not exist yet; parent is seed SQL +backend/split_comp2211_2022_spring_midterm.py +backend/split_comp2211_2022_spring_final_part_a.py +backend/split_comp2211_2022_spring_final_part_b.py +backend/split_comp2211_2023_spring_midterm.py +backend/split_comp2211_2024_spring_midterm.py +backend/split_comp2211_2024_spring_final.py +``` + +Note: `2022-fall-midterm` sub-questions were inserted directly via the seed SQL +(`supabase/seeds/comp2211_problem_level_questions.sql`), not via a split script. +Their page numbers must be fixed directly in that SQL file or via a separate UPDATE. + +## How to determine page numbers + +Use PyMuPDF (`import pymupdf` — already in the venv) to search for question markers +in the local PDF files. The PDFs are at: + +``` +../pastpaper-scraper/papers/COMP2211/ +``` + +Filename mapping (from `upload_course_library_pdfs.py`): + +| Exam key | Local paper PDF | +|----------|----------------| +| COMP2211-2022-fall-midterm | (COMP2211)[2022](f)midterm~=yjz8dxdd^_27002.pdf | +| COMP2211-2022-spring-midterm | (COMP2211)[2022](s)midterm~=b8bidkgs^_14629.pdf | +| COMP2211-2022-spring-final-part-a | (COMP2211)[2022](s)final~=b8bidkgs^_33018.pdf | +| COMP2211-2022-spring-final-part-b | (COMP2211)[2022](s)final~=b8bidkgs^_40627.pdf | +| COMP2211-2023-spring-midterm | (COMP2211)[2023](s)midterm~=bxbidkmj^_26587.pdf | +| COMP2211-2024-spring-midterm | (COMP2211)[2024](s)midterm~=rcidkjgf^_82003.pdf | +| COMP2211-2024-spring-final | (COMP2211)[2024](s)final~=igk5mmg^_90365.pdf | + +### Suggested search strategy + +```python +import pymupdf + +doc = pymupdf.open("path/to/paper.pdf") +for page_num, page in enumerate(doc, start=1): + text = page.get_text() + print(f"--- Page {page_num} ---") + print(text[:500]) +``` + +Search for markers like: +- `"(a)"`, `"(b)"`, ... for True/False sub-statements +- `"Q2(a)"`, `"2(a)"`, `"Question 2"` for major sub-questions +- `"(i)"`, `"(ii)"` for nested sub-questions + +Page numbers are 1-indexed (matching the `page_number` field in the database). + +## Code changes per split script + +### Step 1 — Add `page_number` field to `ChildSpec` + +Each script has its own `ChildSpec` dataclass. Add the field with a default so +existing call sites don't break immediately: + +```python +@dataclass(frozen=True) +class ChildSpec: + ... + page_number: int = 1 # add this field +``` + +### Step 2 — Set correct page numbers in each `ChildSpec` instance + +Fill in the actual page after inspecting the PDF: + +```python +ChildSpec("1a", "1", "1", ("a",), 1.5, "true_false", page_number=1), +ChildSpec("1b", "1", "1", ("b",), 1.5, "true_false", page_number=1), +... +ChildSpec("1h", "1", "1", ("h",), 1.5, "true_false", page_number=2), +``` + +### Step 3 — Write `page_number` in the upsert payload + +Find where the script builds the INSERT/upsert dict and replace the inherited value: + +```python +# Before: +"page_number": parent.get("page_number"), + +# After: +"page_number": child.page_number, +``` + +### Step 4 — Update existing rows in the database + +After modifying the scripts, run each script once — they already use upsert/update +semantics, so re-running overwrites the old (inherited) page numbers with the correct ones. + +If a script does INSERT-only (not upsert), add a separate UPDATE pass: + +```python +sb.table("paper_questions").update({"page_number": child.page_number}) \ + .eq("paper_id", paper_id) \ + .eq("question_number", child.question_number) \ + .execute() +``` + +## 2022-fall-midterm (seed SQL) + +Sub-questions for this paper are in: +`supabase/seeds/comp2211_problem_level_questions.sql` + +The seed has a `page_number` column in the VALUES rows. Find all rows for +`COMP2211-2022-fall-midterm` and correct the values. Then run a direct UPDATE +against the live database: + +```sql +-- Example — adjust actual page numbers after inspecting the PDF +UPDATE paper_questions +SET page_number = 2 +WHERE paper_id = (SELECT id FROM papers WHERE source_exam_key = 'COMP2211-2022-fall-midterm') + AND question_number IN ('1g', '1h', '1i', '1j'); +``` + +## Definition of Done + +- [ ] Every `ChildSpec` in every split script has an explicit `page_number` +- [ ] No script uses `parent.get("page_number")` for the upsert payload +- [ ] All six scripts have been re-run against the live database +- [ ] 2022-fall-midterm sub-questions updated via SQL +- [ ] Spot-check: clicking Q1h in a paper where Q1 spans 2 pages scrolls to page 2 in the UI diff --git a/docs/TAGGING_REQUIREMENTS.md b/docs/TAGGING_REQUIREMENTS.md new file mode 100644 index 0000000..c5611f8 --- /dev/null +++ b/docs/TAGGING_REQUIREMENTS.md @@ -0,0 +1,243 @@ +# Tag Schema & Similar Question Retrieval — Requirements + +## Background + +Current state of `paper_questions` tagging for COMP2211: + +- `analytics_topic`: 8 coarse buckets (e.g. "KNN and Clustering" covers both KNN and K-Means) +- `topic_tags`: redundant copy of `analytics_topic`, adds no information +- `skill_tags`: fine-grained snake_case labels (e.g. `centroid_update`, `distance_calculation`), not shown to users +- `question_text`: at subquestion level, but currently stores **parent problem header text**, not the actual subquestion statement + +The result is that similar question retrieval conflates KNN and K-Means, cannot distinguish "write code" from "trace algorithm", and produces low-precision recommendations. + +--- + +## Goal + +Every subquestion should carry enough structured metadata that the retrieval system can return **topically and skill-wise identical questions across different exam years**, rather than just questions from the same broad topic bucket. + +Precision target: a question on K-Means centroid update should retrieve other K-Means centroid update questions, not KNN distance questions. + +--- + +## Field Definitions (revised) + +### `analytics_topic` — single string, primary retrieval bucket + +Granularity: **algorithm or concept level**, not course-section level. + +Allowed values for COMP2211 (replace current 8-bucket system): + +| New value | Replaces / splits | +|-----------|-------------------| +| `Naive Bayes` | Probabilistic Models (partial) | +| `Bayesian Inference` | Probabilistic Models (partial) | +| `KNN` | KNN and Clustering (partial) | +| `K-Means` | KNN and Clustering (partial) | +| `Perceptron` | Perceptron and MLP (partial) | +| `MLP` | Perceptron and MLP (partial) | +| `CNN` | Vision and CNN | +| `Evaluation Metrics` | Evaluation and Validation (partial) | +| `Cross Validation` | Evaluation and Validation (partial) | +| `Python and NumPy` | Python Fundamentals | +| `Search Algorithms` | Search and Games (partial) | +| `Game Trees` | Search and Games (partial) | +| `Ethics of AI` | Ethics of AI (unchanged) | + +Rules: +- One value per question — pick the **most specific** algorithm being tested +- If a subquestion genuinely spans two algorithms, pick the one being asked to compute/demonstrate +- `True/False` is **not** a valid analytics_topic (it is a format, not a topic) + +--- + +### `topic_tags` — string array, secondary topic labels + +Granularity: **concept and variant level** within the algorithm. + +Purpose: catch cross-topic overlaps and concept aliases. + +Examples: + +``` +analytics_topic = "K-Means" +topic_tags = ["K-Means", "Centroid Update", "Convergence"] + +analytics_topic = "KNN" +topic_tags = ["KNN", "Euclidean Distance", "Classification"] + +analytics_topic = "Naive Bayes" +topic_tags = ["Naive Bayes", "Prior", "Likelihood", "Posterior"] + +analytics_topic = "Evaluation Metrics" +topic_tags = ["Evaluation Metrics", "Precision", "Recall", "F1 Score"] + +analytics_topic = "MLP" +topic_tags = ["MLP", "Backpropagation", "Activation Function", "Hidden Layer"] + +analytics_topic = "Python and NumPy" +topic_tags = ["NumPy", "Broadcasting", "Array Indexing", "Vectorization"] +``` + +Rules: +- First element should match or alias `analytics_topic` +- Include concept names a student would search for ("F1 Score", not "metric_reasoning") +- 2–5 tags per question; avoid over-tagging +- Human-readable, title-case, no underscores + +--- + +### `skill_tags` — string array, task type labels + +Granularity: **what the student must do**, not what the topic is. + +Current values are acceptable in meaning but must be converted to human-readable form. + +Rename convention: `snake_case` → `Title Case with spaces` + +| Old | New | +|-----|-----| +| `concept_check` | `Concept Check` | +| `code_tracing` | `Code Tracing` | +| `algorithm_tracing` | `Algorithm Tracing` | +| `distance_calculation` | `Distance Calculation` | +| `centroid_update` | `Centroid Update` | +| `weight_update` | `Weight Update` | +| `decision_boundary` | `Decision Boundary` | +| `implementation` | `Implementation` | +| `debugging` | `Debugging` | +| `model_selection` | `Model Selection` | +| `concept_explanation` | `Concept Explanation` | +| `architecture_reasoning` | `Architecture Reasoning` | +| `convergence_reasoning` | `Convergence Reasoning` | +| `generalization_reasoning` | `Generalization Reasoning` | +| `classification_decision` | `Classification Decision` | + +Rules: +- 1–3 tags per question +- Describes the **task type**, not the subject matter +- These are used for retrieval ranking, not primary display + +--- + +### `question_text` — the actual subquestion statement + +Current problem: subquestions store the **parent problem header** as `question_text`, not the individual statement. + +Required fix per subquestion type: + +| Type | What `question_text` should contain | +|------|-------------------------------------| +| True/False subquestion (Q1a–Q1j) | The specific T/F statement being judged | +| Code output (Q2a_i–Q2a_v) | The specific code snippet + "What is the output?" | +| Calculation subquestion (Q4a, Q5a) | The specific sub-task, e.g. "Compute the Euclidean distance between..." | +| Written explanation (Q3, Q5c) | The full question prompt for that part | + +This is a **data extraction quality issue**. The backfill script must extract the correct per-subquestion text from the source PDF or from `raw_answer_text`. + +--- + +## Backfill Requirements + +### Script: `backfill_comp2211_tags.py` + +Target: all `paper_questions` where `paper_id` in the COMP2211 course library. + +For each question: + +1. **Re-classify `analytics_topic`** using the new value list above + - Use `question_text` + existing `topic_tags` + `skill_tags` as signals + - If `analytics_topic` is currently `"KNN and Clustering"`: + - Look at `skill_tags` and `question_text` + - If `centroid_update`, `algorithm_tracing`, or text contains "K-Means" / "centroid" → set `"K-Means"` + - Otherwise → set `"KNN"` + - If `analytics_topic` is currently `"Perceptron and MLP"`: + - If `question_text` or `skill_tags` references hidden layer, backprop, activation function → `"MLP"` + - Otherwise → `"Perceptron"` + - If `analytics_topic` is currently `"Probabilistic Models"`: + - If Naive Bayes in text → `"Naive Bayes"` + - Otherwise → `"Bayesian Inference"` + - If `analytics_topic` is currently `"Evaluation and Validation"`: + - If cross-validation, train/val split in text → `"Cross Validation"` + - Otherwise → `"Evaluation Metrics"` + - If `analytics_topic` is currently `"Search and Games"`: + - If minimax, alpha-beta, game tree in text → `"Game Trees"` + - Otherwise → `"Search Algorithms"` + +2. **Rebuild `topic_tags`** — do not copy `analytics_topic`; derive from question content + +3. **Rename `skill_tags`** — convert all snake_case values to Title Case per the mapping table above + +4. **Do not overwrite `question_text`** in this pass (separate task) + +--- + +## Retrieval Algorithm Changes (backend `questions.py`) + +### Separate topic and skill contributions + +Current `similarity_score()` merges `analytics_topic`, `topic_tags`, and `skill_tags` into one set. This causes skill tags like `centroid_update` to appear as "Shared topic: centroid_update" in the UI. + +Required split: + +```python +def similarity_score(target, candidate): + score = 0 + reasons = [] + + # 1. analytics_topic exact match: 40 pts + if target.get("analytics_topic") and target["analytics_topic"] == candidate.get("analytics_topic"): + score += 40 + reasons.append(f"Same topic: {target['analytics_topic']}") + + # 2. topic_tags overlap: up to 20 pts (10 per shared tag, max 2) + target_tt = set(t.lower() for t in (target.get("topic_tags") or [])) + candidate_tt = set(t.lower() for t in (candidate.get("topic_tags") or [])) + shared_tt = target_tt & candidate_tt + tt_pts = min(len(shared_tt) * 10, 20) + if tt_pts: + score += tt_pts + reasons.append(f"Shared concept: {', '.join(sorted(shared_tt)[:2])}") + + # 3. skill_tags overlap: up to 20 pts (10 per shared tag, max 2) + target_st = set(t.lower() for t in (target.get("skill_tags") or [])) + candidate_st = set(t.lower() for t in (candidate.get("skill_tags") or [])) + shared_st = target_st & candidate_st + st_pts = min(len(shared_st) * 10, 20) + if st_pts: + score += st_pts + reasons.append(f"Shared skill: {', '.join(sorted(shared_st)[:2])}") + + # 4. Same question format: 10 pts + if question_family(candidate) == question_family(target): + score += 10 + reasons.append("Same format") + + # 5. Same difficulty: 5 pts + if candidate.get("difficulty") and candidate["difficulty"] == target.get("difficulty"): + score += 5 + reasons.append("Same difficulty") + + # 6. Full-text similarity: up to 20 pts (from tsvector RPC) + # (injected externally, not computed here) + + return min(score, 99), reasons +``` + +### Threshold and display + +- Filter: `match_percent < 20` (raised from 10; ensures analytics_topic at least partially matches) +- UI display: show `match_reasons` chips, but replace snake_case with Title Case before display + +--- + +## Definition of Done + +- [ ] All COMP2211 questions have `analytics_topic` from the new value list +- [ ] No `analytics_topic` value of `"KNN and Clustering"`, `"Perceptron and MLP"`, `"Probabilistic Models"`, `"Evaluation and Validation"`, `"Search and Games"` remains +- [ ] `topic_tags` contains 2–5 human-readable concept names, not a copy of `analytics_topic` +- [ ] `skill_tags` values are Title Case with spaces +- [ ] Similar question retrieval returns 0 cross-algorithm false positives between KNN and K-Means +- [ ] `match_reasons` chips in the UI show no underscores +- [ ] Retrieval threshold enforces `analytics_topic` match as a hard or near-hard requirement diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..a9f5b5d --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,12 @@ +FROM node:20-alpine AS build + +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM nginx:alpine +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..5ad7eb6 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + PastPaper Master + + +
        + + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..7506326 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,27 @@ +server { + listen 80; + server_name pastpaper.knowit.top; + + root /usr/share/nginx/html; + index index.html; + + # SPA fallback + location / { + try_files $uri $uri/ /index.html; + } + + # API proxy to backend + location /api/ { + proxy_pass http://backend:8000/api/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_read_timeout 300s; + client_max_body_size 50M; + } + + # Health check proxy + location /health { + proxy_pass http://backend:8000/health; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..5ab640b --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3058 @@ +{ + "name": "frontend", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "1.0.0", + "dependencies": { + "@supabase/supabase-js": "^2.103.0", + "katex": "^0.16.38", + "pdfjs-dist": "^5.5.207", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "react-pdf": "^10.4.1", + "react-router-dom": "^7.13.1" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.2.1", + "@types/katex": "^0.16.8", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^4.7.0", + "tailwindcss": "^4.2.1", + "typescript": "^5.9.3", + "vite": "^7.3.1" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/canvas": { + "version": "0.1.96", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.96.tgz", + "integrity": "sha512-6NNmNxvoJKeucVjxaaRUt3La2i5jShgiAbaY3G/72s1Vp3U06XPrAIxkAjBxpDcamEn/t+WJ4OOlGmvILo4/Ew==", + "license": "MIT", + "optional": true, + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.96", + "@napi-rs/canvas-darwin-arm64": "0.1.96", + "@napi-rs/canvas-darwin-x64": "0.1.96", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.96", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.96", + "@napi-rs/canvas-linux-arm64-musl": "0.1.96", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.96", + "@napi-rs/canvas-linux-x64-gnu": "0.1.96", + "@napi-rs/canvas-linux-x64-musl": "0.1.96", + "@napi-rs/canvas-win32-arm64-msvc": "0.1.96", + "@napi-rs/canvas-win32-x64-msvc": "0.1.96" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.96", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.96.tgz", + "integrity": "sha512-ew1sPrN3dGdZ3L4FoohPfnjq0f9/Jk7o+wP7HkQZokcXgIUD6FIyICEWGhMYzv53j63wUcPvZeAwgewX58/egg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.96", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.96.tgz", + "integrity": "sha512-Q/wOXZ5PzTqpdmA5eUOcegCf4Go/zz3aZ5DlzSeDpOjFmfwMKh8EzLAoweQ+mJVagcHQyzoJhaTEnrO68TNyNg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.96", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.96.tgz", + "integrity": "sha512-UrXiQz28tQEvGM1qvyptewOAfmUrrd5+wvi6Rzjj2VprZI8iZ2KIvBD2lTTG1bVF95AbeDeG7PJA0D9sLKaOFA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.96", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.96.tgz", + "integrity": "sha512-I90ODxweD8aEP6XKU/NU+biso95MwCtQ2F46dUvhec1HesFi0tq/tAJkYic/1aBSiO/1kGKmSeD1B0duOHhEHQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.96", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.96.tgz", + "integrity": "sha512-Dx/0+RFV++w3PcRy+4xNXkghhXjA5d0Mw1bs95emn5Llinp1vihMaA6WJt3oYv2LAHc36+gnrhIBsPhUyI2SGw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.96", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.96.tgz", + "integrity": "sha512-UvOi7fii3IE2KDfEfhh8m+LpzSRvhGK7o1eho99M2M0HTik11k3GX+2qgVx9EtujN3/bhFFS1kSO3+vPMaJ0Mg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.96", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.96.tgz", + "integrity": "sha512-MBSukhGCQ5nRtf9NbFYWOU080yqkZU1PbuH4o1ROvB4CbPl12fchDR35tU83Wz8gWIM9JTn99lBn9DenPIv7Ig==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.96", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.96.tgz", + "integrity": "sha512-I/ccu2SstyKiV3HIeVzyBIWfrJo8cN7+MSQZPnabewWV6hfJ2nY7Df2WqOHmobBRUw84uGR6zfQHsUEio/m5Vg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.96", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.96.tgz", + "integrity": "sha512-H3uov7qnTl73GDT4h52lAqpJPsl1tIUyNPWJyhQ6gHakohNqqRq3uf80+NEpzcytKGEOENP1wX3yGwZxhjiWEQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "0.1.96", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.96.tgz", + "integrity": "sha512-ATp6Y+djOjYtkfV/VRH7CZ8I1MEtkUQBmKUbuWw5zWEHHqfL0cEcInE4Cxgx7zkNAhEdBbnH8HMVrqNp+/gwxA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.96", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.96.tgz", + "integrity": "sha512-UYGdTltVd+Z8mcIuoqGmAXXUvwH5CLf2M6mIB5B0/JmX5J041jETjqtSYl7gN+aj3k1by/SG6sS0hAwCqyK7zw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@supabase/auth-js": { + "version": "2.103.0", + "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.103.0.tgz", + "integrity": "sha512-6zAanO6c+6gpHOlt5Lb9TlBBkJdZiUWkWCJKAxzkywBDcwaHlLJKXnjQGX6GyVCyKRR1e7sTq4re/yRTH6U/9A==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/functions-js": { + "version": "2.103.0", + "resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.103.0.tgz", + "integrity": "sha512-YrneV2NjskUkkmkZ2Jt2n3elBgbWzV4Y1M9MM370z2Zd5ZPFqFbY8KIoPwuNjtAGE9YrpKBxnbZqeF07BiN9Og==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/phoenix": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.0.tgz", + "integrity": "sha512-RHSx8bHS02xwfHdAbX5Lpbo6PXbgyf7lTaXTlwtFDPwOIw64NnVRwFAXGojHhjtVYI+PEPNSWwkL90f4agN3bw==", + "license": "MIT" + }, + "node_modules/@supabase/postgrest-js": { + "version": "2.103.0", + "resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.103.0.tgz", + "integrity": "sha512-rC3sRxYdPZymkp2CZR1MiNQgbOleD01bGsW8VxEKRR5nMkLZ1NgAS1QTQf78Wh30czFyk505ZYr9Od8/mWT2TA==", + "license": "MIT", + "dependencies": { + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/realtime-js": { + "version": "2.103.0", + "resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.103.0.tgz", + "integrity": "sha512-gcPtXzZ6izyyBVf2of7K3dEt8CScPJn8VcSlQq6oWL9QoE1kqfQl0oFrOMHd5qrcADewxI7OxxosLB8W4XqtIQ==", + "license": "MIT", + "dependencies": { + "@supabase/phoenix": "^0.4.0", + "@types/ws": "^8.18.1", + "tslib": "2.8.1", + "ws": "^8.18.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/storage-js": { + "version": "2.103.0", + "resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.103.0.tgz", + "integrity": "sha512-DHmlvdAXwtOmZNbkIZi4lkobPR3XjIzoOgzoz5duMf6G+sDeY015YrzMJCnqdccuYr7X5x4yYuSwF//RoN2dvQ==", + "license": "MIT", + "dependencies": { + "iceberg-js": "^0.8.1", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@supabase/supabase-js": { + "version": "2.103.0", + "resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.103.0.tgz", + "integrity": "sha512-j/6q5+LtXbR/YOLSLhy7Na74RD1cV2v+KwIIuuqMEjk1JpLEEyu0ynwDHpGoxMncDQl+R5FogaVqZm+85lZvtw==", + "license": "MIT", + "dependencies": { + "@supabase/auth-js": "2.103.0", + "@supabase/functions-js": "2.103.0", + "@supabase/postgrest-js": "2.103.0", + "@supabase/realtime-js": "2.103.0", + "@supabase/storage-js": "2.103.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz", + "integrity": "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.31.1", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.1" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz", + "integrity": "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.1", + "@tailwindcss/oxide-darwin-arm64": "4.2.1", + "@tailwindcss/oxide-darwin-x64": "4.2.1", + "@tailwindcss/oxide-freebsd-x64": "4.2.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", + "@tailwindcss/oxide-linux-x64-musl": "4.2.1", + "@tailwindcss/oxide-wasm32-wasi": "4.2.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.1.tgz", + "integrity": "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.1.tgz", + "integrity": "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.1.tgz", + "integrity": "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.1.tgz", + "integrity": "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.1.tgz", + "integrity": "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.1.tgz", + "integrity": "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.1.tgz", + "integrity": "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.1.tgz", + "integrity": "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.1.tgz", + "integrity": "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.1.tgz", + "integrity": "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz", + "integrity": "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz", + "integrity": "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.1.tgz", + "integrity": "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.2.1", + "@tailwindcss/oxide": "4.2.1", + "tailwindcss": "4.2.1" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/katex": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz", + "integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.19.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.7", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.7.tgz", + "integrity": "sha512-1ghYO3HnxGec0TCGBXiDLVns4eCSx4zJpxnHrlqFQajmhfKMQBzUGDdkMK7fUW7PTHTeLf+j87aTuKuuwWzMGw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001778", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001778.tgz", + "integrity": "sha512-PN7uxFL+ExFJO61aVmP1aIEG4i9whQd4eoSCebav62UwDyp5OHh06zN4jqKSMePVgxHifCw1QJxdRkA1Pisekg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.313", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.313.tgz", + "integrity": "sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.20.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz", + "integrity": "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/esbuild": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/iceberg-js": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz", + "integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==", + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/katex": { + "version": "0.16.38", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.38.tgz", + "integrity": "sha512-cjHooZUmIAUmDsHBN+1n8LaZdpmbj03LtYeYPyuYB7OuloiaeaV6N4LcfjcnHVzGWjVQmKrxxTrpDcmSzEZQwQ==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/lightningcss": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", + "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.31.1", + "lightningcss-darwin-arm64": "1.31.1", + "lightningcss-darwin-x64": "1.31.1", + "lightningcss-freebsd-x64": "1.31.1", + "lightningcss-linux-arm-gnueabihf": "1.31.1", + "lightningcss-linux-arm64-gnu": "1.31.1", + "lightningcss-linux-arm64-musl": "1.31.1", + "lightningcss-linux-x64-gnu": "1.31.1", + "lightningcss-linux-x64-musl": "1.31.1", + "lightningcss-win32-arm64-msvc": "1.31.1", + "lightningcss-win32-x64-msvc": "1.31.1" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", + "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", + "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", + "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", + "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", + "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", + "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", + "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", + "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz", + "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", + "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", + "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/make-cancellable-promise": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/make-cancellable-promise/-/make-cancellable-promise-2.0.0.tgz", + "integrity": "sha512-3SEQqTpV9oqVsIWqAcmDuaNeo7yBO3tqPtqGRcKkEo0lrzD3wqbKG9mkxO65KoOgXqj+zH2phJ2LiAsdzlogSw==", + "license": "MIT", + "funding": { + "url": "https://github.com/wojtekmaj/make-cancellable-promise?sponsor=1" + } + }, + "node_modules/make-event-props": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/make-event-props/-/make-event-props-2.0.0.tgz", + "integrity": "sha512-G/hncXrl4Qt7mauJEXSg3AcdYzmpkIITTNl5I+rH9sog5Yw0kK6vseJjCaPfOXqOqQuPUP89Rkhfz5kPS8ijtw==", + "license": "MIT", + "funding": { + "url": "https://github.com/wojtekmaj/make-event-props?sponsor=1" + } + }, + "node_modules/merge-refs": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-refs/-/merge-refs-2.0.0.tgz", + "integrity": "sha512-3+B21mYK2IqUWnd2EivABLT7ueDhb0b8/dGK8LoFQPrU61YITeCMn14F7y7qZafWNZhUEKb24cJdiT5Wxs3prg==", + "license": "MIT", + "funding": { + "url": "https://github.com/wojtekmaj/merge-refs?sponsor=1" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-readable-to-web-readable-stream": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/node-readable-to-web-readable-stream/-/node-readable-to-web-readable-stream-0.4.2.tgz", + "integrity": "sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==", + "license": "MIT", + "optional": true + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/pdfjs-dist": { + "version": "5.5.207", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.5.207.tgz", + "integrity": "sha512-WMqqw06w1vUt9ZfT0gOFhMf3wHsWhaCrxGrckGs5Cci6ybDW87IvPaOd2pnBwT6BJuP/CzXDZxjFgmSULLdsdw==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.19.0 || >=22.13.0 || >=24" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^0.1.95", + "node-readable-to-web-readable-stream": "^0.4.2" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-pdf": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/react-pdf/-/react-pdf-10.4.1.tgz", + "integrity": "sha512-kS/35staVCBqS29verTQJQZXw7RfsRCPO3fdJoW1KXylcv7A9dw6DZ3vJXC2w+bIBgLw5FN4pOFvKSQtkQhPfA==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "dequal": "^2.0.3", + "make-cancellable-promise": "^2.0.0", + "make-event-props": "^2.0.0", + "merge-refs": "^2.0.0", + "pdfjs-dist": "5.4.296", + "tiny-invariant": "^1.0.0", + "warning": "^4.0.0" + }, + "funding": { + "url": "https://github.com/wojtekmaj/react-pdf?sponsor=1" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-pdf/node_modules/pdfjs-dist": { + "version": "5.4.296", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.296.tgz", + "integrity": "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=20.16.0 || >=22.3.0" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^0.1.80" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.13.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz", + "integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.13.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz", + "integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==", + "license": "MIT", + "dependencies": { + "react-router": "7.13.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", + "integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..68fc263 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,30 @@ +{ + "name": "frontend", + "version": "1.0.0", + "description": "", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@supabase/supabase-js": "^2.103.0", + "katex": "^0.16.38", + "pdfjs-dist": "^5.5.207", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "react-pdf": "^10.4.1", + "react-router-dom": "^7.13.1" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.2.1", + "@types/katex": "^0.16.8", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^4.7.0", + "tailwindcss": "^4.2.1", + "typescript": "^5.9.3", + "vite": "^7.3.1" + } +} diff --git a/frontend/public/favicon.jpg b/frontend/public/favicon.jpg new file mode 100644 index 0000000000000000000000000000000000000000..02adfd80fdd9a3bc5e1cae6b6a299cf233493043 GIT binary patch literal 105047 zcmeFa2|Sd2+dh6tB86m4mWo26ltP#xA;e_QI!R><2^s5%B>wf$<0<0MUM%HUl13$a9TuEz=&N_X_6q|yO49ZULC&g>VTM!sVZy!NS@L$@6-bm{J6|AUO2 zT-?WaPM;AL5j`t)`HHlR>{aFKDynKX)HU?<4GfKL8=F|&yZ^x2#@5co)$NHp!oxG* zS>W@a7r`M>(XV1+<6g%nq@<>$zt70b%Kn^RP*_x4Qu?K)wywUR@oUq!j?S*`9$ar< z|JeA%MIM2V{exKOi&5ISB*S`JxY4_9roEObLcW}_M z?x#B`dEls$4!xx_+o_9B8Q8ByzWY>tQ1Fs2p5yN0c1BJisj<_9pHus7X8(5+^Zzf+ z?B6E#@AK*h4%5Q^xlDvQKiM{;+o(`fm z$p=DoZiuP?;_Nz#bbI4Zxeu85L~ZFcOzwh_A?*Sf8GeR~z47PT2Q>a~MN{62zAzC< zd07Ll5c~GVpKc!TChh8_GrN# zE!d+4dvIV64(!2!JrB{J-Z!-e2ln8=9vs+%1AA~_4-V|Xfju~|XDR;E+o<>8z#bgf zg9CeTU=I%L!GS$Eum=bBDvbW@1=f3(;yp|8o_Ao+JFu55*h?1dsRMiJz@9p=rw;6? z1AFSgo;vW~QU^pu?HhgNx78sGJ@S0EPLNvX%yXL|EZbi&-};EB^LGK7WERWx_o@p* zc7%`j?r^gX@;^ua(p}FEvyW-9kKMf1uEPGDT}K7j8~@rq;MkfXOf(4K?M#=Hdl!+{9s5VteJoP>H($UF*1tH-i@78{pf@ zFT{U_q?4+>>Qq_)`=ssCLZa%RvzMUZo;NVj(7PyB?m|AV+RxbU=j^>0gx~2E?gAI| zRxuwkX}WIxx-@u?zWz7ptLmNPC`G_pI9(h8Uos$YBaCX0y*&3QZi&W>pOn{>oZ8>? z7N3QVsucp>GnDIyH7XnG1=o3NQ3qkzkKEVv&w=e0= znq@XK-0S!0$!!OsIyx9l{#zmV@B1hGPd?sbQvX#Z6)``4+|<@l;{J`~V9h4Mw`WC; z2_;oA0`-K(5Z7Jc0OIbZY4_xS!^3z+mLG!XFQLmYj;+oKG&`KsShv~VlRcFuac_`I zUNw<}{v)rE7e#qnKYE-c81g;i#`m#-17dcG^3${TEmsEGe6&1H|F%qVkm)E_?9lz# z1LctBzNr2yg-#9;in~DL6Wq*_&l?ICJf12rPdw*Q*ma0zmX95bnl`kvr{}caM}RF` zul}Mf8V`ue=o#*{n*0~(o@m|}d+Z6|qWUZyAoe*W_U^u?QCi+^6wPgG{!>v-j3kqZ ztMU$^Wtpwp-XEcEuuU31FSMlzyuhiE)wkG!6YtRS@T=tTNCUEmDA|B4>n-*Th`>sE zM)&v~A4xFz%PL%%vh!l)lh1hBDc|*5+VpKD&}O6WDV6>^VG`ch7vyxTZ9xz8MI^Mr z57G*@2=pt^Q91GVJ%fIQ$9t;dzp6Tx?gBiB#_M>C@G}|X$9tC?aL;sIgmQPfEIuHc zs*;(?%3hyax?ftBAR|7myQ(Qjr?1l$<;h4|nb>U1@>VCJSP)l@f>dNRL#t!FFt;kY zp?ZsA5v1>gRAW-Ge5{UBXE`MNRle$0+PW=_AmA9V@WVp|5MKM+CVU)A$j?&4Mh#+a zt>o{ro$K^uqY%c8l|G$s+xG2Mv-m+#vd2wiI^Z*rN0uf#p zLfQoyAZL5LEpwW(5dKutvN(B1nd<^W2D~CfjzCRCx|7N6f7m<#W4aTw3rrTxqBmM5 zc7f{^9;ZEbfyInt$Z!))=E&+tjnthB)B>8#7hCCPYl8ODPW;=V}~bK&Ukr7nV@_yP{F~eKKb=cCsL|+ZTOu zrk!3u`M0gdCr+1hS*dltLFM;GeNanP-$ac)T~ZV<-&Q1j8aq#`I+ruAF1{LZF4tM8 zj(kK0PGaR)FYrDW_9)QB&x`zk;)iw3kc)uuL414~|>;vk;0OM+F@GfwC zTWr)Iwl9O)*~5nV*#Ge~jN;S|)mws}_SPUZj$`?yURn(MglbsThrbi!hYl#f2@`FM z06AzJw!}0BKe$!&MKj!8-wY+u*BBMFlO-$QKhqhqWj!w3AizbMOAFQQV#-Ts3_X6c zkrUcKBfbml6UJlYH7ay|7(x-{{z|| zRZcs>wZxiEvTy0w=DpMH_B6&TY`M4?sVk-uyLb=(HR(D7pzc!8NHLXiYp z?!6+PauZ(XBfw8?8|=K>*abM^Kgi2X+a$={hsEv!b8IZcw?6HQ*x<{P{HF;Aa>pGE z9W*T}lZdN&YwBXwv>$1cY1^EQ{ci2c=e*mje)qBZik1}5KiIsV*Oy4w^xzrF=)bN} zedtNkj6k}|AJ7DIIFCBV5zZ%a`pjuW4zlfN_z4?+=62VYWm<`EZ+!%zi%9E20*Q7s z>6y100ahDJC#^zEPlCfFpo#e;2`JAF&rg!McUu1~hxoOAzqi`B|NOaCA?Fk4S{CG}JBO}~SdWP`lxD70+6bAx+XcLGsJvb{OMHL|;atx|JwK#8*8B
        C%^ksFR6;m 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
        Loading...
        ; + + return ( + <> + + + : } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + ); +} diff --git a/frontend/src/components/layout/Header.tsx b/frontend/src/components/layout/Header.tsx new file mode 100644 index 0000000..44b0b7d --- /dev/null +++ b/frontend/src/components/layout/Header.tsx @@ -0,0 +1,69 @@ +import { Link } from "react-router-dom"; +import { useAuth } from "@/contexts/AuthContext"; + +export default function Header({ + courseCode, + paperTitle, +}: { + courseCode?: string; + paperTitle?: string; +}) { + const { user, signOut } = useAuth(); + + return ( +
        + + PastPaper Master + + {courseCode && ( +
        + + {courseCode} + + {paperTitle && {paperTitle}} + + + + + AI Analytics + +
        + )} +
        + + My Papers + + + Error Book + + + Analytics + + + Upload + + {user ? ( +
        + {user.email} + +
        + ) : ( + + Sign in + + )} +
        +
        + ); +} diff --git a/frontend/src/components/layout/ProcessingBanner.tsx b/frontend/src/components/layout/ProcessingBanner.tsx new file mode 100644 index 0000000..f7bbe7c --- /dev/null +++ b/frontend/src/components/layout/ProcessingBanner.tsx @@ -0,0 +1,183 @@ +import { useEffect, useRef, useState } from "react"; +import { Link } from "react-router-dom"; +import { myPapers } from "@/lib/api"; +import { useAuth } from "@/contexts/AuthContext"; +import type { Paper } from "@/types/api"; + +interface Notification { + paperId: string; + label: string; +} + +const POLL_MS = 4000; + +export default function ProcessingBanner() { + const { user } = useAuth(); + const [processing, setProcessing] = useState([]); + const [doneNotifs, setDoneNotifs] = useState([]); + const [expanded, setExpanded] = useState(false); + const knownIds = useRef>(new Set()); + + // Drag state + const [pos, setPos] = useState({ x: window.innerWidth - 220, y: 24 }); + const dragging = useRef(false); + const dragOffset = useRef({ x: 0, y: 0 }); + const widgetRef = useRef(null); + + useEffect(() => { + if (!user) return; + let cancelled = false; + + const poll = async () => { + try { + const papers = await myPapers(); + if (cancelled) return; + + const inProgress = papers.filter((p) => p.status === "processing" || p.status === "uploaded"); + setProcessing(inProgress); + + papers + .filter((p) => p.status === "ready" && knownIds.current.has(p.id)) + .forEach((p) => { + knownIds.current.delete(p.id); + const label = `${p.course_code} ${p.year} ${p.term} ${p.exam_type}`; + setDoneNotifs((prev) => [...prev, { paperId: p.id, label }]); + setTimeout(() => { + setDoneNotifs((prev) => prev.filter((n) => n.paperId !== p.id)); + }, 8000); + }); + + inProgress.forEach((p) => knownIds.current.add(p.id)); + } catch { + // silent + } + }; + + poll(); + const interval = setInterval(poll, POLL_MS); + return () => { cancelled = true; clearInterval(interval); }; + }, [user]); + + // Drag handlers + const onMouseDown = (e: React.MouseEvent) => { + // Only drag on the header bar + dragging.current = true; + dragOffset.current = { + x: e.clientX - pos.x, + y: e.clientY - pos.y, + }; + e.preventDefault(); + }; + + useEffect(() => { + const onMouseMove = (e: MouseEvent) => { + if (!dragging.current) return; + setPos({ + x: Math.max(0, Math.min(window.innerWidth - 200, e.clientX - dragOffset.current.x)), + y: Math.max(0, Math.min(window.innerHeight - 60, e.clientY - dragOffset.current.y)), + }); + }; + const onMouseUp = () => { dragging.current = false; }; + window.addEventListener("mousemove", onMouseMove); + window.addEventListener("mouseup", onMouseUp); + return () => { + window.removeEventListener("mousemove", onMouseMove); + window.removeEventListener("mouseup", onMouseUp); + }; + }, []); + + if (!user || (processing.length === 0 && doneNotifs.length === 0)) return null; + + const total = processing.length + doneNotifs.length; + + return ( +
        + {/* ── Header / collapsed pill ── */} +
        setExpanded((v) => !v)} + className="flex items-center gap-2 bg-gray-900 text-white text-xs px-3.5 py-2.5 rounded-xl shadow-lg cursor-grab active:cursor-grabbing" + style={{ minWidth: 180 }} + > + + + {processing.length > 0 + ? `${processing.length} processing…` + : `${doneNotifs.length} ready`} + + {doneNotifs.length > 0 && ( + + {doneNotifs.length} + + )} + {expanded ? "▲" : "▼"} +
        + + {/* ── Expanded panel ── */} + {expanded && ( +
        + {processing.map((p) => { + const step = p.processing_step; + const progress = p.processing_progress || 0; + const total = p.processing_total || 0; + const pct = total > 0 ? Math.round((progress / total) * 100) : 0; + return ( +
        +
        + + + {p.course_code}{" "} + {p.year} {p.term} {p.exam_type} + +
        + {step && ( + <> +
        {step}
        + {total > 0 && ( +
        +
        +
        + )} + + )} +
        + ); + })} + + {doneNotifs.map((n) => ( +
        + + {n.label} + e.stopPropagation()} + > + Open → + + +
        + ))} +
        + )} +
        + ); +} diff --git a/frontend/src/components/shared/CollapsibleSection.tsx b/frontend/src/components/shared/CollapsibleSection.tsx new file mode 100644 index 0000000..b66cd23 --- /dev/null +++ b/frontend/src/components/shared/CollapsibleSection.tsx @@ -0,0 +1,65 @@ +import { useState } from "react"; + +const schemes = { + blue: { + border: "border-blue-200", + bg: "bg-blue-50", + text: "text-blue-800", + icon: "text-blue-500", + }, + amber: { + border: "border-amber-200", + bg: "bg-amber-50", + text: "text-amber-800", + icon: "text-amber-500", + }, + green: { + border: "border-green-200", + bg: "bg-green-50", + text: "text-green-800", + icon: "text-green-500", + }, +} as const; + +export default function CollapsibleSection({ + title, + colorScheme, + defaultOpen = false, + children, +}: { + title: string; + colorScheme: keyof typeof schemes; + defaultOpen?: boolean; + children: React.ReactNode; +}) { + const [isOpen, setIsOpen] = useState(defaultOpen); + const s = schemes[colorScheme]; + + return ( +
        + +
        +
        +
        {children}
        +
        +
        +
        + ); +} diff --git a/frontend/src/components/shared/KaTeXRenderer.tsx b/frontend/src/components/shared/KaTeXRenderer.tsx new file mode 100644 index 0000000..9dc78da --- /dev/null +++ b/frontend/src/components/shared/KaTeXRenderer.tsx @@ -0,0 +1,86 @@ +import { useMemo } from "react"; +import katex from "katex"; + +/** + * Pre-render all LaTeX in an HTML string at the string level, + * then set innerHTML. This avoids DOM-based auto-render issues + * where delimiters get split across text nodes or special chars + * like # cause silent failures. + */ +function renderLatexInString(html: string): string { + // Strip and
         wrappers
        +  let s = html
        +    .replace(/]*class="latex"[^>]*>(.*?)<\/code>/gs, "$1")
        +    .replace(/]*class="latex"[^>]*>(.*?)<\/pre>/gs, "$1");
        +
        +  // 1) Render display math: $$...$$ and \[...\]
        +  s = s.replace(/\$\$([\s\S]+?)\$\$/g, (_match, tex: string) => {
        +    return renderTex(tex.trim(), true);
        +  });
        +  s = s.replace(/\\\[([\s\S]+?)\\\]/g, (_match, tex: string) => {
        +    return renderTex(tex.trim(), true);
        +  });
        +
        +  // 2) Render inline math: $...$ and \(...\)
        +  //    Negative lookbehind for \ to avoid matching \$ escapes
        +  //    Also avoid matching $$ (already handled above)
        +  s = s.replace(/(? {
        +    return renderTex(tex, false);
        +  });
        +  s = s.replace(/\\\(([\s\S]+?)\\\)/g, (_match, tex: string) => {
        +    return renderTex(tex, false);
        +  });
        +
        +  return s;
        +}
        +
        +function decodeHtmlEntities(s: string): string {
        +  return s
        +    .replace(/&/g, "&")
        +    .replace(/</g, "<")
        +    .replace(/>/g, ">")
        +    .replace(/"/g, '"')
        +    .replace(/'/g, "'")
        +    .replace(/ /g, " ");
        +}
        +
        +function renderTex(tex: string, displayMode: boolean): string {
        +  // Decode HTML entities that might appear in DB-sourced HTML
        +  let cleaned = decodeHtmlEntities(tex);
        +  // Sanitize common issues that cause KaTeX to fail:
        +  // 1) # and % inside \text{} — escape them
        +  cleaned = cleaned.replace(/\\text\{([^}]*)\}/g, (_m, inner: string) => {
        +    return "\\text{" + inner.replace(/#/g, "\\#").replace(/%/g, "\\%") + "}";
        +  });
        +  // 2) Standalone # outside \text{} in math — escape it
        +  cleaned = cleaned.replace(/(?${tex}`;
        +  }
        +}
        +
        +export default function KaTeXRenderer({
        +  html,
        +  className,
        +}: {
        +  html: string;
        +  className?: string;
        +}) {
        +  const rendered = useMemo(() => renderLatexInString(html), [html]);
        +
        +  return (
        +    
        + ); +} diff --git a/frontend/src/components/shared/StatusBadge.tsx b/frontend/src/components/shared/StatusBadge.tsx new file mode 100644 index 0000000..8138813 --- /dev/null +++ b/frontend/src/components/shared/StatusBadge.tsx @@ -0,0 +1,15 @@ +const statusConfig = { + uploaded: { label: "Uploaded", bg: "bg-gray-100", text: "text-gray-600" }, + processing: { label: "Processing...", bg: "bg-blue-100", text: "text-blue-700" }, + ready: { label: "Ready", bg: "bg-green-100", text: "text-green-700" }, + error: { label: "Error", bg: "bg-red-100", text: "text-red-700" }, +} as const; + +export default function StatusBadge({ status }: { status: string }) { + const config = statusConfig[status as keyof typeof statusConfig] ?? statusConfig.uploaded; + return ( + + {config.label} + + ); +} diff --git a/frontend/src/components/upload/FilePickerField.tsx b/frontend/src/components/upload/FilePickerField.tsx new file mode 100644 index 0000000..9bacaeb --- /dev/null +++ b/frontend/src/components/upload/FilePickerField.tsx @@ -0,0 +1,63 @@ +import { useRef, useState } from "react"; + +export default function FilePickerField({ + label, + required, + file, + onFileChange, +}: { + label: string; + required?: boolean; + file: File | null; + onFileChange: (file: File | null) => void; +}) { + const inputRef = useRef(null); + const [isDragging, setIsDragging] = useState(false); + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + const f = e.dataTransfer.files[0]; + if (f?.type === "application/pdf") onFileChange(f); + }; + + return ( +
        + +
        inputRef.current?.click()} + onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }} + onDragLeave={() => setIsDragging(false)} + onDrop={handleDrop} + > + onFileChange(e.target.files?.[0] ?? null)} + /> + {file ? ( +
        + {file.name} + +
        + ) : ( +
        + Click or drag PDF file here +
        + )} +
        +
        + ); +} diff --git a/frontend/src/components/upload/UploadForm.tsx b/frontend/src/components/upload/UploadForm.tsx new file mode 100644 index 0000000..35fd411 --- /dev/null +++ b/frontend/src/components/upload/UploadForm.tsx @@ -0,0 +1,184 @@ +import { useState, useCallback } from "react"; +import { useNavigate } from "react-router-dom"; +import { uploadPaper } from "@/lib/api"; +import FilePickerField from "./FilePickerField"; + +/** Try to extract course code, year, term, exam type from filename */ +function parseFilename(name: string): { + courseCode?: string; + year?: number; + term?: string; + examType?: string; +} { + const result: ReturnType = {}; + + // Remove extension + const base = name.replace(/\.[^.]+$/, "").replace(/[_\-]+/g, " "); + + // Course code: 2-4 uppercase letters + 4 digits + optional letter (e.g. COMP2211, MATH1014H) + const courseMatch = base.match(/([A-Za-z]{2,4}\s*\d{4}[A-Za-z]?)/i); + if (courseMatch) { + result.courseCode = courseMatch[1].replace(/\s/g, "").toUpperCase(); + } + + // Year: 4-digit (2019-2029) or 2-digit (19-29) + const year4 = base.match(/\b(20[1-2]\d)\b/); + if (year4) { + result.year = Number(year4[1]); + } else { + const year2 = base.match(/\b(\d{2})\b/); + if (year2) { + const y = Number(year2[1]); + if (y >= 15 && y <= 29) result.year = 2000 + y; + } + } + + // Term + const lower = base.toLowerCase(); + if (/spring|spr/i.test(lower)) result.term = "spring"; + else if (/fall|aut/i.test(lower)) result.term = "fall"; + else if (/summer|sum/i.test(lower)) result.term = "summer"; + + // Exam type + if (/mid/i.test(lower)) result.examType = "midterm"; + else if (/final|fin/i.test(lower)) result.examType = "final"; + else if (/quiz/i.test(lower)) result.examType = "quiz"; + + return result; +} + +export default function UploadForm() { + const navigate = useNavigate(); + const [paperFile, setPaperFile] = useState(null); + const [answerFile, setAnswerFile] = useState(null); + const [courseCode, setCourseCode] = useState(""); + const [year, setYear] = useState(new Date().getFullYear()); + const [term, setTerm] = useState("fall"); + const [examType, setExamType] = useState("midterm"); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const [autoFilled, setAutoFilled] = useState(false); + + const handlePaperFile = useCallback((file: File | null) => { + setPaperFile(file); + if (!file) { setAutoFilled(false); return; } + + const parsed = parseFilename(file.name); + const filled: string[] = []; + + if (parsed.courseCode) { setCourseCode(parsed.courseCode); filled.push("course"); } + if (parsed.year) { setYear(parsed.year); filled.push("year"); } + if (parsed.term) { setTerm(parsed.term); filled.push("term"); } + if (parsed.examType) { setExamType(parsed.examType); filled.push("type"); } + + setAutoFilled(filled.length > 0); + }, []); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!paperFile || !courseCode) return; + + setSubmitting(true); + setError(null); + + try { + const fd = new FormData(); + fd.append("paper_file", paperFile); + if (answerFile) fd.append("answer_file", answerFile); + fd.append("course_code", courseCode); + fd.append("year", String(year)); + fd.append("term", term); + fd.append("exam_type", examType); + + const result = await uploadPaper(fd); + navigate(`/paper/${result.paper_id}`); + } catch (err) { + setError(err instanceof Error ? err.message : "Upload failed"); + setSubmitting(false); + } + }; + + return ( +
        + + {autoFilled && ( +
        + Auto-filled from filename — please verify below +
        + )} + + +
        + + setCourseCode(e.target.value.toUpperCase())} + placeholder="e.g. COMP2011" + className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + required + /> +
        + +
        +
        + + setYear(Number(e.target.value))} + className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
        +
        + + +
        +
        + + +
        +
        + + {error && ( +
        {error}
        + )} + + + + ); +} diff --git a/frontend/src/components/workbench/ActionBar.tsx b/frontend/src/components/workbench/ActionBar.tsx new file mode 100644 index 0000000..0db0ddb --- /dev/null +++ b/frontend/src/components/workbench/ActionBar.tsx @@ -0,0 +1,58 @@ +import type { Question } from "@/types/api"; + +export default function ActionBar({ + question, + onGenerateVariant, + isGenerating, + onPhotoOpen, + answerState, +}: { + question: Question | null; + onGenerateVariant: () => void; + isGenerating: boolean; + onPhotoOpen: () => void; + answerState?: "correct" | "wrong" | null; +}) { + if (!question) return null; + + const isLong = question.question_type === "long_question" || question.question_type === "long_answer" || question.question_type === "coding"; + + return ( +
        + {/* Answer state feedback (for non-long questions, driven by QuestionDetail) */} + {answerState && ( +
        + {answerState === "correct" ? "Correct!" : "Added to error book"} +
        + )} + + {/* Long question: Upload handwritten answer */} + {isLong && ( + + )} + + {/* Generate variant — always available */} + +
        + ); +} diff --git a/frontend/src/components/workbench/AiTrioPanel.tsx b/frontend/src/components/workbench/AiTrioPanel.tsx new file mode 100644 index 0000000..939283c --- /dev/null +++ b/frontend/src/components/workbench/AiTrioPanel.tsx @@ -0,0 +1,21 @@ +import type { Question } from "@/types/api"; +import CollapsibleSection from "@/components/shared/CollapsibleSection"; +import KaTeXRenderer from "@/components/shared/KaTeXRenderer"; + +export default function AiTrioPanel({ question }: { question: Question }) { + return ( +
        + + + + + + + + + + + +
        + ); +} diff --git a/frontend/src/components/workbench/PdfViewer.tsx b/frontend/src/components/workbench/PdfViewer.tsx new file mode 100644 index 0000000..7fb100a --- /dev/null +++ b/frontend/src/components/workbench/PdfViewer.tsx @@ -0,0 +1,170 @@ +import { useState, useRef, useEffect, useCallback } from "react"; +import { Document, Page, pdfjs } from "react-pdf"; +import "react-pdf/dist/Page/AnnotationLayer.css"; +import "react-pdf/dist/Page/TextLayer.css"; + +pdfjs.GlobalWorkerOptions.workerSrc = `https://unpkg.com/pdfjs-dist@${pdfjs.version}/build/pdf.worker.min.mjs`; + +export default function PdfViewer({ + fileUrl, + currentPage, + onPageChange, +}: { + fileUrl: string; + currentPage?: number; + onPageChange?: (page: number) => void; +}) { + const [numPages, setNumPages] = useState(0); + const [containerWidth, setContainerWidth] = useState(0); + const containerRef = useRef(null); + const scrollRef = useRef(null); + const pageRefs = useRef>(new Map()); + const [jumpPage, setJumpPage] = useState(""); + const programmaticScroll = useRef(false); + + // Resize observer for container width + useEffect(() => { + if (!containerRef.current) return; + const observer = new ResizeObserver((entries) => { + setContainerWidth(entries[0].contentRect.width); + }); + observer.observe(containerRef.current); + return () => observer.disconnect(); + }, []); + + // Scroll to page when currentPage changes (programmatic) + useEffect(() => { + if (!currentPage || currentPage < 1) return; + const el = pageRefs.current.get(currentPage); + if (el) { + programmaticScroll.current = true; + el.scrollIntoView({ behavior: "smooth", block: "start" }); + setTimeout(() => { programmaticScroll.current = false; }, 2000); + } + }, [currentPage]); + + // IntersectionObserver to detect visible page on user scroll + useEffect(() => { + if (numPages === 0 || !scrollRef.current) return; + + const visiblePages = new Map(); + + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + const pageNum = Number(entry.target.getAttribute("data-page")); + if (entry.isIntersecting) { + visiblePages.set(pageNum, entry.intersectionRatio); + } else { + visiblePages.delete(pageNum); + } + } + + // Don't fire callback during programmatic scroll + if (programmaticScroll.current) return; + + // Find the page with the highest visibility ratio + let bestPage = 0; + let bestRatio = 0; + for (const [page, ratio] of visiblePages) { + if (ratio > bestRatio) { + bestRatio = ratio; + bestPage = page; + } + } + if (bestPage > 0) { + onPageChange?.(bestPage); + } + }, + { + root: scrollRef.current, + threshold: [0, 0.25, 0.5, 0.75, 1], + }, + ); + + for (const [, el] of pageRefs.current) { + observer.observe(el); + } + + return () => observer.disconnect(); + }, [numPages, onPageChange]); + + const setPageRef = useCallback((pageNum: number, el: HTMLDivElement | null) => { + if (el) { + el.setAttribute("data-page", String(pageNum)); + pageRefs.current.set(pageNum, el); + } else { + pageRefs.current.delete(pageNum); + } + }, []); + + const handleJump = () => { + const p = parseInt(jumpPage, 10); + if (p >= 1 && p <= numPages) { + const el = pageRefs.current.get(p); + el?.scrollIntoView({ behavior: "smooth", block: "start" }); + } + setJumpPage(""); + }; + + return ( +
        + {/* Page controls */} +
        + + {numPages} pages + + | + + Go to{" "} + setJumpPage(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") handleJump(); }} + placeholder="#" + className="w-12 text-center border border-gray-300 rounded px-1 py-0.5 text-sm" + min={1} + max={numPages} + /> + +
        + + {/* All pages scrollable */} +
        + setNumPages(n)} + loading={ +
        + Loading PDF... +
        + } + error={ +
        + Failed to load PDF +
        + } + > + {numPages > 0 && + Array.from({ length: numPages }, (_, i) => i + 1).map((pageNum) => ( +
        setPageRef(pageNum, el)} + className="flex justify-center mb-2" + > +
        + 0 ? containerWidth - 48 : undefined} + renderAnnotationLayer + renderTextLayer + /> +
        +
        + ))} +
        +
        +
        + ); +} diff --git a/frontend/src/components/workbench/PhotoUpload.tsx b/frontend/src/components/workbench/PhotoUpload.tsx new file mode 100644 index 0000000..2e739e0 --- /dev/null +++ b/frontend/src/components/workbench/PhotoUpload.tsx @@ -0,0 +1,90 @@ +import { useState, useRef } from "react"; +import { uploadPhoto } from "@/lib/api"; +import type { UserAttempt } from "@/types/api"; + +export default function PhotoUpload({ + questionId, + onClose, + onSubmitted, +}: { + questionId: string; + onClose: () => void; + onSubmitted: (promise: Promise<{ attempt: UserAttempt; ocr_text: string; grade: { is_correct: boolean; score_given?: number; feedback: string } }>) => void; +}) { + const [file, setFile] = useState(null); + const [preview, setPreview] = useState(null); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const inputRef = useRef(null); + + const handleFile = (f: File) => { + setFile(f); + setPreview(URL.createObjectURL(f)); + setError(null); + }; + + const handleSubmit = () => { + if (!file || submitting) return; + setSubmitting(true); + const promise = uploadPhoto(questionId, file); + // Close modal immediately, let parent handle the async result + onSubmitted(promise); + onClose(); + }; + + return ( +
        +
        +
        +
        +

        Upload Answer Photo

        + +
        + + {!preview ? ( +
        inputRef.current?.click()} + className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center cursor-pointer hover:border-blue-400 transition-colors" + > +
        📷
        +

        Click to take photo or select image

        + { + const f = e.target.files?.[0]; + if (f) handleFile(f); + }} + /> +
        + ) : ( +
        + Preview + {error && ( +
        {error}
        + )} +
        + + +
        +
        + )} +
        +
        +
        + ); +} diff --git a/frontend/src/components/workbench/QuestionDetail.tsx b/frontend/src/components/workbench/QuestionDetail.tsx new file mode 100644 index 0000000..785a88a --- /dev/null +++ b/frontend/src/components/workbench/QuestionDetail.tsx @@ -0,0 +1,260 @@ +import { useState, useEffect } from "react"; +import type { Question } from "@/types/api"; +import { subquestionLabel } from "@/lib/questionGroups"; + +const typeLabels: Record = { + mc: "Multiple Choice", + true_false: "True / False", + fill_blank: "Fill in Blank", + long_question: "Long Question", + long_answer: "Long Answer", + short_answer: "Short Answer", + coding: "Coding", +}; + +const difficultyColors: Record = { + easy: "bg-green-100 text-green-700", + medium: "bg-yellow-100 text-yellow-700", + hard: "bg-red-100 text-red-700", +}; + +export default function QuestionDetail({ + question, + onAnswerResult, +}: { + question: Question; + onAnswerResult?: (isCorrect: boolean, userAnswer: string) => void; +}) { + const [selectedOption, setSelectedOption] = useState(null); + const [checked, setChecked] = useState(false); + const [fillAnswer, setFillAnswer] = useState(""); + const [fillChecked, setFillChecked] = useState(false); + // True/False: per-statement answers { "a": "True", "b": "False", ... } + const [tfAnswer, setTfAnswer] = useState<"True" | "False" | null>(null); + const [tfChecked, setTfChecked] = useState(false); + + // Reset state when question changes + useEffect(() => { + setSelectedOption(null); + setChecked(false); + setFillAnswer(""); + setFillChecked(false); + setTfAnswer(null); + setTfChecked(false); + }, [question.id]); + + const isCorrectMc = checked && selectedOption === question.correct_option; + const isCorrectFill = + fillChecked && + question.correct_answer != null && + fillAnswer.trim().toLowerCase() === question.correct_answer.trim().toLowerCase(); + + const handleMcCheck = () => { + if (!selectedOption) return; + setChecked(true); + const correct = selectedOption === question.correct_option; + onAnswerResult?.(correct, selectedOption); + }; + + const handleFillCheck = () => { + if (!fillAnswer.trim()) return; + setFillChecked(true); + const correct = + question.correct_answer != null && + fillAnswer.trim().toLowerCase() === question.correct_answer.trim().toLowerCase(); + onAnswerResult?.(correct, fillAnswer.trim()); + }; + + const getOptionStyle = (label: string) => { + if (!checked) { + return label === selectedOption + ? "border-blue-400 bg-blue-50" + : "border-gray-200 hover:bg-gray-50"; + } + if (label === question.correct_option) return "border-green-400 bg-green-50"; + if (label === selectedOption) return "border-red-400 bg-red-50"; + return "border-gray-200 opacity-50"; + }; + + return ( +
        + {/* Header row */} +
        + + Q{question.question_number.match(/^\d+/)?.[0] ?? question.question_number} + + {question.question_number.replace(/^\d+/, "") && ( + + {subquestionLabel(question)} + + )} + + {typeLabels[question.question_type] ?? question.question_type} + + {question.score != null && ( + {question.score} pts + )} + {question.difficulty && ( + + {question.difficulty} + + )} +
        + + {/* Topics */} + {question.topics && question.topics.length > 0 && ( +
        + {question.topics.map((t) => ( + + {t} + + ))} +
        + )} + + {/* MC options */} + {question.question_type === "mc" && question.options && ( + <> +
        + {question.options.map((opt) => ( + + ))} +
        + {!checked && selectedOption && ( + + )} + {checked && ( +
        + {isCorrectMc ? "Correct!" : `Wrong — the answer is ${question.correct_option}`} +
        + )} + + )} + + {/* True/False */} + {question.question_type === "true_false" && (() => { + // Normalize T/F/True/False to "true"/"false" + const normTF = (v: string | null | undefined): string => { + if (!v) return ""; + const l = v.trim().toLowerCase(); + if (l === "t" || l === "true") return "true"; + if (l === "f" || l === "false") return "false"; + return l; + }; + const correctNorm = normTF(question.correct_option ?? question.correct_answer); + const correctDisplay = correctNorm === "true" ? "True" : "False"; + return ( + <> +
        + {(["True", "False"] as const).map((val) => { + const isSelected = tfAnswer === val; + const isCorrectVal = tfChecked && normTF(val) === correctNorm; + const isWrongVal = tfChecked && isSelected && !isCorrectVal; + return ( + + ); + })} +
        + {!tfChecked && tfAnswer && ( + + )} + {tfChecked && ( +
        + {normTF(tfAnswer) === correctNorm + ? "Correct!" + : `Wrong — the answer is ${correctDisplay}`} +
        + )} + + ); + })()} + + {/* Fill-blank input */} + {question.question_type === "fill_blank" && ( +
        +
        + { if (!fillChecked) setFillAnswer(e.target.value); }} + placeholder="Type your answer..." + disabled={fillChecked} + className={`flex-1 border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 ${ + fillChecked + ? isCorrectFill ? "border-green-400 bg-green-50" : "border-red-400 bg-red-50" + : "border-gray-300" + }`} + onKeyDown={(e) => { if (e.key === "Enter") handleFillCheck(); }} + /> + {!fillChecked && ( + + )} +
        + {fillChecked && ( +
        + {isCorrectFill + ? "Correct!" + : `Wrong — the answer is: ${question.correct_answer ?? "N/A"}`} +
        + )} +
        + )} +
        + ); +} diff --git a/frontend/src/components/workbench/QuestionNav.tsx b/frontend/src/components/workbench/QuestionNav.tsx new file mode 100644 index 0000000..78818fc --- /dev/null +++ b/frontend/src/components/workbench/QuestionNav.tsx @@ -0,0 +1,56 @@ +import type { Question } from "@/types/api"; +import type { QuestionGroup } from "@/lib/questionGroups"; +import { subquestionLabel } from "@/lib/questionGroups"; + +export default function QuestionNav({ + groups, + currentGroupKey, + currentQuestionId, + onSelectGroup, + onSelectQuestion, +}: { + groups: QuestionGroup[]; + currentGroupKey: string | null; + currentQuestionId: string | null; + onSelectGroup: (groupKey: string) => void; + onSelectQuestion: (questionId: string) => void; +}) { + const activeGroup = groups.find((group) => group.key === currentGroupKey) ?? null; + + return ( +
        +
        + {groups.map((group) => ( + + ))} +
        + {activeGroup && activeGroup.questions.length > 1 && ( +
        + {activeGroup.questions.map((question) => ( + + ))} +
        + )} +
        + ); +} diff --git a/frontend/src/components/workbench/SimilarHistoryPanel.tsx b/frontend/src/components/workbench/SimilarHistoryPanel.tsx new file mode 100644 index 0000000..b78fe5c --- /dev/null +++ b/frontend/src/components/workbench/SimilarHistoryPanel.tsx @@ -0,0 +1,130 @@ +import { useEffect, useState } from "react"; +import { Link } from "react-router-dom"; + +import { getSimilarQuestions } from "@/lib/api"; +import type { Question, SimilarQuestion } from "@/types/api"; + +const typeLabel: Record = { + mc: "MC", + true_false: "T/F", + fill_blank: "Fill", + long_question: "Long", + long_answer: "Long", + short_answer: "Short", + coding: "Code", +}; + +function matchColor(percent: number): string { + if (percent >= 80) return "bg-green-100 text-green-700"; + if (percent >= 60) return "bg-amber-100 text-amber-700"; + return "bg-gray-100 text-gray-600"; +} + +function cleanReason(reason: string): string { + // "Shared topic: foo_bar, baz_qux" → "Shared topic: Foo Bar, Baz Qux" + return reason.replace(/[_]/g, " ").replace(/:\s*(.+)$/, (_, rest) => + ": " + rest.split(",").map((s: string) => + s.trim().replace(/\b\w/g, (c: string) => c.toUpperCase()) + ).join(", ") + ); +} + +export default function SimilarHistoryPanel({ question }: { question: Question }) { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [isOpen, setIsOpen] = useState(true); + + useEffect(() => { + let cancelled = false; + setLoading(true); + setError(null); + setItems([]); + getSimilarQuestions(question.id) + .then((data) => { + if (cancelled) return; + setItems(data); + setLoading(false); + }) + .catch((err: unknown) => { + if (cancelled) return; + setError(err instanceof Error ? err.message : "Failed to load."); + setLoading(false); + }); + return () => { cancelled = true; }; + }, [question.id]); + + return ( +
        + + + {isOpen && ( +
        + {loading &&
        Loading…
        } + {!loading && error && ( +
        {error}
        + )} + {!loading && !error && items.length === 0 && ( +
        No similar questions found.
        + )} + + {items.map((item) => ( + + {/* Match % badge */} + + {item.match_percent}% + + + {/* Main info */} +
        +
        + {item.source} + · + Q{item.question_number} + {item.question_type && ( + <> + · + {typeLabel[item.question_type] ?? item.question_type} + + )} +
        + + {/* Topics + reasons in one row */} +
        + {item.topics.slice(0, 2).map((topic) => ( + + {topic} + + ))} + {item.match_reasons + ?.filter((r) => !r.startsWith("Same format") && !r.startsWith("Same difficulty")) + .slice(0, 2) + .map((reason) => ( + + {cleanReason(reason)} + + ))} +
        +
        + + + + ))} +
        + )} +
        + ); +} diff --git a/frontend/src/components/workbench/VariantDetail.tsx b/frontend/src/components/workbench/VariantDetail.tsx new file mode 100644 index 0000000..9f8f05c --- /dev/null +++ b/frontend/src/components/workbench/VariantDetail.tsx @@ -0,0 +1,148 @@ +import { useState } from "react"; +import type { VariantQuestion } from "@/types/api"; +import KaTeXRenderer from "@/components/shared/KaTeXRenderer"; +import CollapsibleSection from "@/components/shared/CollapsibleSection"; + +export default function VariantDetail({ + variant, +}: { + variant: VariantQuestion; +}) { + const [selectedOption, setSelectedOption] = useState(null); + const [checked, setChecked] = useState(false); + const [fillAnswer, setFillAnswer] = useState(""); + const [fillChecked, setFillChecked] = useState(false); + + const isMc = (variant.question_type === "mc" || variant.question_type === "true_false") && variant.options; + + const handleMcCheck = () => { + if (!selectedOption) return; + setChecked(true); + }; + + const handleFillCheck = () => { + if (!fillAnswer.trim()) return; + setFillChecked(true); + }; + + const isCorrectMc = checked && selectedOption === variant.correct_answer; + const isCorrectFill = + fillChecked && + fillAnswer.trim().toLowerCase() === variant.correct_answer.trim().toLowerCase(); + + const getOptionStyle = (label: string) => { + if (!checked) { + return label === selectedOption + ? "border-blue-400 bg-blue-50" + : "border-gray-200 hover:bg-gray-50"; + } + if (label === variant.correct_answer) return "border-green-400 bg-green-50"; + if (label === selectedOption) return "border-red-400 bg-red-50"; + return "border-gray-200 opacity-50"; + }; + + return ( +
        + {/* Header */} +
        + V + Similar Question + + {variant.question_type} + +
        + + {/* Question text */} +
        + +
        + + {/* MC options */} + {isMc && variant.options && ( + <> +
        + {variant.options.map((opt) => ( + + ))} +
        + {!checked && selectedOption && ( + + )} + {checked && ( +
        + {isCorrectMc ? "Correct!" : `Wrong — the answer is ${variant.correct_answer}`} +
        + )} + + )} + + {/* Non-MC input */} + {!isMc && ( +
        +
        + { if (!fillChecked) setFillAnswer(e.target.value); }} + placeholder="Type your answer..." + disabled={fillChecked} + className={`flex-1 border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 ${ + fillChecked + ? isCorrectFill ? "border-green-400 bg-green-50" : "border-red-400 bg-red-50" + : "border-gray-300" + }`} + onKeyDown={(e) => { if (e.key === "Enter") handleFillCheck(); }} + /> + {!fillChecked && ( + + )} +
        + {fillChecked && ( +
        + {isCorrectFill ? "Correct!" : `Answer: ${variant.correct_answer}`} +
        + )} +
        + )} + + {/* AI Trio */} +
        + {variant.knowledge_reminder && ( + + + + )} + {variant.ai_hint && ( + + + + )} + + + +
        +
        + ); +} diff --git a/frontend/src/components/workbench/VariantModal.tsx b/frontend/src/components/workbench/VariantModal.tsx new file mode 100644 index 0000000..b83c4f0 --- /dev/null +++ b/frontend/src/components/workbench/VariantModal.tsx @@ -0,0 +1,189 @@ +import { useState } from "react"; +import type { VariantQuestion } from "@/types/api"; +import KaTeXRenderer from "@/components/shared/KaTeXRenderer"; + +export default function VariantModal({ + variant, + onClose, +}: { + variant: VariantQuestion; + onClose: () => void; +}) { + const [selectedOption, setSelectedOption] = useState(null); + const [checked, setChecked] = useState(false); + const [fillAnswer, setFillAnswer] = useState(""); + const [fillChecked, setFillChecked] = useState(false); + const [showKnowledge, setShowKnowledge] = useState(false); + const [showHint, setShowHint] = useState(false); + const [showSolution, setShowSolution] = useState(false); + + const isMc = (variant.question_type === "mc" || variant.question_type === "true_false") && variant.options; + + const handleMcCheck = () => { + if (!selectedOption) return; + setChecked(true); + }; + + const handleFillCheck = () => { + if (!fillAnswer.trim()) return; + setFillChecked(true); + }; + + const isCorrectMc = checked && selectedOption === variant.correct_answer; + const isCorrectFill = + fillChecked && + fillAnswer.trim().toLowerCase() === variant.correct_answer.trim().toLowerCase(); + + const getOptionStyle = (label: string) => { + if (!checked) { + return label === selectedOption + ? "border-blue-400 bg-blue-50" + : "border-gray-200 hover:bg-gray-50"; + } + if (label === variant.correct_answer) return "border-green-400 bg-green-50"; + if (label === selectedOption) return "border-red-400 bg-red-50"; + return "border-gray-200 opacity-50"; + }; + + return ( +
        +
        +
        +
        +

        Similar Question

        + +
        + + {/* Question text */} +
        + +
        + + {/* MC options */} + {isMc && variant.options && ( + <> +
        + {variant.options.map((opt) => ( + + ))} +
        + {!checked && selectedOption && ( + + )} + {checked && ( +
        + {isCorrectMc ? "Correct!" : `Wrong — the answer is ${variant.correct_answer}`} +
        + )} + + )} + + {/* Non-MC input */} + {!isMc && ( +
        +
        + { if (!fillChecked) setFillAnswer(e.target.value); }} + placeholder="Type your answer..." + disabled={fillChecked} + className={`flex-1 border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 ${ + fillChecked + ? isCorrectFill ? "border-green-400 bg-green-50" : "border-red-400 bg-red-50" + : "border-gray-300" + }`} + onKeyDown={(e) => { if (e.key === "Enter") handleFillCheck(); }} + /> + {!fillChecked && ( + + )} +
        + {fillChecked && ( +
        + {isCorrectFill ? "Correct!" : `Answer: ${variant.correct_answer}`} +
        + )} +
        + )} + + {/* AI Trio: Knowledge / Hint / Solution */} +
        + {variant.knowledge_reminder && ( +
        + + {showKnowledge && ( +
        + +
        + )} +
        + )} + {variant.ai_hint && ( +
        + + {showHint && ( +
        + +
        + )} +
        + )} +
        + + {showSolution && ( +
        + +
        + )} +
        +
        + + +
        +
        +
        + ); +} diff --git a/frontend/src/contexts/AuthContext.tsx b/frontend/src/contexts/AuthContext.tsx new file mode 100644 index 0000000..194ae55 --- /dev/null +++ b/frontend/src/contexts/AuthContext.tsx @@ -0,0 +1,49 @@ +import { createContext, useContext, useEffect, useState } from "react"; +import type { Session, User } from "@supabase/supabase-js"; +import { supabase } from "@/lib/supabase"; + +interface AuthContextValue { + session: Session | null; + user: User | null; + loading: boolean; + signOut: () => Promise; +} + +const AuthContext = createContext({ + session: null, + user: null, + loading: true, + signOut: async () => {}, +}); + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [session, setSession] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + supabase.auth.getSession().then(({ data }) => { + setSession(data.session); + setLoading(false); + }); + + const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => { + setSession(session); + }); + + return () => subscription.unsubscribe(); + }, []); + + const signOut = async () => { + await supabase.auth.signOut(); + }; + + return ( + + {children} + + ); +} + +export function useAuth() { + return useContext(AuthContext); +} diff --git a/frontend/src/hooks/usePaper.ts b/frontend/src/hooks/usePaper.ts new file mode 100644 index 0000000..ff9dd1a --- /dev/null +++ b/frontend/src/hooks/usePaper.ts @@ -0,0 +1,43 @@ +import { useEffect, useState } from "react"; +import { getPaper } from "@/lib/api"; +import type { Paper } from "@/types/api"; + +const POLL_INTERVAL = 3000; + +export function usePaper(paperId: string) { + const [paper, setPaper] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let intervalId: number | null = null; + let cancelled = false; + + const fetchPaper = async () => { + try { + const data = await getPaper(paperId); + if (cancelled) return; + setPaper(data); + setLoading(false); + if (data.status === "ready" || data.status === "error") { + if (intervalId !== null) clearInterval(intervalId); + } + } catch (err) { + if (cancelled) return; + setError(err instanceof Error ? err.message : "Unknown error"); + setLoading(false); + if (intervalId !== null) clearInterval(intervalId); + } + }; + + fetchPaper(); + intervalId = window.setInterval(fetchPaper, POLL_INTERVAL); + + return () => { + cancelled = true; + if (intervalId !== null) clearInterval(intervalId); + }; + }, [paperId]); + + return { paper, loading, error }; +} diff --git a/frontend/src/hooks/useQuestions.ts b/frontend/src/hooks/useQuestions.ts new file mode 100644 index 0000000..6f595f9 --- /dev/null +++ b/frontend/src/hooks/useQuestions.ts @@ -0,0 +1,33 @@ +import { useEffect, useState } from "react"; +import { getQuestions } from "@/lib/api"; +import type { Question } from "@/types/api"; + +export function useQuestions(paperId: string, enabled: boolean) { + const [questions, setQuestions] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!enabled) return; + let cancelled = false; + setLoading(true); + + getQuestions(paperId) + .then((data) => { + if (!cancelled) { + setQuestions(data); + setLoading(false); + } + }) + .catch((err) => { + if (!cancelled) { + setError(err instanceof Error ? err.message : "Unknown error"); + setLoading(false); + } + }); + + return () => { cancelled = true; }; + }, [paperId, enabled]); + + return { questions, loading, error }; +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..60b3416 --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,190 @@ +import type { + CourseAnalytics, + Paper, + Question, + QuestionVariant, + SimilarQuestion, + UploadResponse, + UserAttempt, +} from "@/types/api"; +import { supabase } from "@/lib/supabase"; + +const API_BASE = "/api"; + +async function authHeaders(): Promise> { + const { data } = await supabase.auth.getSession(); + const token = data.session?.access_token; + if (!token) return {}; + return { Authorization: `Bearer ${token}` }; +} + +export async function uploadPaper(formData: FormData): Promise { + const headers = await authHeaders(); + const res = await fetch(`${API_BASE}/papers/upload`, { + method: "POST", + headers, + body: formData, + }); + if (!res.ok) throw new Error(`Upload failed: ${res.status}`); + return res.json(); +} + +export async function getPaper(paperId: string): Promise { + const res = await fetch(`${API_BASE}/papers/${paperId}`); + if (!res.ok) throw new Error(`Paper not found: ${res.status}`); + return res.json(); +} + +export async function getQuestions(paperId: string): Promise { + const res = await fetch(`${API_BASE}/papers/${paperId}/questions`); + if (!res.ok) throw new Error(`Questions fetch failed: ${res.status}`); + return res.json(); +} + +export async function myPapers(): Promise { + const headers = await authHeaders(); + const res = await fetch(`${API_BASE}/papers/mine`, { headers }); + if (!res.ok) throw new Error(`My papers fetch failed: ${res.status}`); + return res.json(); +} + +export async function listPapers(): Promise { + const res = await fetch(`${API_BASE}/papers/`); + if (!res.ok) throw new Error(`List papers failed: ${res.status}`); + return res.json(); +} + +export async function recordAttempt( + questionId: string, + attemptType: string, + userAnswer: string | null, + isCorrect: boolean | null, +): Promise { + const headers = await authHeaders(); + const res = await fetch(`${API_BASE}/attempts/`, { + method: "POST", + headers: { "Content-Type": "application/json", ...headers }, + body: JSON.stringify({ + question_id: questionId, + attempt_type: attemptType, + user_answer: userAnswer, + is_correct: isCorrect, + }), + }); + if (!res.ok) throw new Error(`Attempt save failed: ${res.status}`); + return res.json(); +} + +export async function uploadPhoto( + questionId: string, + photo: File, +): Promise<{ attempt: UserAttempt; ocr_text: string; grade: { is_correct: boolean; score_given?: number; feedback: string; error_at_step: number | null } }> { + const headers = await authHeaders(); + const fd = new FormData(); + fd.append("question_id", questionId); + fd.append("photo", photo); + const res = await fetch(`${API_BASE}/attempts/photo`, { + method: "POST", + headers, + body: fd, + }); + if (!res.ok) throw new Error(`Photo upload failed: ${res.status}`); + return res.json(); +} + +export async function getPaperAttempts(paperId: string): Promise<{ + question_id: string; + is_correct: boolean; + feedback: string | null; + photo_ocr_text: string | null; +}[]> { + const headers = await authHeaders(); + const res = await fetch(`${API_BASE}/attempts/by-paper/${paperId}`, { headers }); + if (!res.ok) return []; + return res.json(); +} + +export async function generateVariant(questionId: string): Promise { + const headers = await authHeaders(); + const res = await fetch(`${API_BASE}/questions/${questionId}/variant`, { + method: "POST", + headers, + }); + if (!res.ok) throw new Error(`Variant generation failed: ${res.status}`); + return res.json(); +} + +export async function getVariants(questionId: string): Promise { + const headers = await authHeaders(); + const res = await fetch(`${API_BASE}/questions/${questionId}/variants`, { headers }); + if (!res.ok) throw new Error(`Variants fetch failed: ${res.status}`); + return res.json(); +} + +export async function updateVariant(variantId: string, data: { favorited?: boolean }): Promise { + const headers = await authHeaders(); + const res = await fetch(`${API_BASE}/questions/variant/${variantId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json", ...headers }, + body: JSON.stringify(data), + }); + if (!res.ok) throw new Error(`Variant update failed: ${res.status}`); + return res.json(); +} + +export async function deleteVariant(variantId: string): Promise { + const headers = await authHeaders(); + await fetch(`${API_BASE}/questions/variant/${variantId}`, { method: "DELETE", headers }); +} + +export async function getFavoriteVariants(): Promise { + const headers = await authHeaders(); + const res = await fetch(`${API_BASE}/questions/variants/favorited`, { headers }); + if (!res.ok) throw new Error(`Favorited variants fetch failed: ${res.status}`); + return res.json(); +} + +export async function getErrorBook(courseCode?: string): Promise { + const headers = await authHeaders(); + const params = new URLSearchParams(); + if (courseCode) params.set("course_code", courseCode); + const query = params.toString() ? `?${params.toString()}` : ""; + const res = await fetch(`${API_BASE}/attempts/error-book${query}`, { headers }); + if (!res.ok) throw new Error(`Error book fetch failed: ${res.status}`); + return res.json(); +} + +export async function updateAttempt( + attemptId: string, + data: { in_error_book?: boolean; mastered?: boolean }, +): Promise { + const headers = await authHeaders(); + const res = await fetch(`${API_BASE}/attempts/${attemptId}`, { + method: "PATCH", + headers: { "Content-Type": "application/json", ...headers }, + body: JSON.stringify(data), + }); + if (!res.ok) throw new Error(`Attempt update failed: ${res.status}`); + return res.json(); +} + +export async function listCourses(): Promise { + const res = await fetch(`${API_BASE}/analytics/courses`); + if (!res.ok) throw new Error(`Courses fetch failed: ${res.status}`); + return res.json(); +} + +export async function getCourseAnalytics(courseCode: string): Promise { + const res = await fetch(`${API_BASE}/analytics/course/${courseCode}`); + if (!res.ok) throw new Error(`Analytics fetch failed: ${res.status}`); + return res.json(); +} + +export async function getSimilarQuestions( + questionId: string, + limit = 6, +): Promise { + const res = await fetch(`${API_BASE}/questions/${questionId}/similar?limit=${limit}`); + if (!res.ok) throw new Error(`Similar question fetch failed: ${res.status}`); + return res.json(); +} diff --git a/frontend/src/lib/questionGroups.ts b/frontend/src/lib/questionGroups.ts new file mode 100644 index 0000000..747e827 --- /dev/null +++ b/frontend/src/lib/questionGroups.ts @@ -0,0 +1,45 @@ +import type { Question } from "@/types/api"; + +export interface QuestionGroup { + key: string; + label: string; + questions: Question[]; + startPage: number; +} + +function topLevelKey(questionNumber: string): string { + const match = questionNumber.match(/^\d+/); + return match?.[0] ?? questionNumber; +} + +export function groupQuestions(questions: Question[]): QuestionGroup[] { + const groups = new Map(); + + for (const question of questions) { + const key = topLevelKey(question.question_number); + const existing = groups.get(key); + if (existing) { + existing.questions.push(question); + existing.startPage = Math.min(existing.startPage, question.page_number ?? existing.startPage); + continue; + } + groups.set(key, { + key, + label: `Q${key}`, + questions: [question], + startPage: question.page_number ?? 1, + }); + } + + return Array.from(groups.values()).sort((a, b) => Number(a.key) - Number(b.key)); +} + +export function subquestionLabel(question: Question): string { + const remainder = question.question_number.replace(/^\d+/, ""); + if (!remainder) return "Main"; + return remainder + .replace(/^_+/, "") + .split("_") + .filter(Boolean) + .join("."); +} diff --git a/frontend/src/lib/supabase.ts b/frontend/src/lib/supabase.ts new file mode 100644 index 0000000..0ec9556 --- /dev/null +++ b/frontend/src/lib/supabase.ts @@ -0,0 +1,6 @@ +import { createClient } from "@supabase/supabase-js"; + +const supabaseUrl = import.meta.env.VITE_SUPABASE_URL as string; +const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY as string; + +export const supabase = createClient(supabaseUrl, supabaseAnonKey); diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..7b67097 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,16 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; +import App from "./App"; +import { AuthProvider } from "./contexts/AuthContext"; +import "./styles/globals.css"; + +createRoot(document.getElementById("root")!).render( + + + + + + + , +); diff --git a/frontend/src/pages/AnalyticsPage.tsx b/frontend/src/pages/AnalyticsPage.tsx new file mode 100644 index 0000000..0a23883 --- /dev/null +++ b/frontend/src/pages/AnalyticsPage.tsx @@ -0,0 +1,521 @@ +import { useEffect, useMemo, useState } from "react"; +import { Link, useNavigate, useParams } from "react-router-dom"; + +import Header from "@/components/layout/Header"; +import { getCourseAnalytics, listCourses } from "@/lib/api"; +import type { CourseAnalytics, AnalyticsTopicQuestion } from "@/types/api"; + +const typeLabel: Record = { + mc: "Multiple Choice", + true_false: "True / False", + fill_blank: "Fill in Blank", + long_question: "Long Question", + short_answer: "Short Answer", + coding: "Coding", +}; + +const TYPE_COLORS: Record = { + mc: "bg-violet-50 text-violet-700 border-violet-200", + true_false: "bg-amber-50 text-amber-700 border-amber-200", + fill_blank: "bg-teal-50 text-teal-700 border-teal-200", + long_question: "bg-sky-50 text-sky-700 border-sky-200", + short_answer: "bg-rose-50 text-rose-700 border-rose-200", + coding: "bg-emerald-50 text-emerald-700 border-emerald-200", +}; + +const DIFF_COLORS: Record = { + hard: "text-red-600 bg-red-50 border-red-200", + medium: "text-amber-600 bg-amber-50 border-amber-200", + easy: "text-green-600 bg-green-50 border-green-200", +}; + +type QItem = AnalyticsTopicQuestion; +type Analytics = CourseAnalytics; + +const PAGE_SIZE = 8; + +export default function AnalyticsPage() { + const { courseCode } = useParams<{ courseCode?: string }>(); + const navigate = useNavigate(); + + const [courses, setCourses] = useState([]); + const [search, setSearch] = useState(""); + + useEffect(() => { listCourses().then(setCourses).catch(() => {}); }, []); + const filtered = useMemo(() => { + const q = search.trim().toUpperCase(); + return q ? courses.filter((c) => c.includes(q)) : courses; + }, [courses, search]); + + const normalizedCourse = courseCode?.toUpperCase(); + const [analytics, setAnalytics] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!normalizedCourse) return; + let cancelled = false; + setLoading(true); + setAnalytics(null); + setError(null); + getCourseAnalytics(normalizedCourse) + .then((data) => { if (!cancelled) { setAnalytics(data); setLoading(false); } }) + .catch((err) => { if (!cancelled) { setError(err instanceof Error ? err.message : "Failed"); setLoading(false); } }); + return () => { cancelled = true; }; + }, [normalizedCourse]); + + // ── Course picker ── + if (!normalizedCourse) { + return ( +
        +
        +
        +

        Analytics

        +

        Select a course to view statistics.

        + setSearch(e.target.value)} + className="w-full px-4 py-2.5 border border-gray-300 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 mb-4" + /> + {filtered.length === 0 ? ( +

        No courses found.

        + ) : ( +
        + {filtered.map((code) => ( + + ))} +
        + )} +
        +
        + ); + } + + // ── Dashboard ── + return ( +
        +
        +
        +
        + + / +

        {normalizedCourse}

        +
        + + {loading &&
        Loading analytics...
        } + {error &&
        {error}
        } + + {!loading && !error && analytics && ( + <> + {/* KPI row */} +
        + + + + +
        + + {/* Main area: left = search, right = charts */} +
        + {/* Left: Global search */} + t.label)} /> + + {/* Right: Interactive charts + stats */} +
        + ({ label: t.label, value: t.count }))} + typeData={analytics.question_types.map((t) => ({ label: typeLabel[t.label] ?? t.label, value: t.count }))} + diffData={[ + { label: "Easy", value: analytics.difficulty_distribution.easy }, + { label: "Medium", value: analytics.difficulty_distribution.medium }, + { label: "Hard", value: analytics.difficulty_distribution.hard }, + ].filter((d) => d.value > 0)} + /> + + + {analytics.high_yield_topics.length === 0 ? ( +
        No data yet.
        + ) : ( +
          + {analytics.high_yield_topics.map((t, i) => ( +
        • + {i + 1} + {t} +
        • + ))} +
        + )} +
        +
        +
        + + )} +
        +
        + ); +} + +// ── Global Search Engine ── +function GlobalSearch({ questions, topics }: { questions: QItem[]; topics: string[] }) { + const [search, setSearch] = useState(""); + const [topicFilter, setTopicFilter] = useState(null); + const [typeFilter, setTypeFilter] = useState(null); + const [yearFilter, setYearFilter] = useState(null); + const [termFilter, setTermFilter] = useState(null); + const [diffFilter, setDiffFilter] = useState(null); + const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); + + const types = useMemo(() => [...new Set(questions.map((q) => q.question_type))].sort(), [questions]); + const years = useMemo(() => [...new Set(questions.map((q) => q.year).filter(Boolean))].sort((a, b) => (b ?? 0) - (a ?? 0)) as number[], [questions]); + const terms = useMemo(() => { + const order = ["spring", "summer", "fall", "winter"]; + return [...new Set(questions.map((q) => q.term).filter(Boolean))].sort((a, b) => order.indexOf(a!) - order.indexOf(b!)) as string[]; + }, [questions]); + const diffs = useMemo(() => [...new Set(questions.map((q) => q.difficulty).filter(Boolean))] as string[], [questions]); + + const filtered = useMemo(() => { + const q = search.toLowerCase(); + return questions.filter((item) => { + if (topicFilter && !item.topics?.includes(topicFilter)) return false; + if (typeFilter && item.question_type !== typeFilter) return false; + if (yearFilter && item.year !== yearFilter) return false; + if (termFilter && item.term !== termFilter) return false; + if (diffFilter && item.difficulty !== diffFilter) return false; + if (q && !item.preview.toLowerCase().includes(q) && !item.source.toLowerCase().includes(q) && !item.question_number.toLowerCase().includes(q) && !item.topics?.some((t) => t.toLowerCase().includes(q))) return false; + return true; + }); + }, [questions, search, topicFilter, typeFilter, yearFilter, termFilter, diffFilter]); + + const activeCount = [topicFilter, typeFilter, yearFilter, termFilter, diffFilter].filter(Boolean).length; + + useEffect(() => setVisibleCount(PAGE_SIZE), [search, topicFilter, typeFilter, yearFilter, termFilter, diffFilter]); + + const visible = filtered.slice(0, visibleCount); + const hasMore = visibleCount < filtered.length; + + return ( +
        +

        Question Search

        + + {/* Search bar */} +
        + setSearch(e.target.value)} + placeholder="Search questions, topics, papers..." + className="w-full pl-9 pr-3 py-2.5 text-sm border border-gray-200 rounded-xl bg-gray-50 focus:bg-white focus:outline-none focus:ring-2 focus:ring-blue-400" + /> + 🔍 +
        + + {/* Filter rows */} +
        + {/* Topic */} + + + + + {/* Type + Year + Term + Difficulty in one row */} +
        + +
        + {types.map((t) => ( + setTypeFilter(typeFilter === t ? null : t)} /> + ))} +
        +
        + + +
        + {years.map((y) => ( + setYearFilter(yearFilter === y ? null : y)} /> + ))} +
        +
        + + +
        + {terms.map((t) => ( + setTermFilter(termFilter === t ? null : t)} /> + ))} +
        +
        + + +
        + {(["easy", "medium", "hard"] as const).filter((d) => diffs.includes(d)).map((d) => ( + setDiffFilter(diffFilter === d ? null : d)} /> + ))} +
        +
        +
        +
        + + {/* Results count + clear */} +
        + + {filtered.length} question{filtered.length !== 1 ? "s" : ""} + {activeCount > 0 || search ? " matched" : ""} + + {(activeCount > 0 || search) && ( + + )} +
        + + {/* Results */} +
        + {visible.map((q, i) => ( + + ))} +
        + + {hasMore && ( + + )} + {filtered.length === 0 && ( +
        No questions match your search.
        + )} +
        + ); +} + +// ── Interactive Pie Chart ── +const PIE_PALETTE = [ + "#3B82F6", "#8B5CF6", "#F59E0B", "#10B981", "#EF4444", + "#EC4899", "#06B6D4", "#F97316", "#6366F1", "#14B8A6", +]; + +function InteractiveChart({ topicData, typeData, diffData }: { + topicData: { label: string; value: number }[]; + typeData: { label: string; value: number }[]; + diffData: { label: string; value: number }[]; +}) { + const [view, setView] = useState<"topic" | "type" | "difficulty">("topic"); + const [hovered, setHovered] = useState(null); + + const data = view === "topic" ? topicData : view === "type" ? typeData : diffData; + const colors = view === "difficulty" + ? ["#10B981", "#F59E0B", "#EF4444"] + : PIE_PALETTE; + + const total = data.reduce((s, d) => s + d.value, 0); + + // Build conic-gradient + let cumPct = 0; + const segments = data.map((d, i) => { + const pct = total ? (d.value / total) * 100 : 0; + const start = cumPct; + cumPct += pct; + return { ...d, pct, start, end: cumPct, color: colors[i % colors.length] }; + }); + + const gradient = segments + .map((s) => `${s.color} ${s.start}% ${s.end}%`) + .join(", "); + + return ( +
        + {/* Tab switcher */} +
        + {(["topic", "type", "difficulty"] as const).map((t) => ( + + ))} +
        + + {/* Pie */} +
        +
        +
        +
        + {hovered !== null ? ( +
        +
        {segments[hovered].value}
        +
        {segments[hovered].pct.toFixed(0)}%
        +
        + ) : ( +
        +
        {total}
        +
        total
        +
        + )} +
        +
        + + {/* Legend */} +
        + {segments.map((s, i) => ( +
        setHovered(i)} + onMouseLeave={() => setHovered(null)} + className={`flex items-center gap-2 px-2 py-1 rounded-lg cursor-default transition-colors ${ + hovered === i ? "bg-gray-50" : "" + }`} + > + + {s.label} + {s.value} +
        + ))} +
        +
        +
        + ); +} + +// ── Shared components ── +function QuestionCard({ question: q }: { question: QItem }) { + const typeColor = TYPE_COLORS[q.question_type] ?? "bg-gray-50 text-gray-600 border-gray-200"; + const cleanPreview = (q.preview || "") + .replace(/^Problem\s+\d+\s*\[.*?\]\s*/i, "") + .replace(/^(True\/False Questions?\s*)?Indicate whether.*?(answer\.\s*)/i, "") + .trim(); + + return ( + + + {q.question_number} + +
        +
        + {q.source} + · + + {typeLabel[q.question_type] ?? q.question_type} + + {q.difficulty && ( + <> + · + + {q.difficulty} + + + )} + {q.topics?.slice(0, 2).map((t) => ( + {t} + ))} +
        +

        {cleanPreview || q.preview}

        +
        + + + ); +} + +function FilterRow({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
        + {label} + {children} +
        + ); +} + +function Pill({ label, active, color, onClick }: { label: string; active: boolean; color?: string; onClick: () => void }) { + return ( + + ); +} + +function KpiCard({ label, value }: { label: string; value: string | number }) { + return ( +
        +
        {value}
        +
        {label}
        +
        + ); +} + +function Panel({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
        +

        {title}

        + {children} +
        + ); +} + +function TopicCombobox({ topics, value, onChange }: { topics: string[]; value: string | null; onChange: (v: string | null) => void }) { + const [input, setInput] = useState(""); + const [open, setOpen] = useState(false); + + const filtered = useMemo(() => { + const q = input.toLowerCase(); + return q ? topics.filter((t) => t.toLowerCase().includes(q)) : topics; + }, [topics, input]); + + const handleSelect = (t: string | null) => { + onChange(t); + setInput(t ?? ""); + setOpen(false); + }; + + return ( +
        +
        + { setInput(e.target.value); setOpen(true); if (!e.target.value) onChange(null); }} + onFocus={() => setOpen(true)} + placeholder="All Topics" + className="text-xs border border-gray-200 rounded-lg px-2 py-1.5 bg-white focus:outline-none focus:ring-1 focus:ring-blue-400 w-48" + /> + {value && ( + + )} +
        + {open && filtered.length > 0 && ( +
        + {filtered.map((t) => ( + + ))} +
        + )} + {open &&
        setOpen(false)} />} +
        + ); +} + +function DiffStat({ label, value }: { label: string; value: number }) { + return ( +
        +
        {value}
        +
        {label}
        +
        + ); +} diff --git a/frontend/src/pages/ErrorBookPage.tsx b/frontend/src/pages/ErrorBookPage.tsx new file mode 100644 index 0000000..0e3529c --- /dev/null +++ b/frontend/src/pages/ErrorBookPage.tsx @@ -0,0 +1,296 @@ +import { useEffect, useMemo, useState } from "react"; +import { Link } from "react-router-dom"; + +import Header from "@/components/layout/Header"; +import KaTeXRenderer from "@/components/shared/KaTeXRenderer"; +import { getErrorBook, updateAttempt, getFavoriteVariants, updateVariant } from "@/lib/api"; +import { useAuth } from "@/contexts/AuthContext"; +import type { UserAttempt, QuestionVariant } from "@/types/api"; + +const typeLabel: Record = { + mc: "Multiple Choice", + true_false: "True / False", + fill_blank: "Fill in Blank", + long_question: "Long Question", + short_answer: "Short Answer", + coding: "Coding", +}; + +const TYPE_COLORS: Record = { + mc: "bg-violet-50 text-violet-700", + true_false: "bg-amber-50 text-amber-700", + fill_blank: "bg-teal-50 text-teal-700", + long_question: "bg-sky-50 text-sky-700", + short_answer: "bg-rose-50 text-rose-700", + coding: "bg-emerald-50 text-emerald-700", +}; + +const DIFF_COLORS: Record = { + easy: "text-green-600", + medium: "text-amber-600", + hard: "text-red-600", +}; + +export default function ErrorBookPage() { + const { user } = useAuth(); + const [entries, setEntries] = useState([]); + const [favoriteVariants, setFavoriteVariants] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [courseFilter, setCourseFilter] = useState("all"); + + useEffect(() => { + if (!user) { setLoading(false); return; } + let cancelled = false; + setLoading(true); + Promise.all([getErrorBook(), getFavoriteVariants()]) + .then(([attempts, variants]) => { + if (cancelled) return; + setEntries(attempts); + setFavoriteVariants(variants); + setLoading(false); + }) + .catch((err) => { + if (cancelled) return; + setError(err instanceof Error ? err.message : "Failed to load error book"); + setLoading(false); + }); + return () => { cancelled = true; }; + }, [user]); + + const courses = useMemo( + () => Array.from(new Set( + entries.map((e) => e.paper_questions?.paper?.course_code).filter((v): v is string => Boolean(v)), + )).sort(), + [entries], + ); + + const filteredEntries = useMemo(() => { + if (courseFilter === "all") return entries; + return entries.filter((e) => e.paper_questions?.paper?.course_code === courseFilter); + }, [courseFilter, entries]); + + async function handleMarkMastered(attemptId: string) { + await updateAttempt(attemptId, { mastered: true }); + setEntries((prev) => prev.filter((e) => e.id !== attemptId)); + } + + async function handleRemove(attemptId: string) { + await updateAttempt(attemptId, { in_error_book: false }); + setEntries((prev) => prev.filter((e) => e.id !== attemptId)); + } + + async function handleUnfavoriteVariant(variantId: string) { + await updateVariant(variantId, { favorited: false }); + setFavoriteVariants((prev) => prev.filter((v) => v.id !== variantId)); + } + + return ( +
        +
        +
        + {/* Header */} +
        +
        +

        Error Book

        +

        Review your mistakes and track progress.

        +
        +
        + + +
        +
        + + {/* Course filter */} +
        + setCourseFilter("all")} label="All" /> + {courses.map((c) => ( + setCourseFilter(c)} label={c} /> + ))} +
        + + {!user && ( +
        +
        🔒
        +

        Sign in to unlock your Error Book

        + + Sign in + +
        + )} + {user && loading &&
        Loading...
        } + {user && error &&
        {error}
        } + + {user && !loading && !error && filteredEntries.length === 0 && favoriteVariants.length === 0 && ( +
        +
        🎉
        +

        No mistakes yet. Keep practicing!

        +
        + )} + + {/* Saved variants */} + {favoriteVariants.length > 0 && ( +
        +

        + Saved Variants ({favoriteVariants.length}) +

        +
        + {favoriteVariants.map((v) => ( +
        + +
        + Variant of Q{v.source_question_number} +

        {v.variant_data.question_text?.replace(/<[^>]*>/g, "").slice(0, 100)}

        +
        + +
        + ))} +
        +
        + )} + + {/* Error entries */} +
        + {filteredEntries.map((entry) => ( + void handleMarkMastered(entry.id)} + onRemove={() => void handleRemove(entry.id)} + /> + ))} +
        +
        +
        + ); +} + +function ErrorCard({ entry, onMastered, onRemove }: { entry: UserAttempt; onMastered: () => void; onRemove: () => void }) { + const [showFeedback, setShowFeedback] = useState(true); + const question = entry.paper_questions; + if (!question) return null; + + const courseCode = question.paper?.course_code; + const paperId = question.paper?.id; + const paper = question.paper; + const paperInfo = paper ? `${paper.year} ${paper.term} ${paper.exam_type}` : ""; + const typeColor = TYPE_COLORS[question.question_type] ?? "bg-gray-100 text-gray-600"; + const diffColor = DIFF_COLORS[question.difficulty ?? ""] ?? ""; + + // Clean preview: strip boilerplate + const preview = (question.question_text || "") + .replace(/^Problem\s+\d+\s*\[.*?\]\s*/i, "") + .slice(0, 200); + + return ( +
        + {/* Header */} +
        +
        +
        + + {question.question_number} + +
        +
        + + {typeLabel[question.question_type] ?? question.question_type} + + {question.difficulty && ( + {question.difficulty} + )} + {courseCode && ( + + {courseCode} + + )} +
        +
        + {paperId ? {paperInfo} : paperInfo} + {" · "} + {new Date(entry.created_at).toLocaleDateString("en-CA")} +
        +
        +
        + + {/* Score badge */} + {entry.feedback && ( +
        + + Incorrect +
        + )} +
        + + {/* Question preview */} +

        {preview}

        + + {/* Topics */} + {question.topics && question.topics.length > 0 && ( +
        + {question.topics.slice(0, 4).map((t) => ( + {t} + ))} +
        + )} +
        + + {/* AI Feedback section */} + {entry.feedback && ( +
        + + {showFeedback && ( +
        + +
        + )} +
        + )} + + {/* Actions */} +
        + {paperId && ( + + Open paper → + + )} + + +
        +
        + ); +} + +function StatCard({ label, value, color }: { label: string; value: number; color: string }) { + const bg = color === "red" ? "bg-red-50 border-red-200" : "bg-blue-50 border-blue-200"; + const text = color === "red" ? "text-red-700" : "text-blue-700"; + return ( +
        +
        {value}
        +
        {label}
        +
        + ); +} + +function Pill({ active, onClick, label }: { active: boolean; onClick: () => void; label: string }) { + return ( + + ); +} diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx new file mode 100644 index 0000000..0b55eb8 --- /dev/null +++ b/frontend/src/pages/HomePage.tsx @@ -0,0 +1,705 @@ +import { useEffect, useRef, useState } from "react"; +import { Link, useNavigate } from "react-router-dom"; +import { listPapers, myPapers } from "@/lib/api"; +import { useAuth } from "@/contexts/AuthContext"; +import type { Paper } from "@/types/api"; + +function getWorkedIds(userId: string): string[] { + try { + const raw = localStorage.getItem(`worked_papers_${userId}`); + return raw ? JSON.parse(raw) : []; + } catch { return []; } +} + +const fontSora = { fontFamily: "'Sora', sans-serif" }; +const fontMono = { fontFamily: "'IBM Plex Mono', monospace" }; + +/* ── Feature cards data ── */ +const FEATURES = [ + { + icon: ( + + + + ), + title: "AI Analysis", + desc: "Every question gets knowledge reminders, hints, and step-by-step solutions.", + color: "#6366F1", + }, + { + icon: ( + + + + ), + title: "Smart Error Book", + desc: "Auto-collect mistakes with AI feedback. Review, understand, and master.", + color: "#E11D48", + }, + { + icon: ( + + + + ), + title: "Course Analytics", + desc: "Topic frequency, difficulty distribution, and high-yield focus areas.", + color: "#0D9488", + }, + { + icon: ( + + + + ), + title: "Variant Generation", + desc: "Generate unlimited similar questions for extra practice on weak topics.", + color: "#7C3AED", + }, +]; + +/* ── Filter options ── */ +const COURSE_OPTIONS = ["COMP2011", "COMP2211", "MATH1014", "PHYS1112", "MATH2023", "ELEC2100"]; +const TERM_OPTIONS = ["spring", "fall"]; +const TYPE_OPTIONS = ["midterm", "final"]; + +/* ── Chevron SVG ── */ +function ChevronDown({ className = "" }: { className?: string }) { + return ( + + + + ); +} + +/* ── Dropdown select component ── */ +function Dropdown({ + label, + value, + options, + onChange, +}: { + label: string; + value: string | null; + options: { value: string; label: string }[]; + onChange: (v: string | null) => void; +}) { + const [open, setOpen] = useState(false); + const ref = useRef(null); + + useEffect(() => { + const handler = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false); + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, []); + + const selected = options.find((o) => o.value === value); + + return ( +
        +
        + {label} +
        + + {open && ( +
        + + {options.map((o) => ( + + ))} +
        + )} +
        + ); +} + +export default function HomePage() { + const navigate = useNavigate(); + const { user, signOut } = useAuth(); + const [papers, setPapers] = useState([]); + const [papersLoading, setPapersLoading] = useState(false); + const [myUploadedPapers, setMyUploadedPapers] = useState([]); + const [workedPapers, setWorkedPapers] = useState([]); + const [courseInput, setCourseInput] = useState(""); + const [courseFilter, setCourseFilter] = useState(null); + const [showSuggestions, setShowSuggestions] = useState(false); + const [termFilter, setTermFilter] = useState(null); + const [typeFilter, setTypeFilter] = useState(null); + const [analyzing, setAnalyzing] = useState(false); + const inputRef = useRef(null); + + // Autocomplete suggestions + const suggestions = courseInput.trim() + ? COURSE_OPTIONS.filter((c) => + c.toLowerCase().includes(courseInput.trim().toLowerCase()) + ) + : []; + + // Close suggestions on outside click + useEffect(() => { + const handler = (e: MouseEvent) => { + if (inputRef.current && !inputRef.current.contains(e.target as Node)) setShowSuggestions(false); + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, []); + + useEffect(() => { + let cancelled = false; + setPapersLoading(true); + listPapers() + .then((data) => { + if (cancelled) return; + setPapers( + data.sort((a, b) => { + if (a.course_code !== b.course_code) return a.course_code.localeCompare(b.course_code); + if (a.year !== b.year) return b.year - a.year; + if (a.term !== b.term) return a.term.localeCompare(b.term); + return a.exam_type.localeCompare(b.exam_type); + }), + ); + }) + .catch(() => { + if (!cancelled) setPapers([]); + }) + .finally(() => { + if (!cancelled) setPapersLoading(false); + }); + + return () => { + cancelled = true; + }; + }, []); + + // My Papers + useEffect(() => { + if (!user) return; + let cancelled = false; + myPapers().then((data) => { + if (cancelled) return; + setMyUploadedPapers(data.filter((p) => p.status !== "error")); + }).catch(() => {}); + return () => { cancelled = true; }; + }, [user]); + + useEffect(() => { + if (!user || papers.length === 0) return; + const workedIds = new Set(getWorkedIds(user.id)); + setWorkedPapers(papers.filter((p) => workedIds.has(p.id))); + }, [user, papers]); + + // Filter papers + const hasFilter = courseFilter || termFilter || typeFilter; + const filteredPapers = papers.filter((p) => { + if (courseFilter && p.course_code !== courseFilter) return false; + if (termFilter && p.term !== termFilter) return false; + if (typeFilter && p.exam_type !== typeFilter) return false; + return true; + }); + + const selectCourse = (code: string) => { + setCourseInput(code); + setCourseFilter(code); + setShowSuggestions(false); + }; + + return ( +
        + {/* ══════ Nav ══════ */} + + + {/* ══════ Hero + Filter ══════ */} +
        +
        +

        + The Smartest Way to
        + Master Past Papers +

        +

        + Upload any HKUST past paper. AI breaks down every question with analysis, + hints, and solutions — so you study smarter, not harder. +

        + + {/* ── Filter row: Course input + Term dropdown + Type dropdown ── */} +
        +
        + {/* Course code input with autocomplete */} +
        +
        + Course Code +
        +
        + { + const v = e.target.value.toUpperCase(); + setCourseInput(v); + setCourseFilter(COURSE_OPTIONS.includes(v) ? v : null); + setShowSuggestions(true); + }} + onFocus={() => setShowSuggestions(true)} + placeholder="e.g. COMP2011" + className="flex-1 px-3.5 py-2.5 text-sm text-slate-800 outline-none bg-transparent font-semibold" + style={fontMono} + /> + {courseInput && ( + + )} +
        + {/* Autocomplete dropdown */} + {showSuggestions && suggestions.length > 0 && !courseFilter && ( +
        + {suggestions.map((c) => ( + + ))} +
        + )} +
        + + {/* Term dropdown */} + + + {/* Exam Type dropdown */} + + + {/* Buttons */} +
        +
        +
        + +
        +
        +
        + +
        +
        +
        + + {/* ── Results panel ── */} + {hasFilter && ( +
        + {papersLoading ? ( +
        +

        Loading papers...

        +
        + ) : filteredPapers.length === 0 ? ( +
        +

        No papers match these filters

        +
        + ) : ( + <> +
        + + {filteredPapers.length} paper{filteredPapers.length > 1 ? "s" : ""} found + + {courseFilter && ( + + + + + AI Analytics · {courseFilter} + + )} +
        + {filteredPapers.map((p) => ( + + ))} + + )} +
        + )} +
        + + {/* Quick stats — real data */} +
        + {[ + [String(papers.filter(p => p.status === "ready").length), "Past Papers"], + [String(papers.reduce((s, p) => s + (p.question_count || 0), 0)), "Questions Analyzed"], + [String(new Set(papers.filter(p => p.status === "ready").map(p => p.course_code)).size), "Courses"], + ].map(([num, label]) => ( +
        +
        {num}
        +
        {label}
        +
        + ))} +
        +
        + + {/* Decorative grid */} +
        +
        + +
        + {/* ══════ Features ══════ */} +
        +

        + Platform Features +

        +
        + {FEATURES.map((f) => ( +
        +
        + {f.icon} +
        +

        + {f.title} +

        +

        + {f.desc} +

        +
        + ))} +
        +
        + + {/* ══════ My Papers ══════ */} + {user && ( +
        +

        + My Papers +

        + {myUploadedPapers.length === 0 && workedPapers.length === 0 ? ( +
        +

        No papers yet. Upload a past paper or open one to get started.

        +
        + ) : ( +
        + {/* Uploaded */} + {myUploadedPapers.length > 0 && ( +
        +
        + Uploaded +
        +
        + {myUploadedPapers.map((p) => ( + +
        + {p.course_code} + {p.year} {p.term} {p.exam_type} +
        + + {p.status === "processing" ? ( + + + PROCESSING + + ) : p.status.toUpperCase()} + + + ))} +
        +
        + )} + + {/* Worked on */} + {workedPapers.length > 0 && ( +
        +
        + Recently Worked +
        +
        + {workedPapers.map((p) => ( + +
        + {p.course_code} + {p.year} {p.term} {p.exam_type} +
        + + + + + ))} +
        +
        + )} +
        + )} +
        + )} + + {/* ══════ CTA Banner ══════ */} +
        +
        +
        +

        + Ready to ace your exams? +

        +

        + Upload a past paper and let AI do the heavy lifting. +

        +
        +
        + + Upload Paper + + + View Analytics + +
        +
        +
        +
        + + {/* ══════ Footer ══════ */} +
        +
        + + PastPaper Master · HKUST · 2025 + +
        + About + Contact + Privacy +
        +
        +
        +
        + ); +} diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx new file mode 100644 index 0000000..bdd06e3 --- /dev/null +++ b/frontend/src/pages/LoginPage.tsx @@ -0,0 +1,90 @@ +import { useState } from "react"; +import { supabase } from "@/lib/supabase"; + +export default function LoginPage() { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [mode, setMode] = useState<"signin" | "signup">("signin"); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setLoading(true); + try { + if (mode === "signin") { + const { error } = await supabase.auth.signInWithPassword({ email, password }); + if (error) throw error; + } else { + const { error } = await supabase.auth.signUp({ email, password }); + if (error) throw error; + // Auto sign in after signup (requires email confirm disabled in Supabase dashboard) + const { error: signInError } = await supabase.auth.signInWithPassword({ email, password }); + if (signInError) throw signInError; + } + } catch (err: unknown) { + setError(err instanceof Error ? err.message : "Something went wrong"); + } finally { + setLoading(false); + } + }; + + return ( +
        +
        +
        +

        PastPaper Master

        +

        {mode === "signin" ? "Sign in to continue" : "Create your account"}

        +
        + +
        +
        + + setEmail(e.target.value)} + required + className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + placeholder="you@example.com" + /> +
        +
        + + setPassword(e.target.value)} + required + minLength={6} + className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" + placeholder="••••••" + /> +
        + + {error && ( +

        {error}

        + )} + + +
        + +

        + {mode === "signin" ? "No account? " : "Already have one? "} + +

        +
        +
        + ); +} diff --git a/frontend/src/pages/UploadPage.tsx b/frontend/src/pages/UploadPage.tsx new file mode 100644 index 0000000..453b6e8 --- /dev/null +++ b/frontend/src/pages/UploadPage.tsx @@ -0,0 +1,16 @@ +import Header from "@/components/layout/Header"; +import UploadForm from "@/components/upload/UploadForm"; + +export default function UploadPage() { + return ( +
        +
        +
        +

        + Upload Past Paper +

        + +
        +
        + ); +} diff --git a/frontend/src/pages/WorkbenchPage.tsx b/frontend/src/pages/WorkbenchPage.tsx new file mode 100644 index 0000000..25c6c98 --- /dev/null +++ b/frontend/src/pages/WorkbenchPage.tsx @@ -0,0 +1,524 @@ +import { useState, useEffect, useCallback, useRef } from "react"; +import { useParams } from "react-router-dom"; +import Header from "@/components/layout/Header"; +import PdfViewer from "@/components/workbench/PdfViewer"; +import QuestionNav from "@/components/workbench/QuestionNav"; +import QuestionDetail from "@/components/workbench/QuestionDetail"; +import AiTrioPanel from "@/components/workbench/AiTrioPanel"; +import SimilarHistoryPanel from "@/components/workbench/SimilarHistoryPanel"; +import ActionBar from "@/components/workbench/ActionBar"; +import PhotoUpload from "@/components/workbench/PhotoUpload"; +import VariantDetail from "@/components/workbench/VariantDetail"; +import KaTeXRenderer from "@/components/shared/KaTeXRenderer"; +import { usePaper } from "@/hooks/usePaper"; +import { useQuestions } from "@/hooks/useQuestions"; +import { generateVariant, getVariants, updateVariant, deleteVariant, recordAttempt, getPaperAttempts } from "@/lib/api"; +import { groupQuestions } from "@/lib/questionGroups"; +import { useAuth } from "@/contexts/AuthContext"; +import type { QuestionVariant } from "@/types/api"; + +const WORKED_KEY = (userId: string) => `worked_papers_${userId}`; +const WORKED_THRESHOLD_MS = 3 * 60 * 1000; // 3 minutes + +function markWorked(userId: string, paperId: string) { + try { + const raw = localStorage.getItem(WORKED_KEY(userId)); + const ids: string[] = raw ? JSON.parse(raw) : []; + if (!ids.includes(paperId)) { + localStorage.setItem(WORKED_KEY(userId), JSON.stringify([...ids, paperId])); + } + } catch { /* silent */ } +} + +export default function WorkbenchPage() { + const { id } = useParams<{ id: string }>(); + const { user } = useAuth(); + const { paper, loading: paperLoading, error: paperError } = usePaper(id!); + const isReady = paper?.status === "ready"; + const { questions, loading: questionsLoading } = useQuestions(id!, isReady); + const [currentQuestionId, setCurrentQuestionId] = useState(null); + const [showPhoto, setShowPhoto] = useState(false); + // Grading result per question + const [gradingResults, setGradingResults] = useState>(new Map()); + // Track which grading panels are expanded + const [gradingExpanded, setGradingExpanded] = useState>(new Set()); + + // Tab state + const [activeTab, setActiveTab] = useState<"questions" | "variants">("questions"); + // variants per question: questionId → QuestionVariant[] + const [variantMap, setVariantMap] = useState>(new Map()); + // which question IDs have been fetched from server + const loadedRef = useRef>(new Set()); + // generating state + const [isGenerating, setIsGenerating] = useState(false); + // Currently viewing variant (full detail view) + const [activeVariantId, setActiveVariantId] = useState(null); + + // Cooldown: ignore scroll-based updates for 2s after user clicks a question + const lastUserSelectTime = useRef(0); + + const handleQuestionSelect = useCallback((questionId: string) => { + lastUserSelectTime.current = Date.now(); + setCurrentQuestionId(questionId); + }, []); + + const groups = groupQuestions(questions); + const currentQuestion = + questions.find((question) => question.id === currentQuestionId) + ?? questions[0] + ?? null; + const currentGroupKey = currentQuestion?.question_number.match(/^\d+/)?.[0] ?? null; + const paperTitle = paper + ? `${paper.year} ${paper.term} ${paper.exam_type}` + : undefined; + + const currentVariants = variantMap.get(currentQuestion?.id ?? "") ?? []; + const activeVariant = currentVariants.find((v) => v.id === activeVariantId) ?? null; + + const handleGroupSelect = useCallback((groupKey: string) => { + lastUserSelectTime.current = Date.now(); + const group = groups.find((item) => item.key === groupKey); + if (group?.questions[0]) { + setCurrentQuestionId(group.questions[0].id); + } + }, [groups]); + + useEffect(() => { + if (questions.length === 0) { + setCurrentQuestionId(null); + return; + } + setCurrentQuestionId((prev) => + prev && questions.some((question) => question.id === prev) ? prev : questions[0].id, + ); + }, [questions]); + + // 3-minute worked tracking + useEffect(() => { + if (!id || !user) return; + const timer = setTimeout(() => markWorked(user.id, id), WORKED_THRESHOLD_MS); + return () => clearTimeout(timer); + }, [id, user]); + + // Load historical grading results + useEffect(() => { + if (!id || !user || !isReady) return; + getPaperAttempts(id).then((attempts) => { + const map = new Map(); + for (const a of attempts) { + map.set(a.question_id, { + isCorrect: a.is_correct, + feedback: a.feedback || "", + ocrText: a.photo_ocr_text || "", + }); + } + if (map.size > 0) { + setGradingResults((prev) => { + const next = new Map(prev); + for (const [k, v] of map) { + if (!next.has(k)) next.set(k, v); // don't overwrite current session + } + return next; + }); + setGradingExpanded(new Set(map.keys())); + } + }).catch(() => {}); + }, [id, user, isReady]); + + // Load variants for current question (once per question ID) + useEffect(() => { + if (!currentQuestionId || loadedRef.current.has(currentQuestionId)) return; + loadedRef.current.add(currentQuestionId); + getVariants(currentQuestionId) + .then((data) => { + setVariantMap((prev) => new Map(prev).set(currentQuestionId, data)); + }) + .catch(() => {}); + }, [currentQuestionId]); + + // When user scrolls PDF, find the question closest to that page + // But ignore if user just clicked a question (2s cooldown) + const handlePdfPageChange = useCallback( + (page: number) => { + if (questions.length === 0) return; + if (Date.now() - lastUserSelectTime.current < 2000) return; + let best = questions[0]; + for (let i = 0; i < questions.length; i++) { + if ((questions[i].page_number ?? 1) <= page) best = questions[i]; + } + setCurrentQuestionId(best.id); + }, + [questions], + ); + + // Track answer state per question for ActionBar feedback + const [answerStates, setAnswerStates] = useState>(new Map()); + + const handleAnswerResult = async (isCorrect: boolean, userAnswer: string) => { + if (!currentQuestion) return; + const state = isCorrect ? "correct" : "wrong"; + setAnswerStates((prev) => new Map(prev).set(currentQuestion.id, state)); + try { + const type = currentQuestion.question_type === "mc" ? "select" : "input"; + await recordAttempt(currentQuestion.id, type, userAnswer, isCorrect); + // Wrong answer → auto generate variant + if (!isCorrect) { + handleGenerateVariant(); + } + } catch { + // silent + } + }; + + const handleGenerateVariant = async () => { + if (!currentQuestion || isGenerating) return; + setIsGenerating(true); + setActiveTab("variants"); + try { + const saved = await generateVariant(currentQuestion.id); + setVariantMap((prev) => { + const existing = prev.get(currentQuestion.id) ?? []; + return new Map(prev).set(currentQuestion.id, [saved, ...existing]); + }); + } catch { + // silent + } finally { + setIsGenerating(false); + } + }; + + const handleToggleFavorite = async (v: QuestionVariant) => { + const updated = await updateVariant(v.id, { favorited: !v.favorited }); + setVariantMap((prev) => { + const existing = prev.get(v.source_question_id) ?? []; + return new Map(prev).set( + v.source_question_id, + existing.map((item) => (item.id === v.id ? updated : item)), + ); + }); + }; + + const handleDeleteVariant = async (v: QuestionVariant) => { + await deleteVariant(v.id); + if (activeVariantId === v.id) setActiveVariantId(null); + setVariantMap((prev) => { + const existing = prev.get(v.source_question_id) ?? []; + return new Map(prev).set( + v.source_question_id, + existing.filter((item) => item.id !== v.id), + ); + }); + }; + + if (paperLoading) { + return ( +
        +
        Loading...
        +
        + ); + } + + if (paperError || !paper) { + return ( +
        +
        {paperError ?? "Paper not found"}
        +
        + ); + } + + return ( +
        +
        + + {/* Processing overlay */} + {paper.status === "processing" && ( +
        +
        +
        +

        AI is analyzing the paper...

        +

        + {paper.question_count + ? `${paper.question_count} questions found, generating analysis...` + : "Extracting and structuring questions..."} +

        +
        +
        + )} + + {/* Error state */} + {paper.status === "error" && ( +
        +
        +

        Processing Failed

        +

        {paper.error_message}

        +
        +
        + )} + + {/* Ready — workbench */} + {paper.status === "ready" && ( +
        + {/* Left: PDF viewer */} +
        + +
        + + {/* Right: analysis panel */} +
        + {questionsLoading ? ( +
        + Loading questions... +
        + ) : activeVariantId && activeVariant ? ( + /* ===== Variant Detail View ===== */ + <> + +
        + +
        + + ) : ( + /* ===== Normal Tab View ===== */ + <> + {/* Tab bar */} +
        + + +
        + + {/* Question nav — always visible */} + + + {/* Questions tab content */} + {activeTab === "questions" && ( + <> +
        + {currentQuestion && ( + <> + + {/* Grading result panel */} + {gradingResults.has(currentQuestion.id) && (() => { + const gr = gradingResults.get(currentQuestion.id)!; + const expanded = gradingExpanded.has(currentQuestion.id); + const toggleExpand = () => setGradingExpanded((prev) => { + const next = new Set(prev); + next.has(currentQuestion.id) ? next.delete(currentQuestion.id) : next.add(currentQuestion.id); + return next; + }); + + if (gr.loading) { + return ( +
        +
        + + Grading your answer... +
        +
        + ); + } + + return ( +
        + + {expanded && ( +
        + {gr.ocrText && ( +
        + Your Answer (OCR) +
        + ")} className="text-xs text-gray-700" /> +
        +
        + )} + +
        + )} +
        + ); + })()} + + + + )} +
        + setShowPhoto(true)} + answerState={currentQuestion ? answerStates.get(currentQuestion.id) ?? null : null} + /> + + )} + + {/* Variants tab content */} + {activeTab === "variants" && ( +
        +
        + +
        + + {currentVariants.length === 0 && !isGenerating ? ( +
        +

        No variants yet for this question.

        +
        + ) : ( +
        + {currentVariants.map((v) => ( +
        +
        + + {new Date(v.created_at).toLocaleDateString("en-CA")} + +
        + + +
        +
        +

        + {v.variant_data.question_text?.replace(/<[^>]*>/g, "").slice(0, 140)} +

        + +
        + ))} +
        + )} +
        + )} + + )} +
        +
        + )} + + {/* Photo upload modal */} + {showPhoto && currentQuestion && (() => { + const qid = currentQuestion.id; + return ( + setShowPhoto(false)} + onSubmitted={async (promise) => { + // Set loading state + setGradingResults((prev) => new Map(prev).set(qid, { isCorrect: false, feedback: "", ocrText: "", loading: true })); + setGradingExpanded((prev) => new Set(prev).add(qid)); + try { + const res = await promise; + const { is_correct, feedback, score_given } = res.grade; + setGradingResults((prev) => new Map(prev).set(qid, { + isCorrect: is_correct, + feedback, + ocrText: res.ocr_text, + scoreGiven: score_given, + loading: false, + })); + // Wrong → auto generate variant + if (!is_correct) { + handleGenerateVariant(); + } + } catch { + setGradingResults((prev) => new Map(prev).set(qid, { + isCorrect: false, + feedback: "Grading failed. Please try again.", + ocrText: "", + loading: false, + })); + } + }} + /> + ); + })()} +
        + ); +} diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css new file mode 100644 index 0000000..2990743 --- /dev/null +++ b/frontend/src/styles/globals.css @@ -0,0 +1,79 @@ +@import "tailwindcss"; +@import "katex/dist/katex.min.css"; + +/* ── Google Fonts: Sora (headings) + IBM Plex Mono (data) ── */ +@import url("https://fonts.googleapis.com/css2?family=Sora:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap"); + +/* Hide scrollbar on horizontal tab rows */ +.hide-scrollbar { -ms-overflow-style: none; scrollbar-width: none; } +.hide-scrollbar::-webkit-scrollbar { display: none; } + +/* ── Knowledge Base HTML content styling (from SOS project) ── */ +.kb-html-content h1 { font-size: 1.25rem; font-weight: 700; margin: 0.75rem 0 0.5rem; line-height: 1.3; } +.kb-html-content h2 { font-size: 1.1rem; font-weight: 600; margin: 0.75rem 0 0.4rem; color: #1e40af; border-bottom: 1px solid #e5e7eb; padding-bottom: 0.25rem; } +.kb-html-content h3 { font-size: 0.95rem; font-weight: 600; margin: 0.6rem 0 0.3rem; color: #374151; } +.kb-html-content h4 { font-size: 0.875rem; font-weight: 600; margin: 0.5rem 0 0.25rem; color: #6b7280; } +.kb-html-content p { margin: 0.3rem 0; line-height: 1.6; } +.kb-html-content p.summary { background: #eff6ff; border-left: 3px solid #3b82f6; padding: 0.5rem 0.75rem; border-radius: 0 0.25rem 0.25rem 0; color: #1e3a5f; margin-bottom: 0.75rem; } +.kb-html-content ul, .kb-html-content ol { margin: 0.3rem 0 0.3rem 1.25rem; line-height: 1.6; } +.kb-html-content ul { list-style: disc; } +.kb-html-content ol { list-style: decimal; } +.kb-html-content li { margin: 0.15rem 0; } +.kb-html-content strong { font-weight: 600; color: #1e293b; } +.kb-html-content blockquote { border-left: 3px solid #d1d5db; padding: 0.4rem 0.75rem; margin: 0.4rem 0; background: #f9fafb; color: #4b5563; font-style: italic; border-radius: 0 0.25rem 0.25rem 0; } +.kb-html-content pre { background: #1e293b; color: #e2e8f0; padding: 0.75rem; border-radius: 0.375rem; overflow-x: auto; margin: 0.4rem 0; font-size: 0.8rem; } +.kb-html-content code { font-family: ui-monospace, monospace; font-size: 0.85em; } +.kb-html-content :not(pre) > code { background: #f1f5f9; padding: 0.1rem 0.3rem; border-radius: 0.2rem; color: #be185d; } +.kb-html-content table { border-collapse: collapse; width: 100%; margin: 0.4rem 0; font-size: 0.8rem; } +.kb-html-content th, .kb-html-content td { border: 1px solid #e5e7eb; padding: 0.35rem 0.5rem; text-align: left; } +.kb-html-content th { background: #f3f4f6; font-weight: 600; } +.kb-html-content section { margin: 0.5rem 0; } +.kb-html-content .tag { display: inline-block; background: #dbeafe; color: #1e40af; padding: 0.1rem 0.5rem; border-radius: 9999px; font-size: 0.75rem; margin: 0.15rem 0.15rem; } +.kb-html-content hr { border: none; border-top: 1px solid #e5e7eb; margin: 0.75rem 0; } + +/* ── Example blocks ── */ +.kb-html-content .example { background: #fffbeb; border: 1px solid #fbbf24; border-radius: 0.375rem; padding: 0.75rem; margin: 0.6rem 0; } +.kb-html-content .example-title { font-weight: 700; color: #92400e; margin-bottom: 0.4rem; font-size: 0.9rem; } +.kb-html-content .example-solution { border-top: 1px dashed #d97706; padding-top: 0.4rem; } + +/* ── LaTeX blocks ── */ +.kb-html-content pre.latex { background: #f8fafc; color: #1e293b; border: 1px solid #e2e8f0; text-align: center; font-size: 0.9rem; padding: 0.6rem; } +.kb-html-content code.latex { background: #f1f5f9; padding: 0.1rem 0.3rem; border-radius: 0.2rem; color: #4338ca; font-size: 0.85em; } + +/* ── Common error block (used in solution) ── */ +.kb-html-content .common-error { + background: #fef2f2; + border: 1px solid #fca5a5; + border-left: 3px solid #ef4444; + border-radius: 0.375rem; + padding: 0.6rem 0.75rem; + margin: 0.5rem 0; +} +.kb-html-content .common-error::before { + content: "⚠ Common Mistake"; + font-weight: 700; + color: #dc2626; + display: block; + margin-bottom: 0.3rem; + font-size: 0.85rem; +} + +/* ── Figure description blocks ── */ +.kb-html-content .figure-desc { + background: #faf5ff; + border: 1px solid #d8b4fe; + border-left: 3px solid #a855f7; + border-radius: 0.375rem; + padding: 0.6rem 0.75rem; + margin: 0.5rem 0; +} + +/* ── AI Supplement blocks ── */ +.kb-html-content .ai-supplement { + background: #f0fdf4; + border: 1px solid #86efac; + border-left: 3px solid #22c55e; + border-radius: 0.375rem; + padding: 0.6rem 0.75rem; + margin: 0.5rem 0; +} diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts new file mode 100644 index 0000000..aab08f7 --- /dev/null +++ b/frontend/src/types/api.ts @@ -0,0 +1,169 @@ +export interface Paper { + id: string; + user_id: string | null; + course_code: string; + year: number; + term: string; + exam_type: string; + paper_file_url: string; + answer_file_url: string | null; + status: "uploaded" | "processing" | "ready" | "error"; + error_message: string | null; + total_score: number | null; + question_count: number | null; + topics_summary: Record | null; + difficulty_level: string | null; + processing_step: string | null; + processing_progress: number; + processing_total: number; + created_at: string; + updated_at: string; +} + +export interface PaperSummary { + id: string; + course_code: string; + year: number; + term: string; + exam_type: string; + part_label: string | null; +} + +export interface Question { + id: string; + paper_id: string; + question_number: string; + parent_question: string | null; + display_order: number; + question_type: string; + question_format?: string | null; + question_text: string; + score: number | null; + page_number: number | null; + page_y_ratio?: number | null; + options: { label: string; text: string }[] | null; + correct_option: string | null; + correct_answer: string | null; + raw_answer_text: string | null; + topics: string[] | null; + topic_primary?: string | null; + analytics_topic?: string | null; + topic_tags?: string[] | null; + skill_tags?: string[] | null; + difficulty: string | null; + knowledge_reminder: string; + ai_hint: string; + solution: string; + created_at: string; + updated_at: string; + paper?: PaperSummary; +} + +export interface UploadResponse { + paper_id: string; + status: string; + message: string; +} + +export interface UserAttempt { + id: string; + user_id: string; + question_id: string; + attempt_type: string; + user_answer: string | null; + photo_url: string | null; + photo_ocr_text: string | null; + is_correct: boolean | null; + feedback: string | null; + error_at_step: number | null; + in_error_book: boolean; + mastered: boolean; + created_at: string; + paper_questions?: Question; + score_given?: number | null; +} + +export interface VariantQuestion { + question_text: string; + question_type: string; + options: { label: string; text: string }[] | null; + correct_answer: string; + ai_hint: string; + knowledge_reminder: string; + solution: string; +} + +export interface QuestionVariant { + id: string; + user_id: string; + source_question_id: string; + source_question_number: string; + variant_data: VariantQuestion; + favorited: boolean; + created_at: string; +} + +export interface GradeResult { + is_correct: boolean; + feedback: string; + error_at_step: number | null; +} + +export interface SimilarQuestion { + id: string; + paper_id: string; + source: string; + question_number: string; + match_percent: number; + match_reasons?: string[]; + question_type: Question["question_type"]; + question_text: string; + topics: string[]; + difficulty: string | null; + knowledge_reminder: string; + ai_hint: string; + solution: string; +} + +export interface AnalyticsTopicQuestion { + paper_id: string; + source: string; + question_number: string; + preview: string; + difficulty: string | null; + question_type: string; + year?: number | null; + term?: string | null; + exam_type?: string | null; + topics?: string[]; +} + +export interface AnalyticsTopicEntry { + label: string; + count: number; + pct: number; + questions: AnalyticsTopicQuestion[]; +} + +export interface CourseAnalytics { + course_code: string; + kpi: { + papers: number; + questions: number; + topics: number; + difficulty: string; + }; + topic_frequency: AnalyticsTopicEntry[]; + question_types: Array<{ + label: string; + count: number; + pct: number; + }>; + difficulty_distribution: { + easy: number; + medium: number; + hard: number; + }; + high_yield_topics: string[]; + all_questions: AnalyticsTopicQuestion[]; +} diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..b9bfd29 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..ecdcb3a --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import tailwindcss from "@tailwindcss/vite"; +import { resolve } from "path"; + +export default defineConfig({ + plugins: [react(), tailwindcss()], + resolve: { + alias: { + "@": resolve(__dirname, "src"), + }, + }, + server: { + port: 5173, + proxy: { + "/api": { + target: "http://localhost:8000", + changeOrigin: true, + }, + }, + }, +}); diff --git a/index 2.html b/index 2.html new file mode 100644 index 0000000..17c4ec9 --- /dev/null +++ b/index 2.html @@ -0,0 +1,22 @@ + + + + + + + PastpaperMaster + + + + +
        + + diff --git a/memory/MEMORY.md b/memory/MEMORY.md new file mode 100644 index 0000000..57e30ee --- /dev/null +++ b/memory/MEMORY.md @@ -0,0 +1,3 @@ +# Memory Index + +- [project_pastpaper_master.md](project_pastpaper_master.md) — PastPaper Master 项目概览与当前开发进度 diff --git a/memory/project_pastpaper_master.md b/memory/project_pastpaper_master.md new file mode 100644 index 0000000..10b03ac --- /dev/null +++ b/memory/project_pastpaper_master.md @@ -0,0 +1,37 @@ +--- +name: PastPaper Master 项目概览 +description: 项目技术栈、当前开发状态、已完成工作流及下一步优先级 +type: project +--- + +AI 辅助学习平台,支持 COMP2211 试卷练习。核心功能:题目工作台、AI 三件套、相似题推荐、错题本、变式题生成。 + +## 技术栈 +- Frontend: React 19 + TypeScript + Vite 7 + Tailwind v4 +- Backend: FastAPI + Python 3.12 + uv +- DB: Supabase PostgreSQL(RLS 已预留,当前用 temp user id) +- LLM: GPT-4o (laozhang proxy) + Qwen-plus fallback + +## 当前 DB 状态(2026-04-10) +COMP2211 共 7 份 status=ready 试卷,250 道 subquestion 级题目,均有 knowledge_reminder / ai_hint / solution / analytics_topic / topic_tags / skill_tags。 + +## 已完成的工作(本次 session) +**Workstream A:相似题检索 + 移除 demo fallback** +- `backend/app/routers/questions.py`: + - `skill_tags` 加入 SELECT 和 `question_topics()` 计算 + - 修复 `isinstance(target_score, int)` → `(int, float)` 支持 NUMERIC 小数分 + - `similarity_score()` 返回 `(score, reasons)` tuple + - 过滤阈值从 `<= 0` 改为 `< 10` + - 响应增加 `match_reasons` 字段 +- `frontend/src/types/api.ts`:`SimilarQuestion` 加 `match_reasons?: string[]` +- `frontend/src/components/workbench/SimilarHistoryPanel.tsx`:移除全部 demo fallback,改为真实 empty/error 状态,显示 match_reasons chip + +## 下一步优先级(来自 HANDOFF_COMP2211.md) +1. ✅ Workstream A: 相似题检索 + 移除 demo fallback — 已完成 +2. Workstream B: Analytics 深化(per-paper drill-down、topic 频率时序、高频话题) +3. Workstream C: LaTeX/KaTeX 渲染质量(集中归一化、剔除 OCR 噪声) +4. Workstream D: 用户上传去重(对比 course_library 已有试卷) +5. Workstream E: UI/UX pass(QuestionNav、状态 badge、workbench 层级) + +**Why:** HANDOFF 文档中建议的开发顺序,以数据稳定性为先。 +**How to apply:** 下次 session 从 Workstream B(Analytics 深化)开始。 diff --git a/pastpaper-scraper b/pastpaper-scraper new file mode 160000 index 0000000..36d4a45 --- /dev/null +++ b/pastpaper-scraper @@ -0,0 +1 @@ +Subproject commit 36d4a450cd4872c919b9a2933ce84ff3f8967226 diff --git a/pitch_script.md b/pitch_script.md new file mode 100644 index 0000000..9facbc0 --- /dev/null +++ b/pitch_script.md @@ -0,0 +1,25 @@ +# KnowIt Pitch — Product Demo (Pages 5-6, ~45s) + +## Transition In + +> Now let me show you the product. + +## Page 5 — Product Demo + +> This is PastPaper Master. Search any course, download past papers, and hit "AI Analyze" — our system reads every page, extracts each question, and generates knowledge reminders, hints, and full solutions automatically. +> +> It's powered by Gemini vision and DeepSeek, with a RAG pipeline connecting papers, recordings, and courseware. + +## Page 6 — Workflow + +> Here's the full student workflow. +> +> **Download** papers. **AI analysis** breaks down topics and difficulty. **Upload your answers** — AI grades them instantly with detailed feedback. +> +> Wrong answers go into your **mistake book**. AI generates **variant questions** on the same topic, plus retrieves **similar questions** from other exams. +> +> And **smart flashcards** auto-generated for quick revision — already live for pharmacology students. + +## Transition Out + +> One closed loop — find, practice, grade, review, master. Over to [name] on the market. diff --git a/supabase/migrations/001_init_schema.sql b/supabase/migrations/001_init_schema.sql new file mode 100644 index 0000000..11ab768 --- /dev/null +++ b/supabase/migrations/001_init_schema.sql @@ -0,0 +1,207 @@ +-- ============================================ +-- PastPaper Master — 初始数据库 Schema +-- Version: 001 +-- Date: 2025-03-11 +-- ============================================ + +-- 启用必要的扩展 +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +-- ============================================ +-- Table 1: papers — 上传的试卷 +-- ============================================ +CREATE TABLE papers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + + -- 元信息(用户上传时填写) + course_code TEXT NOT NULL, -- "COMP2011" + year INTEGER NOT NULL, -- 2024 + term TEXT NOT NULL CHECK (term IN ('fall', 'spring', 'summer')), + exam_type TEXT NOT NULL CHECK (exam_type IN ('midterm', 'final', 'quiz')), + + -- 文件 (Supabase Storage) + paper_file_url TEXT NOT NULL, -- 试卷 PDF + answer_file_url TEXT, -- 答案 PDF(可选) + + -- 处理状态 + status TEXT NOT NULL DEFAULT 'uploaded' + CHECK (status IN ('uploaded', 'processing', 'ready', 'error')), + error_message TEXT, -- 处理失败时的错误信息 + + -- 提取的原始文本(缓存) + paper_extracted_text TEXT, + answer_extracted_text TEXT, + + -- 整卷概览(AI 生成) + total_score INTEGER, + question_count INTEGER, + topics_summary JSONB, -- {"Linked List": 40, "Recursion": 30} + difficulty_level TEXT CHECK (difficulty_level IN ('easy', 'medium', 'hard')), + + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- ============================================ +-- Table 2: paper_questions — 逐题数据 +-- ============================================ +CREATE TABLE paper_questions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + paper_id UUID NOT NULL REFERENCES papers(id) ON DELETE CASCADE, + + -- 题目标识 + question_number TEXT NOT NULL, -- "1", "1a", "2b" + parent_question TEXT, -- 子题的父题号: "1a" → "1" + display_order INTEGER NOT NULL, -- 显示顺序 + + -- 题目内容 + question_type TEXT NOT NULL + CHECK (question_type IN ('mc', 'fill_blank', 'long_question')), + question_text TEXT NOT NULL, -- 题目原文 + score INTEGER, -- 分值 + page_number INTEGER, -- PDF 页码(左右联动) + + -- 选择题专用 + options JSONB, -- [{"label":"A","text":"..."},...] + correct_option TEXT, -- "B" + + -- 填空题专用 + correct_answer TEXT, -- 正确答案 + accept_variants TEXT[], -- 等价表达 ["O(nlogn)","O(n log n)"] + + -- 答案 PDF 提取的原始答案(所有题型) + raw_answer_text TEXT, + + -- 知识点标签 + topics TEXT[], -- ["Linked List","Pointer"] + difficulty TEXT CHECK (difficulty IN ('easy', 'medium', 'hard')), + + -- AI 三件套(HTML + KaTeX) + knowledge_reminder TEXT, -- 知识点 Reminder + ai_hint TEXT, -- AI Hint + solution TEXT, -- Solution(逐步 derivation) + + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- ============================================ +-- Table 3: user_attempts — 用户答题记录 +-- Phase 4 实现,先建好表结构 +-- ============================================ +CREATE TABLE user_attempts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + question_id UUID NOT NULL REFERENCES paper_questions(id) ON DELETE CASCADE, + + -- 用户的作答 + attempt_type TEXT NOT NULL + CHECK (attempt_type IN ('select', 'input', 'photo')), + user_answer TEXT, -- 选项 / 输入的答案 + photo_url TEXT, -- 上传的照片 + photo_ocr_text TEXT, -- OCR 识别结果 + + -- AI 判定 + is_correct BOOLEAN, + feedback TEXT, -- HTML — 逐步错误分析 + error_at_step INTEGER, -- 第几步开始错 + + -- 错题本 + in_error_book BOOLEAN NOT NULL DEFAULT false, + mastered BOOLEAN NOT NULL DEFAULT false, + + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- ============================================ +-- 索引 +-- ============================================ +CREATE INDEX idx_papers_user ON papers(user_id); +CREATE INDEX idx_papers_course ON papers(course_code); +CREATE INDEX idx_papers_status ON papers(status); + +CREATE INDEX idx_questions_paper ON paper_questions(paper_id); +CREATE INDEX idx_questions_type ON paper_questions(question_type); +CREATE INDEX idx_questions_topics ON paper_questions USING GIN(topics); + +CREATE INDEX idx_attempts_user ON user_attempts(user_id); +CREATE INDEX idx_attempts_question ON user_attempts(question_id); +CREATE INDEX idx_attempts_errorbook ON user_attempts(user_id) + WHERE in_error_book = true; + +-- ============================================ +-- RLS 策略 +-- ============================================ +ALTER TABLE papers ENABLE ROW LEVEL SECURITY; +ALTER TABLE paper_questions ENABLE ROW LEVEL SECURITY; +ALTER TABLE user_attempts ENABLE ROW LEVEL SECURITY; + +-- papers: 用户只能看自己上传的(以后加公共库时再调整) +CREATE POLICY "Users can view own papers" + ON papers FOR SELECT + USING (auth.uid() = user_id); + +CREATE POLICY "Users can insert own papers" + ON papers FOR INSERT + WITH CHECK (auth.uid() = user_id); + +CREATE POLICY "Users can update own papers" + ON papers FOR UPDATE + USING (auth.uid() = user_id); + +CREATE POLICY "Users can delete own papers" + ON papers FOR DELETE + USING (auth.uid() = user_id); + +-- paper_questions: 跟随 paper 的权限 +CREATE POLICY "Users can view questions of own papers" + ON paper_questions FOR SELECT + USING ( + EXISTS ( + SELECT 1 FROM papers + WHERE papers.id = paper_questions.paper_id + AND papers.user_id = auth.uid() + ) + ); + +-- service_role 用于后端写入 questions(处理管线用) +-- 前端不直接写 questions,通过 API 触发后端处理 + +-- user_attempts: 用户只能看/写自己的 +CREATE POLICY "Users can view own attempts" + ON user_attempts FOR SELECT + USING (auth.uid() = user_id); + +CREATE POLICY "Users can insert own attempts" + ON user_attempts FOR INSERT + WITH CHECK (auth.uid() = user_id); + +CREATE POLICY "Users can update own attempts" + ON user_attempts FOR UPDATE + USING (auth.uid() = user_id); + +-- ============================================ +-- updated_at 自动更新触发器 +-- ============================================ +CREATE OR REPLACE FUNCTION update_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = now(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER papers_updated_at + BEFORE UPDATE ON papers + FOR EACH ROW EXECUTE FUNCTION update_updated_at(); + +CREATE TRIGGER questions_updated_at + BEFORE UPDATE ON paper_questions + FOR EACH ROW EXECUTE FUNCTION update_updated_at(); + +-- ============================================ +-- Storage bucket +-- ============================================ +-- 在 Supabase Dashboard 中手动创建 bucket: "papers" +-- 或通过 API 创建(后端初始化时处理) diff --git a/supabase/migrations/002_course_library_fields.sql b/supabase/migrations/002_course_library_fields.sql new file mode 100644 index 0000000..cbb203c --- /dev/null +++ b/supabase/migrations/002_course_library_fields.sql @@ -0,0 +1,38 @@ +-- ============================================ +-- PastPaper Master — Shared course library fields +-- Version: 002 +-- Date: 2026-03-24 +-- ============================================ + +-- Shared library / canonical import metadata on papers +ALTER TABLE papers + ADD COLUMN IF NOT EXISTS source_kind TEXT NOT NULL DEFAULT 'user_upload' + CHECK (source_kind IN ('user_upload', 'course_library')), + ADD COLUMN IF NOT EXISTS source_exam_key TEXT, + ADD COLUMN IF NOT EXISTS part_label TEXT + CHECK (part_label IN ('A', 'B')), + ADD COLUMN IF NOT EXISTS source_question_filename TEXT, + ADD COLUMN IF NOT EXISTS source_answer_filename TEXT; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_papers_course_library_exam_key + ON papers(source_exam_key) + WHERE source_kind = 'course_library' AND source_exam_key IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_papers_course_lookup + ON papers(course_code, year, term, exam_type, part_label); + +-- Grading results should persist awarded score +ALTER TABLE user_attempts + ADD COLUMN IF NOT EXISTS score_given INTEGER; + +CREATE INDEX IF NOT EXISTS idx_attempts_errorbook_active + ON user_attempts(user_id, created_at DESC) + WHERE in_error_book = true AND mastered = false; + +-- The backend and frontend already support true_false; schema must match. +ALTER TABLE paper_questions + DROP CONSTRAINT IF EXISTS paper_questions_question_type_check; + +ALTER TABLE paper_questions + ADD CONSTRAINT paper_questions_question_type_check + CHECK (question_type IN ('mc', 'true_false', 'fill_blank', 'long_question')); diff --git a/supabase/migrations/003_question_taxonomy_fields.sql b/supabase/migrations/003_question_taxonomy_fields.sql new file mode 100644 index 0000000..08de76e --- /dev/null +++ b/supabase/migrations/003_question_taxonomy_fields.sql @@ -0,0 +1,41 @@ +-- ============================================ +-- PastPaper Master — Question taxonomy fields +-- Version: 003 +-- Date: 2026-03-24 +-- ============================================ + +-- A question needs multiple classification layers: +-- 1) question_format: how the student interacts with it +-- 2) topic_tags / topic_primary / analytics_topic: course knowledge taxonomy +-- 3) skill_tags: what kind of thinking task the question requires +ALTER TABLE paper_questions + ADD COLUMN IF NOT EXISTS question_format TEXT + CHECK ( + question_format IN ( + 'mc', + 'true_false', + 'fill_blank', + 'short_answer', + 'long_answer', + 'coding' + ) + ), + ADD COLUMN IF NOT EXISTS topic_primary TEXT, + ADD COLUMN IF NOT EXISTS analytics_topic TEXT, + ADD COLUMN IF NOT EXISTS topic_tags TEXT[], + ADD COLUMN IF NOT EXISTS skill_tags TEXT[]; + +-- Keep the legacy topics column for backward compatibility for now. +-- New analytics and retrieval code should gradually move to analytics_topic/topic_tags. + +CREATE INDEX IF NOT EXISTS idx_questions_question_format + ON paper_questions(question_format); + +CREATE INDEX IF NOT EXISTS idx_questions_analytics_topic + ON paper_questions(analytics_topic); + +CREATE INDEX IF NOT EXISTS idx_questions_topic_tags + ON paper_questions USING GIN(topic_tags); + +CREATE INDEX IF NOT EXISTS idx_questions_skill_tags + ON paper_questions USING GIN(skill_tags); diff --git a/supabase/migrations/004_decouple_course_library_from_auth.sql b/supabase/migrations/004_decouple_course_library_from_auth.sql new file mode 100644 index 0000000..6e87c53 --- /dev/null +++ b/supabase/migrations/004_decouple_course_library_from_auth.sql @@ -0,0 +1,30 @@ +-- ============================================ +-- PastPaper Master — Decouple course library papers from auth users +-- Version: 004 +-- Date: 2026-03-24 +-- ============================================ + +-- Course-library papers should not depend on a concrete auth.users row. +-- User-uploaded papers still keep user_id populated. +ALTER TABLE papers + ALTER COLUMN user_id DROP NOT NULL; + +-- Keep existing FK so user-owned papers can still reference auth.users, +-- while course-library rows simply use NULL. + +-- Tighten the intended invariant with a check constraint: +-- - user_upload rows must have user_id +-- - course_library rows must not have user_id +ALTER TABLE papers + DROP CONSTRAINT IF EXISTS papers_source_kind_user_id_check; + +ALTER TABLE papers + ADD CONSTRAINT papers_source_kind_user_id_check + CHECK ( + (source_kind = 'user_upload' AND user_id IS NOT NULL) + OR + (source_kind = 'course_library' AND user_id IS NULL) + ); + +-- Existing RLS policies continue to apply to user-owned rows. +-- Course-library rows are accessed through the backend service role. diff --git a/supabase/migrations/005_allow_long_question_format_alias.sql b/supabase/migrations/005_allow_long_question_format_alias.sql new file mode 100644 index 0000000..8d994d4 --- /dev/null +++ b/supabase/migrations/005_allow_long_question_format_alias.sql @@ -0,0 +1,27 @@ +-- ============================================ +-- PastPaper Master — Allow legacy long_question format alias +-- Version: 005 +-- Date: 2026-03-24 +-- ============================================ +-- +-- Some existing seeds and older generated SQL used `long_question` in the +-- `question_format` column, while the 003 taxonomy migration introduced +-- `long_answer` as the canonical value. Allow both temporarily so historical +-- inserts do not fail. New generators should continue emitting `long_answer`. + +ALTER TABLE paper_questions + DROP CONSTRAINT IF EXISTS paper_questions_question_format_check; + +ALTER TABLE paper_questions + ADD CONSTRAINT paper_questions_question_format_check + CHECK ( + question_format IN ( + 'mc', + 'true_false', + 'fill_blank', + 'short_answer', + 'long_answer', + 'long_question', + 'coding' + ) + ); diff --git a/supabase/migrations/006_make_scores_numeric.sql b/supabase/migrations/006_make_scores_numeric.sql new file mode 100644 index 0000000..edf2640 --- /dev/null +++ b/supabase/migrations/006_make_scores_numeric.sql @@ -0,0 +1,17 @@ +-- ============================================ +-- PastPaper Master — Make score fields numeric +-- Version: 006 +-- Date: 2026-04-10 +-- ============================================ + +ALTER TABLE paper_questions + ALTER COLUMN score TYPE NUMERIC + USING score::NUMERIC; + +ALTER TABLE papers + ALTER COLUMN total_score TYPE NUMERIC + USING total_score::NUMERIC; + +ALTER TABLE user_attempts + ALTER COLUMN score_given TYPE NUMERIC + USING score_given::NUMERIC; diff --git a/supabase/migrations/007_fulltext_search.sql b/supabase/migrations/007_fulltext_search.sql new file mode 100644 index 0000000..1169e00 --- /dev/null +++ b/supabase/migrations/007_fulltext_search.sql @@ -0,0 +1,36 @@ +-- 007: Full-text search on paper_questions.question_text +-- +-- Adds a tsvector generated column (auto-maintained by PostgreSQL on every +-- INSERT/UPDATE), a GIN index for fast @@ queries, and a batch-scoring RPC +-- used by the similar-question retrieval endpoint. + +ALTER TABLE paper_questions + ADD COLUMN IF NOT EXISTS search_text tsvector + GENERATED ALWAYS AS ( + to_tsvector('english', coalesce(question_text, '')) + ) STORED; + +CREATE INDEX IF NOT EXISTS idx_pq_search_text + ON paper_questions USING gin(search_text); + +-- text_similarity_scores(query_text, candidate_ids) +-- Returns one row per candidate ID with a ts_rank_cd score normalised by +-- unique word count (normalization flag = 1). Questions that share no +-- lexemes with the query still appear in the result with score = 0 so the +-- caller always gets a complete score map for every candidate. +CREATE OR REPLACE FUNCTION text_similarity_scores( + query_text text, + candidate_ids uuid[] +) +RETURNS TABLE (question_id uuid, text_score float4) +LANGUAGE sql STABLE AS $$ + SELECT + id, + ts_rank_cd( + search_text, + plainto_tsquery('english', query_text), + 1 -- normalise by unique word count + )::float4 + FROM paper_questions + WHERE id = ANY(candidate_ids); +$$; diff --git a/supabase/migrations/008_add_page_y_ratio.sql b/supabase/migrations/008_add_page_y_ratio.sql new file mode 100644 index 0000000..e04fe87 --- /dev/null +++ b/supabase/migrations/008_add_page_y_ratio.sql @@ -0,0 +1,2 @@ +ALTER TABLE paper_questions + ADD COLUMN IF NOT EXISTS page_y_ratio NUMERIC; diff --git a/supabase/migrations/008_fix_storage_url_placeholder.sql b/supabase/migrations/008_fix_storage_url_placeholder.sql new file mode 100644 index 0000000..8260d5f --- /dev/null +++ b/supabase/migrations/008_fix_storage_url_placeholder.sql @@ -0,0 +1,27 @@ +-- 008: Replace __SUPABASE_STORAGE_PUBLIC_BASE_URL__ placeholder in paper URLs +-- +-- The course-library seed (comp2211_course_library_papers.sql) was inserted +-- without substituting the placeholder. This migration replaces it with the +-- real Supabase Storage public base URL for the `papers` bucket. + +UPDATE papers +SET paper_file_url = REPLACE( + paper_file_url, + '__SUPABASE_STORAGE_PUBLIC_BASE_URL__', + 'https://pvcxipwovpwrurebouwg.supabase.co/storage/v1/object/public/papers' +) +WHERE paper_file_url LIKE '%__SUPABASE_STORAGE_PUBLIC_BASE_URL__%'; + +UPDATE papers +SET answer_file_url = REPLACE( + answer_file_url, + '__SUPABASE_STORAGE_PUBLIC_BASE_URL__', + 'https://pvcxipwovpwrurebouwg.supabase.co/storage/v1/object/public/papers' +) +WHERE answer_file_url LIKE '%__SUPABASE_STORAGE_PUBLIC_BASE_URL__%'; + +-- Verify: should return 0 rows +SELECT id, course_code, year, term, exam_type, paper_file_url, answer_file_url +FROM papers +WHERE paper_file_url LIKE '%__SUPABASE_STORAGE_PUBLIC_BASE_URL__%' + OR answer_file_url LIKE '%__SUPABASE_STORAGE_PUBLIC_BASE_URL__%'; diff --git a/supabase/seeds/comp2211_2022_fall_page_number_backfill.sql b/supabase/seeds/comp2211_2022_fall_page_number_backfill.sql new file mode 100644 index 0000000..a97a343 --- /dev/null +++ b/supabase/seeds/comp2211_2022_fall_page_number_backfill.sql @@ -0,0 +1,52 @@ +UPDATE paper_questions +SET page_number = CASE question_number + WHEN '1a' THEN 2 + WHEN '1b' THEN 2 + WHEN '1c' THEN 2 + WHEN '1d' THEN 2 + WHEN '1e' THEN 2 + WHEN '1f' THEN 2 + WHEN '1g' THEN 2 + WHEN '1h' THEN 2 + WHEN '1i' THEN 2 + WHEN '1j' THEN 2 + WHEN '2a_i' THEN 3 + WHEN '2a_ii' THEN 3 + WHEN '2a_iii' THEN 3 + WHEN '2a_iv' THEN 3 + WHEN '2a_v' THEN 4 + WHEN '2a_vi' THEN 4 + WHEN '2a_vii' THEN 4 + WHEN '2b_i' THEN 5 + WHEN '2b_ii' THEN 5 + WHEN '2b_iii' THEN 5 + WHEN '2c' THEN 6 + WHEN '3a_i' THEN 8 + WHEN '3a_ii' THEN 8 + WHEN '3b_i' THEN 9 + WHEN '3b_ii' THEN 9 + WHEN '3b_iii' THEN 10 + WHEN '3c' THEN 10 + WHEN '3d' THEN 11 + WHEN '4a' THEN 12 + WHEN '4b' THEN 13 + WHEN '4c' THEN 13 + WHEN '4d' THEN 13 + WHEN '5a' THEN 14 + WHEN '5b' THEN 14 + WHEN '5c' THEN 14 + WHEN '5d' THEN 15 + WHEN '5e' THEN 15 + WHEN '5f' THEN 15 + WHEN '6a' THEN 16 + WHEN '6b_i' THEN 17 + WHEN '6b_ii' THEN 17 + WHEN '7a' THEN 18 + WHEN '7b' THEN 18 + ELSE page_number +END +WHERE paper_id = ( + SELECT id + FROM papers + WHERE source_exam_key = 'COMP2211-2022-fall-midterm' +); diff --git a/supabase/seeds/comp2211_course_library_papers.sql b/supabase/seeds/comp2211_course_library_papers.sql new file mode 100644 index 0000000..d1a30d5 --- /dev/null +++ b/supabase/seeds/comp2211_course_library_papers.sql @@ -0,0 +1,148 @@ +-- ============================================ +-- PastPaper Master — COMP2211 course library papers +-- Seed Date: 2026-03-24 +-- ============================================ +-- +-- Before running: +-- 1. Upload the referenced PDFs into the `papers` bucket using the exact storage paths below. +-- 2. Replace __SUPABASE_STORAGE_PUBLIC_BASE_URL__ with your project-specific public base URL. +-- +-- Example base URL: +-- https://.supabase.co/storage/v1/object/public/papers +-- +-- This seed only inserts canonical, importable COMP2211 course-library papers. + +INSERT INTO papers ( + user_id, + course_code, + year, + term, + exam_type, + part_label, + paper_file_url, + answer_file_url, + status, + source_kind, + source_exam_key, + source_question_filename, + source_answer_filename +) +VALUES + ( + NULL, + 'COMP2211', + 2022, + 'fall', + 'midterm', + NULL, + '__SUPABASE_STORAGE_PUBLIC_BASE_URL__/course-library/COMP2211/COMP2211-2022-fall-midterm/paper.pdf', + '__SUPABASE_STORAGE_PUBLIC_BASE_URL__/course-library/COMP2211/COMP2211-2022-fall-midterm/answer.pdf', + 'uploaded', + 'course_library', + 'COMP2211-2022-fall-midterm', + '(COMP2211)[2022](f)midterm~=yjz8dxdd^_27002.pdf', + '(COMP2211)[2022](f)midterm~=yjz8dxdd^_18747.pdf' + ), + ( + NULL, + 'COMP2211', + 2022, + 'spring', + 'midterm', + NULL, + '__SUPABASE_STORAGE_PUBLIC_BASE_URL__/course-library/COMP2211/COMP2211-2022-spring-midterm/paper.pdf', + '__SUPABASE_STORAGE_PUBLIC_BASE_URL__/course-library/COMP2211/COMP2211-2022-spring-midterm/answer.pdf', + 'uploaded', + 'course_library', + 'COMP2211-2022-spring-midterm', + '(COMP2211)[2022](s)midterm~=b8bidkgs^_14629.pdf', + '(COMP2211)[2022](s)midterm~=6ma030^_89587.pdf' + ), + ( + NULL, + 'COMP2211', + 2022, + 'spring', + 'final', + 'A', + '__SUPABASE_STORAGE_PUBLIC_BASE_URL__/course-library/COMP2211/COMP2211-2022-spring-final-part-a/paper.pdf', + '__SUPABASE_STORAGE_PUBLIC_BASE_URL__/course-library/COMP2211/COMP2211-2022-spring-final-part-a/answer.pdf', + 'uploaded', + 'course_library', + 'COMP2211-2022-spring-final-part-a', + '(COMP2211)[2022](s)final~=b8bidkgs^_33018.pdf', + '(COMP2211)[2022](s)final~=ajou6^_82011.pdf' + ), + ( + NULL, + 'COMP2211', + 2022, + 'spring', + 'final', + 'B', + '__SUPABASE_STORAGE_PUBLIC_BASE_URL__/course-library/COMP2211/COMP2211-2022-spring-final-part-b/paper.pdf', + '__SUPABASE_STORAGE_PUBLIC_BASE_URL__/course-library/COMP2211/COMP2211-2022-spring-final-part-b/answer.pdf', + 'uploaded', + 'course_library', + 'COMP2211-2022-spring-final-part-b', + '(COMP2211)[2022](s)final~=b8bidkgs^_40627.pdf', + '(COMP2211)[2022](s)final~=ajou6^_51199.pdf' + ), + ( + NULL, + 'COMP2211', + 2023, + 'spring', + 'midterm', + NULL, + '__SUPABASE_STORAGE_PUBLIC_BASE_URL__/course-library/COMP2211/COMP2211-2023-spring-midterm/paper.pdf', + '__SUPABASE_STORAGE_PUBLIC_BASE_URL__/course-library/COMP2211/COMP2211-2023-spring-midterm/answer.pdf', + 'uploaded', + 'course_library', + 'COMP2211-2023-spring-midterm', + '(COMP2211)[2023](s)midterm~=bxbidkmj^_26587.pdf', + '(COMP2211)[2023](s)midterm~clchanbg^_17297.pdf' + ), + ( + NULL, + 'COMP2211', + 2024, + 'spring', + 'midterm', + NULL, + '__SUPABASE_STORAGE_PUBLIC_BASE_URL__/course-library/COMP2211/COMP2211-2024-spring-midterm/paper.pdf', + '__SUPABASE_STORAGE_PUBLIC_BASE_URL__/course-library/COMP2211/COMP2211-2024-spring-midterm/answer.pdf', + 'uploaded', + 'course_library', + 'COMP2211-2024-spring-midterm', + '(COMP2211)[2024](s)midterm~=rcidkjgf^_82003.pdf', + '(COMP2211)[2024](s)midterm~=ubrzkjmz^_90406.pdf' + ), + ( + NULL, + 'COMP2211', + 2024, + 'spring', + 'final', + NULL, + '__SUPABASE_STORAGE_PUBLIC_BASE_URL__/course-library/COMP2211/COMP2211-2024-spring-final/paper.pdf', + '__SUPABASE_STORAGE_PUBLIC_BASE_URL__/course-library/COMP2211/COMP2211-2024-spring-final/answer.pdf', + 'uploaded', + 'course_library', + 'COMP2211-2024-spring-final', + '(COMP2211)[2024](s)final~=igk5mmg^_90365.pdf', + '(COMP2211)[2024](s)final~=igk5mmg^_58857.pdf' + ) +ON CONFLICT (source_exam_key) +WHERE source_kind = 'course_library' AND source_exam_key IS NOT NULL +DO UPDATE SET + course_code = EXCLUDED.course_code, + year = EXCLUDED.year, + term = EXCLUDED.term, + exam_type = EXCLUDED.exam_type, + part_label = EXCLUDED.part_label, + paper_file_url = EXCLUDED.paper_file_url, + answer_file_url = EXCLUDED.answer_file_url, + status = EXCLUDED.status, + source_question_filename = EXCLUDED.source_question_filename, + source_answer_filename = EXCLUDED.source_answer_filename; diff --git a/supabase/seeds/comp2211_problem_level_questions.sql b/supabase/seeds/comp2211_problem_level_questions.sql new file mode 100644 index 0000000..826ce4c --- /dev/null +++ b/supabase/seeds/comp2211_problem_level_questions.sql @@ -0,0 +1,7173 @@ +-- ============================================ +-- PastPaper Master — COMP2211 problem-level questions +-- Seed Date: 2026-03-24 +-- ============================================ +-- +-- Preconditions: +-- 1. Run comp2211_course_library_papers.sql first. +-- 2. Ensure those paper rows already exist in `papers`. +-- +-- This seed inserts one row per top-level Problem. +-- It is intentionally coarse-grained and idempotent. + +INSERT INTO paper_questions ( + paper_id, + question_number, + parent_question, + display_order, + question_type, + question_format, + question_text, + score, + page_number, + options, + correct_option, + correct_answer, + raw_answer_text, + topics, + topic_primary, + analytics_topic, + topic_tags, + skill_tags, + difficulty, + knowledge_reminder, + ai_hint, + solution +) +SELECT + p.id, + seed.question_number, + seed.parent_question, + seed.display_order, + seed.question_type, + seed.question_format, + seed.question_text, + seed.score, + seed.page_number, + seed.options, + seed.correct_option, + seed.correct_answer, + seed.raw_answer_text, + seed.topics, + seed.topic_primary, + seed.analytics_topic, + seed.topic_tags, + seed.skill_tags, + seed.difficulty, + seed.knowledge_reminder, + seed.ai_hint, + seed.solution +FROM ( + VALUES + ('COMP2211-2022-fall-midterm', '1', NULL, 1, 'true_false', 'true_false', 'Problem 1 [15 points] True/False Questions +Indicate whether the following statements are true or false by putting T or F in the given +table. You get 1.5 points for each correct answer. +(a) Machine learning gives computers the ability to make decision by writing down rules and +methods and being explicitly programmed. +(b) The regression technique in machine learning is NOT a group of algorithms that are used +for predicting a class/category. +(c) A 5-fold cross validation for K-nearest neighbors algorithm means that for each value +of K, we randomly select 1/5 of the training data as the validation set to evaluate the +model which is trained by the remaining (4/5) of the training data. +(d) If we use K-means clustering, we will get the same cluster assignments for each data +point, whether or not we standardize the variables. +(e) Given an input data point x, where all the attribute values in x are real numbers. Suppose +you are asked to predict a label y for x, where y = 0 or y = 1. Assume you have no +knowledge about the distributions of x and y, perceptron is an appropriate method for +this problem. +(f) A perceptron with the unit step function (i.e., f(z) = 0 if z ≤0, otherwise f(z) = 1) as +the activation function cannot be used for multi-class classification. +(g) If a training dataset is linearly separable into two classes, the perceptron learning rule +will always converge to weights and bias that accomplish the desired classification. +(h) The neural network weights are updated during forward propagation. +(i) An advantage of gradient descent-based methods, such as back-propagation, is that they +cannot get stuck in local minima. +(j) The back-propagation algorithm, when run until a minimum is achieved, always finds +the same solution (i.e., weights and biases) no matter what the initial set of weights and +biases are. +Question +(a) +(b) +(c) +(d) +(e) +(f) +(g) +(h) +(i) +(j) +Answer', 15, 2, NULL::jsonb, NULL, NULL, 'Problem 1 [15 points] True/False Questions +Indicate whether the following statements are true or false by putting T or F in the given +table. You get 1.5 points for each correct answer. +(a) Machine learning gives computers the ability to make decision by writing down rules and +methods and being explicitly programmed. +(b) The regression technique in machine learning is NOT a group of algorithms that are used +for predicting a class/category. +(c) A 5-fold cross validation for K-nearest neighbors algorithm means that for each value +of K, we randomly select 1/5 of the training data as the validation set to evaluate the +model which is trained by the remaining (4/5) of the training data. +(d) If we use K-means clustering, we will get the same cluster assignments for each data +point, whether or not we standardize the variables. +(e) Given an input data point x, where all the attribute values in x are real numbers. Suppose +you are asked to predict a label y for x, where y = 0 or y = 1. Assume you have no +knowledge about the distributions of x and y, perceptron is an appropriate method for +this problem. +(f) A perceptron with the unit step function (i.e., f(z) = 0 if z ≤0, otherwise f(z) = 1) as +the activation function cannot be used for multi-class classification. +(g) If a training dataset is linearly separable into two classes, the perceptron learning rule +will always converge to weights and bias that accomplish the desired classification. +(h) The neural network weights are updated during forward propagation. +(i) An advantage of gradient descent-based methods, such as back-propagation, is that they +cannot get stuck in local minima. +(j) The back-propagation algorithm, when run until a minimum is achieved, always finds +the same solution (i.e., weights and biases) no matter what the initial set of weights and +biases are. +Question +(a) +(b) +(c) +(d) +(e) +(f) +(g) +(h) +(i) +(j) +Answer +F +T +F +F +F +T +T +F +F +F +Marking scheme: +ˆ 1.5 points for each correct answer. 15 points in total', ARRAY['True/False']::TEXT[], 'True/False', 'True/False', ARRAY['True/False']::TEXT[], ARRAY['concept_check', 'rapid_reasoning']::TEXT[], 'medium', '', '', ''), + ('COMP2211-2022-fall-midterm', '2', NULL, 2, 'long_question', 'coding', 'Problem 2 [19 points] Python Fundamentals +(a) +[7 points] Consider the following NumPy arrays: +import numpy as np +A = np.array([1, 2, 3, 4, 5, 6, 7, 8, 9]) +B = np.array([[[0, 1, 2, 3], +[4, 5, 6, 7], +[8, 9, 10, 11]], +[[12, 13, 14, 15], +[16, 17, 18, 19], +[20, 21, 22, 23]]]) +Write the output for each of the following Python statements. If the output is an empty +array, write “Empty Array”. If an error occurs, write “Error”. +(i) print(A[::3]) +(ii) print(A[:-2:-2]) +(iii) print(B[:, 1, 3:0:-1]) +(iv) print(B[0, 1, [0,3]]) +The NumPy array B is repeated here to ease your reading. +B = np.array([[[0, 1, 2, 3], +[4, 5, 6, 7], +[8, 9, 10, 11]], +[[12, 13, 14, 15], +[16, 17, 18, 19], +[20, 21, 22, 23]]]) +(v) print(B[ B % 4 == 0 ]) +(vi) # numpy.sum(a, axis) +# returns the sum of array elements over a given axis +print( np.sum( B, axis=1 ) ) +(vii) # numpy.ndarray.reshape(shape) +# returns an array containing the same data with a new shape +print( B.reshape( (3, -1, 2) ) ) +(b) +[6 points] Write the output for the following Python code segments. If the output is an +empty array, write “Empty Array”. If an error occurs, write “Error”. +(i) import numpy as np +A = np.array([[1, 2, 3], [2, 4, 6]]) +B = np.array([1, 2, 3]) +print(A * B) +(ii) import numpy as np +A = np.array([1, 2, 3, 4]) +B = np.array([[1, 2], [2, 4], [3, 6], [4, 8]]) +print(A + B) +(iii) import numpy as np +A = np.array([1, 2, 3, 4]) +B = np.array([[2], [3], [4]]) +print(A + B) +(c) +[6 points] The cosine similarity of two non-zero vectors, xTrain = (xTrain +, . . . , xTrain +n +) +and xTest = (xTest +, . . . , xTest +n +), can be calculated by the following formula: +Pn +i=1 (xTrain +i +× xTest +i +) +qPn +i=1 (xTrain +i +)2 +qPn +i=1 (xTest +i +)2 +For example, if a training sample xTrain is (0, 1, 2) and a testing sample xTest is (4, 6, +8), the cosine similarity is: +0 × 4 + 1 × 6 + 2 × 8 +√ +02 + 12 + 22√ +42 + 62 + 82 = 0.91350028 +Given the following NumPy arrays, X_train and X_test, where each 1D array represents +a data point: +import numpy as np +X_train = np.array([[0, 1, 2], [2, 3, 4], [4, 5, 6]]) +X_test = np.array([[4, 6, 8], [5, 0, 0]]) +Compute the cosine similarity scores between each data point in X_train and each data +point in X_test with a one-line Python expression, such that the evaluated result of +the expression is: +[[0.91350028 0. +] +[1. +0.37139068] +[0.99461155 0.45584231]] +N ote: +ˆ An expression is a combination of values, variables, operators, and calls to functions. +ˆ Your expression should work with any number of data points in X_train and X_test +and any number of values in the data points. +ˆ You can assume that the number of attribute values in each data point is the same +for both X_train and X_test. +ˆ There must be no explicit loops in your expression. +You may find the following attribute or functions useful for this question. +ˆ Dot product of two arrays, a and b: +numpy.dot(a, b) +– It returns the product of matrix multiplication. +ˆ Return the element-wise square of an array, x +numpy.square(x) +ˆ Return the sum of array elements over a given axis. +numpy.sum(a, axis=None) +– a: the input array with elements to sum. +– axis: None or int or tuple of ints +ˆ Return the non-negative square-root of an array, element-wise. +numpy.sqrt(x) +– x: the input array with values whose square-roots are required. +ˆ Insert a new axis that will appear at the axis position in the expanded array shape. +numpy.expand_dims(a, axis) +– a: the input array. +– axis: an int or tuple of ints that represents position in the expanded axes where +the new axis (or axes) is placed. +ˆ The transposed array. +numpy.ndarray.T +Write the one-line Python expression below: +print( +)', 19, 3, NULL::jsonb, NULL, NULL, 'Problem 2 [19 points] Python Fundamentals +(a) +[7 points] Consider the following NumPy arrays: +Marking scheme: +ˆ 1 point each sub-questions, i.e. (i) to (vii) +ˆ No partial score +import numpy as np +A = np.array([1, 2, 3, 4, 5, 6, 7, 8, 9]) +B = np.array([[[0, 1, 2, 3], +[4, 5, 6, 7], +[8, 9, 10, 11]], +[[12, 13, 14, 15], +[16, 17, 18, 19], +[20, 21, 22, 23]]]) +Write the output for each of the following Python statements. If the output is an empty +array, write “Empty Array”. If an error occurs, write “Error”. +(i) print(A[::3]) +Answer: +[1 4 7] +(ii) print(A[:-2:-2]) +Answer: +[9] +(iii) print(B[:, 1, 3:0:-1]) +Answer: +[[ 7 +5] +[19 18 17]] +(iv) print(B[0, 1, [0,3]]) +Answer: +[4 7] +(v) print(B[ B % 4 == 0 ]) +Answer: +[ 0 +8 12 16 20] +(vi) # numpy.sum(a, axis) +# returns the sum of array elements over a given axis +print( np.sum( B, axis=1 ) ) +Answer: +[[12 15 18 21] +[48 51 54 57]] +(vii) # numpy.ndarray.reshape(shape) +# returns an array containing the same data with a new shape +print( B.reshape( (3, -1, 2) ) ) +Answer: +[[[ 0 +1] +[ 2 +3] +[ 4 +5] +[ 6 +7]] +[[ 8 +9] +[10 11] +[12 13] +[14 15]] +[[16 17] +[18 19] +[20 21] +[22 23]]] +(b) +[6 points] Write the output for the following Python code segments. If the output is an +empty array, write “Empty Array”. If an error occurs, write “Error”. +Marking scheme: +ˆ 2 points each sub-questions, i.e. (i) to (iii) +ˆ No partial score +(i) import numpy as np +A = np.array([[1, 2, 3], [2, 4, 6]]) +B = np.array([1, 2, 3]) +print(A * B) +Answer: +[[ 1 +9] +[ 2 +8 18]] +(ii) import numpy as np +A = np.array([1, 2, 3, 4]) +B = np.array([[1, 2], [2, 4], [3, 6], [4, 8]]) +print(A + B) +Answer: +Error +(iii) import numpy as np +A = np.array([1, 2, 3, 4]) +B = np.array([[2], [3], [4]]) +print(A + B) +Answer: +[[3 4 5 6] +[4 5 6 7] +[5 6 7 8]] +(c) +[6 points] The cosine similarity of two non-zero vectors, xTrain = (xTrain +, . . . , xTrain +n +) +and xTest = (xTest +, . . . , xTest +n +), can be calculated by the following formula: +Pn +i=1 (xTrain +i +× xTest +i +) +qPn +i=1 (xTrain +i +)2 +qPn +i=1 (xTest +i +)2 +For example, if a training sample xTrain is (0, 1, 2) and a testing sample xTest is (4, 6, +8), the cosine similarity is: +0 × 4 + 1 × 6 + 2 × 8 +√ +02 + 12 + 22√ +42 + 62 + 82 = 0.91350028 +Given the following NumPy arrays, X_train and X_test, where each 1D array represents +a data point: +import numpy as np +X_train = np.array([[0, 1, 2], [2, 3, 4], [4, 5, 6]]) +X_test = np.array([[4, 6, 8], [5, 0, 0]]) +Compute the cosine similarity scores between each data point in X_train and each data +point in X_test with a one-line Python expression, such that the evaluated result of +the expression is: +[[0.91350028 0. +] +[1. +0.37139068] +[0.99461155 0.45584231]] +N ote: +ˆ An expression is a combination of values, variables, operators, and calls to functions. +ˆ Your expression should work with any number of data points in X_train and X_test +and any number of values in the data points. +ˆ You can assume that the number of attribute values in each data point is the same +for both X_train and X_test. +ˆ There must be no explicit loops in your expression. +You may find the following attribute or functions useful for this question. +ˆ Dot product of two arrays, a and b: +numpy.dot(a, b) +– It returns the product of matrix multiplication. +ˆ Return the element-wise square of an array, x +numpy.square(x) +ˆ Return the sum of array elements over a given axis. +numpy.sum(a, axis=None) +– a: the input array with elements to sum. +– axis: None or int or tuple of ints +ˆ Return the non-negative square-root of an array, element-wise. +numpy.sqrt(x) +– x: the input array with values whose square-roots are required. +ˆ Insert a new axis that will appear at the axis position in the expanded array shape. +numpy.expand_dims(a, axis) +– a: the input array. +– axis: an int or tuple of ints that represents position in the expanded axes where +the new axis (or axes) is placed. +ˆ The transposed array. +numpy.ndarray.T +Write the one-line Python expression below: +print( +) +Answer: +print(X_train.dot(X_test.T) / +np.dot(np.expand_dims(np.sqrt(np.sum(X_train**2, axis=1)), 1), +np.expand_dims(np.sqrt(np.sum(X_test**2, axis=1)), 1).T) +) +print(X_train.dot(X_test.T) / +np.dot(np.expand_dims(np.sqrt(np.sum(X_train**2, axis=1)), 1), +np.expand_dims(np.sqrt(np.sum(X_test**2, axis=1)), 0)) +) +print(np.dot(X_train, X_test.transpose()) / +(np.sqrt(np.sum(np.square(X_train),axis=1)[:, None]) * +np.sqrt(np.sum(np.square(X_test), axis=1)[None, :])) +) +print(np.dot(X_train, np.transpose(X_test)) / +(np.sqrt(np.sum(np.square(X_train),axis=1)[:, np.newaxis]) * +np.sqrt(np.sum(np.square(X_test), axis=1)[np.newaxis,:])) +) +print(np.dot(X_train, X_test.T) / +np.sqrt(np.sum(np.square(X_train), axis=1)).reshape(3,1).dot( +np.sqrt(np.sum(np.square(X_test), axis=1)).reshape(1, 2)) +) +Marking scheme: +ˆ 2 points for correct numerator +– If not using np.dot correctly, deduct 1 point (np.dot can be replaced by np.matmul, +@); +– If not using np.ndarray.T correctly, deduct 1 point (np.ndarray.T can be replaced +by np.transpose); +– The maximum number of points can be deducted for numerator is 2. +ˆ 4 points for correct denominator +– If not using np.expand dims correctly (including specifying axis), deduct 1 point +for each (np.expand dims can be replaced by np.reshape or adding a new axis); +– If not using np.dot correctly, deduct 1 point (np.dot can be replaced by np.matmul, +@); +– If not using np.sqrt correctly, deduct 1 point for each; +– If not using np.sum correcly (including specifying axis), deduct 1 point for each; +– If not using np.square correctly, deduct 1 point for each (np.square can be re- +placed by np.ndarray ** 2); +– The maximum number of points can be deducted for denominator is 4. If points +to be deducted exceed 4, the denominator part gets 0 point.', ARRAY['Python Fundamentals']::TEXT[], 'Python Fundamentals', 'Python Fundamentals', ARRAY['Python Fundamentals']::TEXT[], ARRAY['implementation', 'code_tracing', 'debugging']::TEXT[], 'hard', '', '', ''), + ('COMP2211-2022-fall-midterm', '3', NULL, 3, 'long_question', 'long_answer', 'Problem 3 [18 points] Conditional Probability and Bayes Classifier +(a) +[4 points] Assume the probability of getting an A+ in COMP 2211 is 0.08. The prob- +ability of getting a score greater than 90 in the midterm exam given that a student +gets an A+ in COMP 2211 is 0.95, and the probability of getting a score greater than +90 in the midterm exam given that the student does not get an A+ in COMP 2211 is 0.05. +(i) Calculate the probability of getting a score greater than 90 in the midterm exam. +Show all your steps. Round your answer to 2 decimal places. +The formula that you may find useful for this question: +P(E) = P(E|B)P(B) + P(E|Not B)P(Not B) +(ii) Use Bayes’ rule to calculate the probability of getting an A+ given that a student +gets a score greater than 90 in the midterm exam. Show all your steps. Round your +answer to 2 decimal places. +The formula that you may find useful for this question: +P(B|E) = P(B)P(E|B) +P(E) +(b) +[8 points] Given the following dataset (X, y) with 10 training examples, each contains +2 attributes (x1, x2) and its binary label y. +X = [[0 0], [0 1], [1 1], [1 0], [1 1], [1 0], [0 1], [0 1], [1 1], [0 0]] +y = [0 0 1 1 1 1 0 0 1 1] +Suppose you believe that a naive Bayes model would be appropriate for this dataset, and +you want to classify the test sample: +x = [1 1] +(i) Compute the class prior probabilities, i.e., P(y = 0) and P(y = 1). +(ii) Compute the 4 conditional probabilities required by naive Bayes for the test sample, +i.e., P(x1 = 1|y = 0), P(x2 = 1|y = 0), P(x1 = 1|y = 1), P(x2 = 1|y = 1). +(iii) Under the naive Bayes model and the probabilities you compute in parts b(i) and +b(ii), what is the most likely label for the test sample, i.e., 0 or 1? If there is a tie, +please include the word “tie” in your answer. Show all your steps. +The formula that you may find useful for this question: +BNB = argmaxBiP(Bi)(P(e1|Bi)P(e2|Bi)P(e3|Bi) . . . P(ed|Bi)) +(c) +[3 points] Briefly describe a zero frequency problem of Naive Bayes classification and +suggest a way to solve the problem. Also, state whether a zero frequency problem occurs +in part (b). +The dataset (X, y) is repeated here to ease your reading. +X = [[0 0], [0 1], [1 1], [1 0], [1 1], [1 0], [0 1], [0 1], [1 1], [0 0]] +y = [0 0 1 1 1 1 0 0 1 1] +(d) +[3 points] Consider the dataset given in part (b), i.e., the one above. Suppose you do +NOT believe that the naive Bayes model would be appropriate for this dataset, and you +want to classify the test sample: +x = [0 0] +using the Bayes model without making the naive assumption. What is the most +likely label for the test sample? If there is a tie, please include the word “tie” in your +answer. Show all your steps. +The formula that you may find useful for this question: +BNB = argmaxBiP(Bi)P((e1, e2, e3, . . . , ed)|Bi)', 18, 8, NULL::jsonb, NULL, NULL, 'Problem 3 [18 points] Conditional Probability and Bayes Classifier +(a) +[4 points] Assume the probability of getting an A+ in COMP 2211 is 0.08. The prob- +ability of getting a score greater than 90 in the midterm exam given that a student +gets an A+ in COMP 2211 is 0.95, and the probability of getting a score greater than +90 in the midterm exam given that the student does not get an A+ in COMP 2211 is 0.05. +(i) [3 points] Calculate the probability of getting a score greater than 90 in the midterm +exam. Show all your steps. Round your answer to 2 decimal places. +The formula that you may find useful for this question: +P(E) = P(E|B)P(B) + P(E|Not B)P(Not B) +Answer: +P(A+) =0.08 +P(> 90|A+) =0.95 +P(> 90|Not A+) =0.05 +P(> 90) =P(> 90|A+)P(A+) + P(> 90|Not A+)P(Not A+) +=0.95(0.08) + 0.05(0.92) = 0.12 +Marking scheme: +ˆ 0.5 point for P(A+) = 0.08 +ˆ 0.5 point for P(> 90|A+) = 0.95 +ˆ 0.5 point for P(> 90|Not A+) = 0.05 +ˆ 0.5 point for P(Not A+) = 0.92 +ˆ 1 point for P(> 90) = 0.12 +(ii) +[1 point] Use Bayes’ rule to calculate the probability of getting an A+ given that +a student gets a score greater than 90 in the midterm exam. Show all your steps. +Round your answer to 2 decimal places. +The formula that you may find useful for this question: +P(B|E) = P(B)P(E|B) +P(E) +Answer: +P(A + | > 90) = P(A+)P(> 90|A+) +P(> 90) += 0.08(0.95) +0.12 += 0.63 +Marking scheme: +ˆ 1 point for P(A + | > 90) = 0.63 +(b) +[8 points] Given the following dataset (X, y) with 10 training examples, each contains +2 attributes (x1, x2) and its binary label y. +X = [[0 0], [0 1], [1 1], [1 0], [1 1], [1 0], [0 1], [0 1], [1 1], [0 0]] +y = [0 0 1 1 1 1 0 0 1 1] +Suppose you believe that a naive Bayes model would be appropriate for this dataset, and +you want to classify the test sample: +x = [1 1] +(i) +[1 point] Compute the class prior probabilities, i.e., P(y = 0) and P(y = 1). +Answer: +P(y = 0) = 4 +10 = 2 +P(y = 1) = 6 +10 = 3 +Marking scheme: +ˆ 0.5 point for P(y = 0) = 2 +ˆ 0.5 point for P(y = 1) = 3 +(ii) +[4 points] Compute the 4 conditional probabilities required by naive Bayes for +the test sample, i.e., P(x1 = 1|y = 0), P(x2 = 1|y = 0), P(x1 = 1|y = 1), +P(x2 = 1|y = 1). +Answer: +P(x1 = 1|y = 0) = 0 +P(x2 = 1|y = 0) = 3 +P(x1 = 1|y = 1) = 5 +P(x2 = 1|y = 1) = 3 +6 = 1 +Marking scheme: +ˆ 1 point for P(x1 = 1|y = 0) = 0 +ˆ 1 point for P(x2 = 1|y = 0) = 3 +ˆ 1 point for P(x1 = 1|y = 1) = 5 +ˆ 1 point for P(x2 = 1|y = 1) = 1 +(iii) +[3 points] Under the naive Bayes model and the probabilities you compute in parts +b(i) and b(ii), what is the most likely label for the test sample, i.e., 0 or 1? If there +is a tie, please include the word “tie” in your answer. Show all your steps. +The formula that you may find useful for this question: +BNB = argmaxBiP(Bi)(P(e1|Bi)P(e2|Bi)P(e3|Bi) . . . P(ed|Bi)) +Answer: +The numerator part of P(y = 0|x) = P(y = 0)P(x1 = 1|y = 0)P(x2 = 1|y = 0) += 0 +The numerator part of P(y = 1|x) = P(y = 1)P(x1 = 1|y = 1)P(x2 = 1|y = 1) += 3 +5 + 1 + += 1 +So, the most likely label for the test sample is 1. +Marking scheme: +ˆ 1 point for the numerator part of P(y = 0|x) = 0 +ˆ 1 point for the numerator part of P(y = 1|x) = 1 +ˆ 1 point for stating the most likely label for the test sample is 1 +(c) +[3 points] Briefly describe a zero frequency problem of Naive Bayes classification and +suggest a way to solve the problem. Also, state whether a zero frequency problem occurs +in part (b). +Answer: +If categorical variable has a category in test data set, which was not observed in the +training data set, then the frequency-based probability estimate will be zero. And we +will get a zero when all the probabilities are multiplied. This will be unable to make a +prediction. This is known as zero frequency. +A way to overcome this “zero frequency problem” is to add one to the count for ev- +ery attribute value-class combination when an attribute value does not occur with every +class value. +A zero frequency problem occurs in part (b). +Marking scheme: +ˆ 1 point for briefly describing a zero frequency problem +ˆ 1 point for suggesting a way to solve the problem +*** Please note that suggesting “adding more training data/increase the dataset +size/Using Log()” will not get any mark *** +ˆ 1 point for stating a zero frequency problem occurs in part (b) +The dataset (X, y) is repeated here to ease your reading. +X = [[0 0], [0 1], [1 1], [1 0], [1 1], [1 0], [0 1], [0 1], [1 1], [0 0]] +y = [0 0 1 1 1 1 0 0 1 1] +(d) +[3 points] Consider the dataset given in part (b), i.e., the one above. Suppose you do +NOT believe that the naive Bayes model would be appropriate for this dataset, and you +want to classify the test sample: +x = [0 0] +using the Bayes model without making the naive assumption. What is the most +likely label for the test sample? If there is a tie, please include the word “tie” in your +answer. Show all your steps. +The formula that you may find useful for this question: +BNB = argmaxBiP(Bi)P((e1, e2, e3, . . . , ed)|Bi) +Answer: +The numerator part of P(y = 0|x) = P(y = 0)P((x1 = 0, x2 = 0)|y = 0) += +2 + 1 + += 1 +The numerator part of P(y = 1|x) = P(y = 1)P((x1 = 0, x2 = 0)|y = 1) += +3 + 1 + += 1 +As the two probabilities are the same value, it’s a tie. +Marking scheme: +ˆ 1 point for the numerator part of P(y = 0|x) = 1 +ˆ 1 point for the numerator part of P(y = 1|x) = 1 +ˆ 1 point for stating it’s a tie', ARRAY['Probabilistic Models']::TEXT[], 'Probabilistic Models', 'Probabilistic Models', ARRAY['Probabilistic Models']::TEXT[], ARRAY['manual_computation', 'probability_reasoning', 'classification_decision']::TEXT[], 'hard', '', '', ''), + ('COMP2211-2022-fall-midterm', '4', NULL, 4, 'long_question', 'long_answer', 'Problem 4 [14 points] K-Nearest Neighbors +Consider a set of 8 training data given as (xTrain, cTrain) values, where xTrain is a string with +6 bits and cTrain is the multi-class label, A, B or C: +{ ("101110", A), ("110100", B), ("011100", B), ("000101", B), +("011111", B), ("101101", B), ("100011", A), ("010000", C) } +Classify a test sample (xTest) with the string “101011” using a K-Nearest Neighbors classifier +(KNN) and Hamming distance. +Hamming distance is the number of bit positions in which the two bits are different. For +example, the hamming distance between two strings, “11011001” and “10011101” is 2. +(a) +[5 points] Complete the following table by filling in the computed Hamming distance +between each training data point and the test sample. Also, determine the class label of +the test sample using KNN with K = 3 based on the results. +xTrain +cTrain +Distance +“101110” +A +“110100” +B +“011100” +B +“000101” +B +“011111” +B +“101101” +B +“100011” +A +“010000” +C +The predicted class label of the test sample is +. +(b) +[3 points] What will happen if we use a KNN classifier with K = 4 instead to classify +the test sample above? Describe your answer. +(c) +[3 points] A student claims that if a KNN classifier with K = 3 is used with the above +training data, the predicted class label will never be class C no matter what the test +example is, if there is no tie-breaking strategy used. Is this claim correct or not? Explain +your answer. +(d) +[3 points] Is the above training data suitable for performing a D-fold cross-validation? +Explain your answer.', 14, 12, NULL::jsonb, NULL, NULL, 'Problem 4 [14 points] K-Nearest Neighbors +Consider a set of 8 training data given as (xTrain, cTrain) values, where xTrain is a string with +6 bits and cTrain is the multi-class label, A, B or C: +{ ("101110", A), ("110100", B), ("011100", B), ("000101", B), +("011111", B), ("101101", B), ("100011", A), ("010000", C) } +Classify a test sample (xTest) with the string “101011” using a K-Nearest Neighbors classifier +(KNN) and Hamming distance. +Hamming distance is the number of bit positions in which the two bits are different. For +example, the hamming distance between two strings, “11011001” and “10011101” is 2. +(a) +[5 points] Complete the following table by filling in the computed Hamming distance +between each training data point and the test sample. Also, determine the class label of +the test sample using KNN with K = 3 based on the results. +xTrain +cTrain +Distance +“101110” +A +“110100” +B +“011100” +B +“000101” +B +“011111” +B +“101101” +B +“100011” +A +“010000” +C +The predicted class label of the test sample is +. +Answer: +xTrain +cTrain +Distance +“101110” +A +“110100” +B +“011100” +B +“000101” +B +“011111” +B +“101101” +B +“100011” +A +“010000” +C +The predicted class label of the test sample is A. +Marking scheme: +ˆ 0.5 point for each distance, i.e. 4 points total +ˆ 1 point for the predicted class label +(b) +[3 points] What will happen if we use a KNN classifier with K = 4 instead to classify +the test sample above? Describe your answer. +Answer: +We will have a tie where 2 nearest neighbors are with class label A and 2 nearest neighbors +are with class label B. +Marking scheme: +ˆ 1 point for mentioning “Tie” or ”cannot find the class label” +ˆ 2 points for the description of “2 NNs are class label A and 2 NNs are class label B” +(c) +[3 points] A student claims that if a KNN classifier with K = 3 is used with the above +training data, the predicted class label will never be class C no matter what the test +example is, if there is no tie-breaking strategy used. Is this claim correct or not? Explain +your answer. +Answer: +The claim is correct. If k = 3 and assume equal weights for all the points, no mat- +ter what the testing example is, it will be one of the following 3 cases: +ˆ none of the 3 nearest neighbors is with class label C, so the predicted label will not +be C. +ˆ 1 of the 3 nearest neighbors is with class label C and the other 2 neighbors are both +with class label A, or both with class label B, so the predicted class label will only +be A or B. +ˆ A tie +Marking scheme: +ˆ 1 point for “correct” or “yes” +ˆ 2 points for the explanation +(d) +[3 points] Is the above training data suitable for performing a D-fold cross-validation? +Explain your answer. +Answer: +No, unless the number of folds is equal to the number of training data points. +The +above training data set is umbalanced. Hence, if we split the above training data set, the +data used for training may not have samples with class labels A or C. +Marking scheme: +ˆ 1 point for “no” +ˆ 2 points for the explanation', ARRAY['KNN and Clustering']::TEXT[], 'KNN and Clustering', 'KNN and Clustering', ARRAY['KNN and Clustering']::TEXT[], ARRAY['manual_computation', 'distance_calculation', 'algorithm_tracing']::TEXT[], 'medium', '', '', ''), + ('COMP2211-2022-fall-midterm', '5', NULL, 5, 'long_question', 'long_answer', 'Problem 5 [12 points] K-Means Clustering +Consider the following training dataset: +Example +A +B +C +D +E +Attribute value +0.2 +0.5 +0.7 +2.5 +3.5 +Apply the K-means clustering algorithm to this dataset for K=2, i.e., you will produce two +data clusters. Assume the distance measure you use is Euclidean distance, defined as follows. +distance(xTrain, xTest) = +v +u +u +t +n +X +i=1 +(xTrain +i +−xTest +i +)2 +(a) [1.5 points] Assume examples A and B are chosen as the initial centroid of clusters 1 and +cluster 2, respectively. Write down the resulting cluster assignments in the table below. +The cluster assignment for A and B has been done for you. +Example +A +B +C +D +E +Cluster Assignment +(b) [2 points] After assigning the examples to the clusters in part (a), re-compute the cluster +centroids to be the mean of the examples currently assigned to each cluster. For each +cluster, write the new cluster centroid in the table below. +Cluster +Centroid +(c) +[2.5 points] After recomputing the cluster centroids in part (b), re-assign the examples +to the clusters to which they are closest. Write down the resulting cluster assignments +in the table below. +Example +A +B +C +D +E +Cluster Assignment +(d) [2 points] After assigning the examples to the clusters in part (c), re-compute the cluster +centroids to be the mean of the examples currently assigned to each cluster. For each +cluster, write the new cluster centroid in the table below. If your answer is a floating +point number, round it to 2 decimal places. +Cluster +Centroid +(e) +[2.5 points] After recomputing the cluster centroids in part (d), re-assign the examples +to the clusters to which they are closest. Write down the resulting cluster assignments +in the table below. +Example +A +B +C +D +E +Cluster Assignment +(f) +[1.5 points] State whether the K-means clustering algorithm converges for this dataset +after performing all the steps in parts (a)-(e). Explain your answer.', 12, 14, NULL::jsonb, NULL, NULL, 'Problem 5 [12 points] K-Means Clustering +Consider the following training dataset: +Example +A +B +C +D +E +Attribute value +0.2 +0.5 +0.7 +2.5 +3.5 +Apply the K-means clustering algorithm to this dataset for K=2, i.e., you will produce two +data clusters. Assume the distance measure you use is Euclidean distance, defined as follows. +distance(xTrain, xTest) = +v +u +u +t +n +X +i=1 +(xTrain +i +−xTest +i +)2 +(a) [1.5 points] Assume examples A and B are chosen as the initial centroid of clusters 1 and +cluster 2, respectively. Write down the resulting cluster assignments in the table below. +The cluster assignment for A and B has been done for you. +Example +A +B +C +D +E +Cluster Assignment +Marking scheme: +ˆ 0.5 point for each correct cluster assignment. 1.5 points in total +(b) [2 points] After assigning the examples to the clusters in part (a), re-compute the cluster +centroids to be the mean of the examples currently assigned to each cluster. For each +cluster, write the new cluster centroid in the table below. +Cluster +Centroid +0.2 +1.8 +Marking scheme: +ˆ 0.5 point for each correct centroid. 1 point in total +(c) +[2.5 points] After recomputing the cluster centroids in part (b), re-assign the examples +to the clusters to which they are closest. Write down the resulting cluster assignments +in the table below. +Example +A +B +C +D +E +Cluster Assignment +Marking scheme: +ˆ 0.5 point for each correct cluster assignment. 2.5 points in total +(d) [2 points] After assigning the examples to the clusters in part (c), re-compute the cluster +centroids to be the mean of the examples currently assigned to each cluster. For each +cluster, write the new cluster centroid in the table below. If your answer is a floating +point number, round it to 2 decimal places. +Cluster +Centroid +0.47 +Marking scheme: +ˆ 1 point for each correct centroid. 2 points in total +(e) +[2.5 points] After recomputing the cluster centroids in part (d), re-assign the examples +to the clusters to which they are closest. Write down the resulting cluster assignments +in the table below. +Example +A +B +C +D +E +Cluster Assignment +Marking scheme: +ˆ 0.5 point for each correct cluster assignment. 2.5 points in total +(f) +[1.5 points] State whether the K-means clustering algorithm converges for this dataset +after performing all the steps in parts (a)-(e). Explain your answer. +Answer: +The algorithm converges for this dataset since the cluster assignment of examples re- +mains the same. +Marking scheme: +ˆ 0.5 point for stating the algorithm converges +ˆ 1 point for the correct explanation', ARRAY['KNN and Clustering']::TEXT[], 'KNN and Clustering', 'KNN and Clustering', ARRAY['KNN and Clustering']::TEXT[], ARRAY['manual_computation', 'distance_calculation', 'algorithm_tracing']::TEXT[], 'medium', '', '', ''), + ('COMP2211-2022-fall-midterm', '6', NULL, 6, 'long_question', 'long_answer', 'Problem 6 [14 points] Perceptron +Suppose you are a CSE professor at HKUST who wants to offer a new course, COMP 2211. +Before starting, you want to predict if COMP 2211 will be successful or not. You approach +two students, A and B, asking them to read the proposal of 5 existing courses, COMP 1111, +COMP 2222, COMP 3333, COMP 4444, and COMP 5555, and rate them on a scale of 1 +to 5 (assume only integer scores). Assume each student reads the proposals independently +and announces their rating. As each student might be biased, you may be unable to average +their ratings. Instead, you decide to use a perceptron to classify the data using the training +dataset in Table 1. +Course Code +x1 +x2 +Success +COMP 1111 +No +COMP 2222 +Yes +COMP 3333 +No +COMP 4444 +Yes +COMP 5555 +Yes +Table 1: Training dataset +The training dataset is composed of the two student scores, x1 = score given by student A, +and x2 = score given by student B, and the corresponding true label regarding the success +of the courses judged based on the SFQ, for the 5 courses. +(a) [8 points] Train the perceptron to generate O = 1 if the course is success, O = 0 otherwise, +using the initial weight of w1 = 0, w2 = 0, θ = -1, and the activation function: +f(z) = + + + +if z ≤0 +otherwise +Also, use a learning rate of η = 1. +You may find the updating rules below useful. +∆wi = η(T −O)xi +∆θ = η(T −O) +wi = wi + ∆wi +θ = θ + ∆θ +Present each row of Table 1 as a training example, and update the perceptron weights +and bias before moving on to the next row. Show all the steps by completing Table 2. +x1 +x2 +T +O +∆w1 +w1 +∆w2 +w2 +∆θ +θ +- +- +- +- +- +- +- +-1 +Table 2: Perceptron algorithm execution +(b) +[6 points] Now, assume you do not care about the true label regarding the success of +the courses judged based on the SFQ. State whether each of the following scenarios for +which a perceptron using the ratings of the two students can correctly classify the data. +Justify your answer. +(i) If the total of their ratings is more than 8, then the course will be success and +otherwise it will fail, i.e., +Course Code +x1 +x2 +Success +COMP 1111 +No +COMP 2222 +No +COMP 3333 +Yes +COMP 4444 +No +COMP 5555 +No +(ii) The course will succeed if and only if each reviewer gives either a rating of 2 or a +rating of 3, i.e., +Course Code +x1 +x2 +Success +COMP 1111 +No +COMP 2222 +Yes +COMP 3333 +No +COMP 4444 +No +COMP 5555 +Yes', 14, 16, NULL::jsonb, NULL, NULL, 'Problem 6 [14 points] Perceptron +Suppose you are a CSE professor at HKUST who wants to offer a new course, COMP 2211. +Before starting, you want to predict if COMP 2211 will be successful or not. You approach +two students, A and B, asking them to read the proposal of 5 existing courses, COMP 1111, +COMP 2222, COMP 3333, COMP 4444, and COMP 5555, and rate them on a scale of 1 +to 5 (assume only integer scores). Assume each student reads the proposals independently +and announces their rating. As each student might be biased, you may be unable to average +their ratings. Instead, you decide to use a perceptron to classify the data using the training +dataset in Table 1. +Course Code +x1 +x2 +Success +COMP 1111 +No +COMP 2222 +Yes +COMP 3333 +No +COMP 4444 +Yes +COMP 5555 +Yes +Table 1: Training dataset +The training dataset is composed of the two student scores, x1 = score given by student A, +and x2 = score given by student B, and the corresponding true label regarding the success +of the courses judged based on the SFQ, for the 5 courses. +(a) [8 points] Train the perceptron to generate O = 1 if the course is success, O = 0 otherwise, +using the initial weight of w1 = 0, w2 = 0, θ = -1, and the activation function: +f(z) = + + + +if z ≤0 +otherwise +Also, use a learning rate of η = 1. +You may find the updating rules below useful. +∆wi = η(T −O)xi +∆θ = η(T −O) +wi = wi + ∆wi +θ = θ + ∆θ +Present each row of Table 1 as a training example, and update the perceptron weights +and bias before moving on to the next row. Show all the steps by completing Table 2. +x1 +x2 +T +O +∆w1 +w1 +∆w2 +w2 +∆θ +θ +- +- +- +- +- +- +- +-1 +-1 +-4 +-1 +-5 +-3 +-1 +-1 +Table 2: Perceptron algorithm execution +Marking scheme: +ˆ 0.2 point for each correct value. 40 values, 8 points in total +(b) +[6 points] Now, assume you do not care about the true label regarding the success of +the courses judged based on the SFQ. State whether each of the following scenarios for +which a perceptron using the ratings of the two students can correctly classify the data. +Justify your answer. +(i) +[3 points] If the total of their ratings is more than 8, then the course will be success +and otherwise it will fail, i.e., +Course Code +x1 +x2 +Success +COMP 1111 +No +COMP 2222 +No +COMP 3333 +Yes +COMP 4444 +No +COMP 5555 +No +Answer: +Perceptron can correctly classify the data in this scenario, because the data are +linearly separable. +Marking scheme: +ˆ 1 point for stating the perceptron can correctly classify the data +ˆ 2 points for the explanation +*** 2 points for the explanation part if correct weights and bias are given. *** +(ii) +[3 points] The course will succeed if and only if each reviewer gives either a rating +of 2 or a rating of 3, i.e., +Course Code +x1 +x2 +Success +COMP 1111 +No +COMP 2222 +Yes +COMP 3333 +No +COMP 4444 +No +COMP 5555 +Yes +Answer: +Perceptron cannot correctly classify the data in this scenario, because the data are +not linearly separable. +Marking scheme: +ˆ 1 point for stating the perceptron cannot correctly classify the data +ˆ 2 points for the explanation', ARRAY['Perceptron and MLP']::TEXT[], 'Perceptron and MLP', 'Perceptron and MLP', ARRAY['Perceptron and MLP']::TEXT[], ARRAY['forward_pass', 'backpropagation', 'weight_update']::TEXT[], 'medium', '', '', ''), + ('COMP2211-2022-fall-midterm', '7', NULL, 7, 'long_question', 'long_answer', 'Problem 7 [8 points] Multilayer Perceptron +Given the following training dataset with 4 examples in which each example has 3 binary +input attributes, x1, x2, x3 and 1 binary output T. +x1 +x2 +x3 +T +Suppose we are learning a Multilayer Perceptron with the training dataset above; the neural +network has 1 hidden layer of 2 neurons, answer the following questions. +(a) +[2 points] How many weights and how many biases are there in the neural network? +(b) +[6 points] Draw the neural network below. +-------------------- END OF PAPER +-------------------- +/* Rough work */ +/* Rough work */', 8, 18, NULL::jsonb, NULL, NULL, 'Problem 7 [8 points] Multilayer Perceptron +Given the following training dataset with 4 examples in which each example has 3 binary +input attributes, x1, x2, x3 and 1 binary output T. +x1 +x2 +x3 +T +Suppose we are learning a Multilayer Perceptron with the training dataset above; the neural +network has 1 hidden layer of 2 neurons, answer the following questions. +(a) +[2 points] How many weights and how many biases are there in the neural network? +Answer: +8 weights and 3 biases +Marking scheme: +ˆ 1 point for the number of weights +ˆ 1 point for the number of biases +*** 1 point if students only put 11 as the sum of these variables (e.g., 8 + 3 = 11) and +do not indicate 8 for weights and 3 for biases. *** +(b) +[6 points] Draw the neural network below. +Answer: +Marking scheme: +ˆ 3 points for indicating 3 input nodes, 2 hidden neurons, and 1 output neuron. +-0.5 point for losing 1 node/neuron. +ˆ 1 point for indicating 8 weights. +-0.5 point for losing 1 weight indicator, at most -1. +ˆ 1 point for indicating 3 biases. +-0.5 point for losing 1 bias indicator, at most -1. +ˆ 1 point for lines that connected layers. +-9,5 point for losing 1 connecting line, at most -1. +*** Additionally, suppose the number of nodes/neurons, the number of weights, the +number of biases, and the number of lines are more than expected. In that case, we will +only consider those correct ones, and the layer containing additional nodes/neurons will +be regarded as wrong. (e.g. if you put 3-¿4-¿1 as network, then only the input nodes +and output neuron consider to be correct, while all lines, weights, and biases should be +wrong, you can get 2 points.) *** +-------------------- END OF PAPER +--------------------', ARRAY['Perceptron and MLP']::TEXT[], 'Perceptron and MLP', 'Perceptron and MLP', ARRAY['Perceptron and MLP']::TEXT[], ARRAY['forward_pass', 'backpropagation', 'weight_update']::TEXT[], 'easy', '', '', ''), + ('COMP2211-2022-spring-midterm', '1', NULL, 1, 'true_false', 'true_false', 'Problem 1 [15 points] True/False Questions +Indicate whether the following statements are true or false by putting T or F in the given +table. You get 1.5 points for each correct answer. +(a) Machine learning is a sub-field of artificial intelligence. +(b) Tensorflow is easy to use and less flexible than Keras. +(c) Suppose we wish to calculate P(B|e1, e2) and we have no conditional independence in- +formation. Having P(e1, e2), P(B), P(e1, e2|B) are sufficient for the calculation. +(d) Training a K-nearest neighbors classifier takes more computational time than applying +it. +(e) K-nearest neighbors cannot be used for regression. +(f) Consider D-fold cross-validation. A higher number of folds (i.e. larger value of D), the +estimated error will be lower on average. +(g) K-means clustering algorithm is guaranteed to converge. +(h) Consider a two-class classification problem. Suppose we have trained a perceptron model +on a linearly separable training set, and now we get a new labeled data point which is +correctly classified by the model, and far away from the decision boundary. If we add +this new point to our earlier training set and re-train with the same set of initial weights +and bias, the learnt decision boundary will be changed for sure. +(i) Gradient descent is usually NOT guaranteed to converge at global minimum. +(j) If the learning rate is too small, gradient descent may take a very long time to converge +and computationally expensive. +Question +Answer (T/F) +(a) +(b) +(c) +(d) +(e) +(f) +(g) +(h) +(i) +(j)', 15, 2, NULL::jsonb, NULL, NULL, 'Problem 1 [15 points] True/False Questions +Indicate whether the following statements are true or false by putting T or F in the given +table. You get 1.5 points for each correct answer. +(a) Machine learning is a sub-field of artificial intelligence. +(b) Tensorflow is easy to use and less flexible than Keras. +(c) Suppose we wish to calculate P(B|e1, e2) and we have no conditional independence in- +formation. Having P(e1, e2), P(B), P(e1, e2|B) are sufficient for the calculation. +(d) Training a K-nearest neighbors classifier takes more computational time than applying +it. +(e) K-nearest neighbors cannot be used for regression. +(f) Consider D-fold cross-validation. A higher number of folds (i.e. larger value of D), the +estimated error will be lower on average. +(g) K-means clustering algorithm is guaranteed to converge. +(h) Consider a two-class classification problem. Suppose we have trained a perceptron model +on a linearly separable training set, and now we get a new labeled data point which is +correctly classified by the model, and far away from the decision boundary. If we add +this new point to our earlier training set and re-train with the same set of initial weights +and bias, the learnt decision boundary will be changed for sure. +(i) Gradient descent is usually NOT guaranteed to converge at global minimum. +(j) If the learning rate is too small, gradient descent may take a very long time to converge +and computationally expensive. +Question +Answer (T/F) +(a) +T +(b) +F +(c) +T +(d) +F +(e) +F +(f) +T +(g) +T +(h) +F +(i) +T +(j) +T +Marking scheme: +ˆ 1.5 points for each correct answer.', ARRAY['True/False']::TEXT[], 'True/False', 'True/False', ARRAY['True/False']::TEXT[], ARRAY['concept_check', 'rapid_reasoning']::TEXT[], 'medium', '', '', ''), + ('COMP2211-2022-spring-midterm', '2', NULL, 2, 'long_question', 'coding', 'Problem 2 [16 points] Python Fundamentals +For each of the Python expressions below, write the output when the expression is evaluated. +If the output is an empty array, write “Empty Array”. If an error occurs, write “Error”. +(a) +[5 points] Consider the following NumPy arrays: +import numpy as np +A = np.array([10, 20, 30, 40, 50, 60, 70, 80, 90]) +B = np.array([ [0, 1, 2, 3], +[4, 5, 6, 7], +[8, 9, 10, 11], +[12, 13, 14, 15] ]) +(i) print(A[2:6:3]) +(ii) print(B[0:2, 1:3]) +(iii) print(A[-1:-3]) +(iv) print(B[::-1]) +(v) print(B[:3,2:]) +(b) +[2 points] What is the output of the following code? +import numpy as np +X = np.array([2,2,0,2,2,0,1,1,0,1,1,0]) +Y = X[X != 0] +print(Y[::2]) +(c) +[9 points] Given the following code which computes the distance between each training +point in X train and each test point in X test using a nested loop over both the training +data and test data. +import numpy as np +def compute_distances_nested_loops(X_train, X_test): +num_test = X_test.shape[0] +num_train = X_train.shape[0] +distances = np.zeros((num_test, num_train)) +# --- BLOCK TO REWRITE --- +for i in range(num_test): +for j in range(num_train): +distances[i,j] = np.sqrt(np.sum(np.square(X_test[i,:]-X_train[j,:]))) +# --- BLOCK TO REWRITE --- +return distances +Train = np.array([[0,1], [1,2], [2,3], [3,4]]) +Test = np.array([[5,6], [7,8]]) +print(compute_distances_nested_loops(Train, Test)) +# Output: +# [[7.07106781 5.65685425 4.24264069 2.82842712] +# +[9.89949494 8.48528137 7.07106781 5.65685425]] +Rewrite the block of code between the comment lines # --- BLOCK TO REWRITE --- +using no explicit loops in the space provided. +Hints: +ˆ To compute the distance between training data point (0, 1) and test data point (5, 6), +we do +dist = (0 −5)2 + (1 −6)2 +ˆ Expand it +dist = 02 −2(0)(5) + 52 + 12 −2(1)(6) + 62 += 02 + 12 + 52 + 62 −2(0)(5) −2(1)(6) += 02 + 12 + 52 + 62 −2((0)(5) + (1)(6)) +You may find the following functions useful for this question. +ˆ Dot product of two arrays: +numpy.dot(a, b) +– It returns the product of matrix multiplication. +ˆ Return the element-wise square of the input +numpy.square(x) +– x is the input data +ˆ Return the sum of array elements over a given axis. +numpy.sum(a, axis=None) +– a is an array with elements to sum. +– axis: None or int or tuple of ints. axis = 0 means along the column and axis = +1 means working along ther row. +ˆ Return the non-negative square-root of an array, element-wise. +numpy.sqrt(x) +– x is the array with values whose square-roots are required. +ˆ Return a matrix (or a 2D array) from an 1D array. +numpy.matrix(data) +– data is the 1D array. +– Example: numpy.matrix([1, 2, 3]), output is [[1, 2, 3]] +ˆ Insert a new axis that will appear at the axis position in the expanded array shape. +numpy.expand_dims(a,axis) +– a is the input array. +– axis is an int or tuple of ints that represents poisition in the expanded axes where +the new axis (or axes) is placed.', 16, 4, NULL::jsonb, NULL, NULL, 'Problem 2 [16 points] Python Fundamentals +For each of the Python expressions below, write the output when the expression is evaluated. +If the output is an empty array, write “Empty Array”. If an error occurs, write “Error”. +(a) +[5 points] Consider the following NumPy arrays: +import numpy as np +A = np.array([10, 20, 30, 40, 50, 60, 70, 80, 90]) +B = np.array([ [0, 1, 2, 3], +[4, 5, 6, 7], +[8, 9, 10, 11], +[12, 13, 14, 15] ]) +(i) print(A[2:6:3]) +Answer: [30 60] +# 1 point +(ii) print(B[0:2, 1:3]) +Answer: +[[1 2] +[5 6]] +# 1 point +(iii) print(A[-1:-3]) +Answer: +Empty Array +# 1 point +(iv) print(B[::-1]) +Answer: +[[12 13 14 15] +# 1 point +[ 8 +9 10 11] +[ 4 +7] +[ 0 +3]] +(v) print(B[:3,2:]) +Answer: +[[ 2 +3] +# 1 point +[ 6 +7] +[10 11]] +Remark: +ˆ 0.5 point will be deducted if the answers are in raw number format without [](i.e. +1 2 5 6). Because in this case, the answer is no longer in array type, and we can- +not distinguish whether the answer is a 2d array or 1d array. This deduction only +performs once for a(i) and a(ii). +(b) +[2 points] What is the output of the following code? +import numpy as np +X = np.array([2,2,0,2,2,0,1,1,0,1,1,0]) +Y = X[X != 0] +print(Y[::2]) +Answer: +[2 2 1 1] +# 2 points +Remark: +ˆ 0.5 point will be deducted if the answers are in raw number format without [](i.e. +1 2 5 6). Because in this case, the answer is no longer in array type, and we can- +not distinguish whether the answer is a 2d array or 1d array. This deduction only +performs once for for a(i) and a(ii). +(c) +[9 points] Given the following code which computes the distance between each training +point in X train and each test point in X test using a nested loop over both the training +data and test data. +import numpy as np +def compute_distances_nested_loops(X_train, X_test): +num_test = X_test.shape[0] +num_train = X_train.shape[0] +distances = np.zeros((num_test, num_train)) +# --- BLOCK TO REWRITE --- +for i in range(num_test): +for j in range(num_train): +distances[i,j] = np.sqrt(np.sum(np.square(X_test[i,:]-X_train[j,:]))) +# --- BLOCK TO REWRITE --- +return distances +Train = np.array([[0,1], [1,2], [2,3], [3,4]]) +Test = np.array([[5,6], [7,8]]) +print(compute_distances_nested_loops(Train, Test)) +# Output: +# [[7.07106781 5.65685425 4.24264069 2.82842712] +# +[9.89949494 8.48528137 7.07106781 5.65685425]] +Rewrite the block of code between the comment lines # --- BLOCK TO REWRITE --- +using no explicit loops in the space provided. +Hints: +ˆ To compute the distance between training data point (0, 1) and test data point (5, 6), +we do +dist = (0 −5)2 + (1 −6)2 +ˆ Expand it +dist = 02 −2(0)(5) + 52 + 12 −2(1)(6) + 62 += 02 + 12 + 52 + 62 −2(0)(5) −2(1)(6) += 02 + 12 + 52 + 62 −2((0)(5) + (1)(6)) +You may find the following functions useful for this question. +ˆ Dot product of two arrays: +numpy.dot(a, b) +– It returns the product of matrix multiplication. +ˆ Return the element-wise square of the input +numpy.square(x) +– x is the input data +ˆ Return the sum of array elements over a given axis. +numpy.sum(a, axis=None) +– a is an array with elements to sum. +– axis: None or int or tuple of ints. axis = 0 means along the column and axis = +1 means working along ther row. +ˆ Return the non-negative square-root of an array, element-wise. +numpy.sqrt(x) +– x is the array with values whose square-roots are required. +ˆ Return a matrix (or a 2D array) from an 1D array. +numpy.matrix(data) +– data is the 1D array. +– Example: numpy.matrix([1, 2, 3]), output is [[1, 2, 3]] +ˆ Insert a new axis that will appear at the axis position in the expanded array shape. +numpy.expand_dims(a,axis) +– a is the input array. +– axis is an int or tuple of ints that represents poisition in the expanded axes where +the new axis (or axes) is placed. +Answer: +M = np.dot(X_test, X_train.T) +te = np.square(X_test).sum(axis=1) +tr = np.square(X_train).sum(axis=1) +dists = np.sqrt(-2*M + np.matrix(tr) + np.matrix(te).T) +Alternative answer: +distances = np.sqrt(np.sum(np.square(np.expand_dims(X_test, 1) - X_train), axis=2)) +distances = np.sqrt(np.sum(np.square(X_test[:,None] - X_train[None]), axis=-1)) +Marking scheme: +ˆ Case 0: Any for loop, list comprehension. # 0 point +ˆ Case 1: +(a) M = np.dot(X_test, X_train.T) # 2 points +1 point if missing transpose +(b) te = np.square(X_test).sum(axis=1) # 2 points +tr = np.square(X_train).sum(axis=1) # 2 points +– each square # 1 point +– each sum with right axis # 1 point +(c) dists = np.sqrt(-2*M + np.matrix(tr) + np.matrix(te).T) # 3 points +– 1 point if shape doesn’t match. +– 2 points if np.matrix is missing, or attempt to modify the shape but wrong +– if np.sqrt is missing, +* if previous part isn’t calculated correctly, 0 point for this part. +* -1 point otherwise. +ˆ Case 2: +distances = np.sqrt(np.sum(np.square(np.expand_dims(X_test, 1) - X_train), axis=2)) +# 4 points +(a) 4 points for the subtraction +– 2 points if the shape modification is wrong. +– 0 points if no shape modification at all. +(b) 2 points for square +(c) 2 points for sum; +1 point if axis is missing or wrong +(d) 1 point for sqrt (unlike Case 1, sqrt is worth 1 point here.) +(e) -3 points if a2–b2 is used. +Remarks: +(a) If reshaped is used to change the shape of the solution, -1 point. +(b) If the program assume there’s 4 train points or 2 test points, and the number of +dimension is 2, -1 point or 0 point for the whole question. +(c) If Train, Test used instead of Xtrain, Xtest, -1 point. If your program are terribly +wrong (< 3 points) anyway, this mark wouldn’t be deducted. +(d) If extra code which leads to wrong answer, -1 point. +(e) If the output shape is (numtrain, numtest) instead of (numtest, numtrain), -1 point. +(f) If expand dims/reshape used without reassigning the variable, i.e. a = a.reshape(*b.shape), +-1 point. +(g) If missing shape, (e.g. Xtrain[0] instead of Xtrain.shape[0]), – 0.5 point. +(h) Wrong spelling and syntax will not resulted in mark deduction, but 0.5 point will be +deducted if you got full points. +(i) Index using i j assuming the existence of for loop gets 0 point.', ARRAY['Python Fundamentals']::TEXT[], 'Python Fundamentals', 'Python Fundamentals', ARRAY['Python Fundamentals']::TEXT[], ARRAY['implementation', 'code_tracing', 'debugging']::TEXT[], 'hard', '', '', ''), + ('COMP2211-2022-spring-midterm', '3', NULL, 3, 'long_question', 'long_answer', 'Problem 3 [11 points] Conditional Probability and Bayes Classifier +(a) +[2 points] Given the following probabilities: +ˆ P(Good course | Desmond is in the course) = 0.5 +ˆ P(Good course | Desmond is not in the course) = 0.3 +ˆ P(Desmond in a randomly chosen course) = 0.1 +What is P(Desmond is in the course | Not a good course)? If your answer is not an +integer, write your answer in fraction form (use / to separate your numerator and de- +nominator), e.g. 1/2. +(b) +[7 points] Suppose you are given the following set of data with three Boolean input +variables A, B, C, and a single Boolean output variable D. +A +B +C +D +(i) According to the Naive Bayes classifier, what is P(D = 1|A = 1, B = 1, C = 0)? If +your answer is not an integer, write your answer in fraction form (use / to separate +your numerator and denominator), e.g. 1/2. +(ii) According to the Naive Bayes classifier, what is P(D = 0|A = 1, B = 1)? If your +answer is not an integer, write your answer in fraction form (use / to separate your +numerator and denominator), e.g. 1/2. +(iii) According to the general Bayes classifier (i.e. without independence assumption), +what is P(D = 1|A = 1, B = 1, C = 0)? If your answer is not an integer, write your +answer in fraction form (use / to separate your numerator and denominator), e.g. +1/2. +(iv) According to the general Bayes classifier (i.e. without independence assumption), +what is P(D = 1|A = 1, B = 1)? If your answer is not an integer, write your answer +in fraction form (use / to separate your numerator and denominator), e.g. 1/2. +(c) +[2 points] The Naive Bayes algorithm selects the class c for an example x that maxi- +mizes P(c|x). Suppose one of your classmates stated that it is equivalent to selecting the +c that maximizes P(x|c) under an assumption. What is the assumption that he has made?', 11, 7, NULL::jsonb, NULL, NULL, 'Problem 3 [11 points] Conditional Probability and Bayes Classifier +(a) +[2 points] Given the following probabilities: +ˆ P(Good course | Desmond is in the course) = 0.5 +ˆ P(Good course | Desmond is not in the course) = 0.3 +ˆ P(Desmond in a randomly chosen course) = 0.1 +What is P(Desmond is in the course | Not a good course)? If your answer is not an +integer, write your answer in fraction form (use / to separate your numerator and de- +nominator), e.g. 1/2. +Answer: +Let D be Desmond is in the course, G be a good course +P(D|NOT G) = P(D, NOT G) +P(Not G) += +P(Not G|D)P(D) +P(Not G|D)P(D) + P(Not G|Not D)P(Not D) += +(1 −0.5) × 0.1 +(1 −0.5) × 0.1 + (1 −0.3) × (1 −0.1) += 5 +Marking scheme: +ˆ 2 points for giving the correct answer. +(b) +[7 points] Suppose you are given the following set of data with three Boolean input +variables A, B, C, and a single Boolean output variable D. +A +B +C +D +(i) According to the Naive Bayes classifier, what is P(D = 1|A = 1, B = 1, C = 0)? If +your answer is not an integer, write your answer in fraction form (use / to separate +your numerator and denominator), e.g. 1/2. +Answer: +P(D = 1|A = 1, B = 1, C = 0) = +P(D = 1)P(A = 1|D = 1)P(B = 1|D = 1)P(C = 0|D = 1) +P1 +j=0 P(D = j)P(A = 1|D = j)P(B = 1|D = j)P(C = 0|D = j) += +(4/8)(2/4)(1/4)(2/4) +(4/8)(2/4)(2/4)(1/4) + (4/8)(2/4)(1/4)(2/4) = 1 +Marking scheme: +ˆ 1.75 points for giving the correct answer. +(ii) According to the Naive Bayes classifier, what is P(D = 0|A = 1, B = 1)? If your +answer is not an integer, write your answer in fraction form (use / to separate your +numerator and denominator), e.g. 1/2. +Answer: +P(D = 0|A = 1, B = 1) = +P(D = 0)P(A = 1|D = 0)P(B = 1|D = 0) +P1 +j=0 P(D = j)P(A = 1|D = j)P(B = 1|D = j) += +(4/8)(2/4)(2/4) +(4/8)(2/4)(2/4) + (4/8)(2/4)(1/4) = 2 +Marking scheme: +ˆ 1.75 points for giving the correct answer. +(iii) According to the general Bayes classifier (i.e. without independence assumption), +what is P(D = 1|A = 1, B = 1, C = 0)? If your answer is not an integer, write your +answer in fraction form (use / to separate your numerator and denominator), e.g. +1/2. +Answer: +P(D = 1|A = 1, B = 1, C = 0) = 0 as there is no data with A = 1, B = 1, C += 0 and D = 1 in the dataset. +Marking scheme: +ˆ 1.75 points for giving the correct answer. +(iv) According to the general Bayes classifier (i.e. without independence assumption), +what is P(D = 1|A = 1, B = 1)? If your answer is not an integer, write your answer +in fraction form (use / to separate your numerator and denominator), e.g. 1/2. +Answer: +P(D = 1|A = 1, B = 1) = 1/2 as number of data with A = 1, B = 1, D = 1 +is 1, and number of data with A = 1, B = 1 is 2. +Marking scheme: +ˆ 1.75 points for giving the correct answer. +(c) +[2 points] The Naive Bayes algorithm selects the class c for an example x that maxi- +mizes P(c|x). Suppose one of your classmates stated that it is equivalent to selecting the +c that maximizes P(x|c) under an assumption. What is the assumption that he has made? +Answer: +P(c|x) = P(x|c)P(c) +P(x) +, so finding the c that maximizes P(c|x) is equivalent to finding c +that maximizes P(x|c), if the prior P(c) is uniform. +Marking scheme: +ˆ 2 points for stating the assumption correctly.', ARRAY['Probabilistic Models']::TEXT[], 'Probabilistic Models', 'Probabilistic Models', ARRAY['Probabilistic Models']::TEXT[], ARRAY['manual_computation', 'probability_reasoning', 'classification_decision']::TEXT[], 'medium', '', '', ''), + ('COMP2211-2022-spring-midterm', '4', NULL, 4, 'long_question', 'long_answer', 'Problem 4 [14 points] K-Nearest Neighbors +(a) [3 points] Consider a set of 5 training data given as ((xTrain +, xTrain +), cTrain) values, where +xTrain +and xTrain +are the two attribute values (positive integers) and cTrain is the binary +class label, A or B: +{ ((6,7),A), ((4,8),B), ((6,5),B), ((7,9),A), ((2,4),A) } +Classify a test example (xTest +, xTest +) with attribute values (4,7) using a KNN classifier +with K = 3 and Manhattan distance defined by +distance(xTrain, xTest) = P2 +i=1 |xTrain +i +−xTest +i +| +where | · | denote absolute value. +Complete the following table by filling in the computed Manhattan distance between +each training data point and the test example. Determine the class label based on the +results. +xTrain +xTrain +cTrain +Distance +A +B +B +A +A +(b) [6 points] Judge whether each of the following student’s claims is correct or not. Explain +why. +(i) +[3 points] A student claims that the results of a general KNN classifier that uses +Euclidean distance will change if we multiply all attribute values of each training +and test data point by 0.5. +(ii) [3 points] Another student claims that the classification accuracy of the training set +will always increase if the value of K used in KNN classifier is incrementally increased +from 1 to N, where N is the total number of training examples. +(c) [5 points] Consider KNN using Euclidean distance on the following data set. Each point +belongs to one of the two classes: + and x. +(i) +[2 points] Perform 10-fold cross validation on the given data set (i.e. the 10 data +points as shown in the figure), what is the validation error when using 1-nearest +neighbor? +(ii) [3 points] Which of the following values of K leads to the minimum 10-fold cross vali- +dation error: 3, 5 or 9? What is the error for that K? If there is a tie, please elaborate.', 14, 9, NULL::jsonb, NULL, NULL, 'Problem 4 [14 points] K-Nearest Neighbors +(a) [3 points] Consider a set of 5 training data given as ((xTrain +, xTrain +), cTrain) values, where +xTrain +and xTrain +are the two attribute values (positive integers) and cTrain is the binary +class label, A or B: +{ ((6,7),A), ((4,8),B), ((6,5),B), ((7,9),A), ((2,4),A) } +Classify a test example (xTest +, xTest +) with attribute values (4,7) using a KNN classifier +with K = 3 and Manhattan distance defined by +distance(xTrain, xTest) = P2 +i=1 |xTrain +i +−xTest +i +| +where | · | denote absolute value. +Complete the following table by filling in the computed Manhattan distance between +each training data point and the test example. Determine the class label based on the +results. +xTrain +xTrain +cTrain +Distance +A +B +B +A +A +Answer: +xTrain +xTrain +cTrain +Distance +A +B +B +A +A +The predicted class label of the test example is B. +Marking scheme: +ˆ 0.5 point for giving each correct distance value. 2.5 points in total. +ˆ 0.5 point for giving the correct class label. +(b) [6 points] Judge whether each of the following student’s claims is correct or not. Explain +why. +(i) +[3 points] A student claims that the results of a general KNN classifier that uses +Euclidean distance will change if we multiply all attribute values of each training +and test data point by 0.5. +Answer: +The claim is false, because K nearest neighbors will remain unchanged after multi- +plying all attribute values of each training and test data points by 0.5. +Marking scheme: +ˆ 1 point for stating the claim is false. +ˆ 2 points for giving the correct explanation. +Remark: +ˆ 1 point given if stating the claim is correct but did mention that the change on +distance will be a multiplication of constant to the old distance AND didn’t state +result of KNN classifier will change. +ˆ 2 points given if stating the claim is correct but mention the change on distance +will be a multiplication and state the result wont change. +ˆ 0 point if correct deduction but draw the conclusion that result will change. +ˆ 1 point for explanation if treat the question as asking multiply the values on +Manhattan and answered correctly. +ˆ 0 point for explanation if treat the question as asking comparison between Man- +hattan and euclidean distance. +(ii) [3 points] Another student claims that the classification accuracy of the training set +will always increase if the value of K used in KNN classifier is incrementally increased +from 1 to N, where N is the total number of training examples. +Answer: +The claim is false. A counterexample is as follows: +The training set accuracy when K = 1 will be 100%. +As K approaches the total number of training examples more and more examples +influence the class, and eventually the class will always be the majority class in the +training set. +Marking scheme: +ˆ 1 point for stating the claim is false. +ˆ 2 points for giving the correct explanation. +Remarks: +ˆ Give full mark if they misregard outliers as further neighbors. +ˆ -1 point if they mention is due to outliers but didn’t explain what is regarded as +outliers. +ˆ -2 points if they really mean outliers. +ˆ -1 point if they only state larger k will cover more neighbors (this is just explain- +ing what does a larger k means but not explaining why larger k can lower the +accuracy) (*further neighbors is accepted but more neighbors is not) +ˆ Other accept answers: +i. Larger k include further neighbors which have low relevancy. +ii. Underfitting +iii. Giving a concrete situation that the claim is wrong. +(c) [5 points] Consider KNN using Euclidean distance on the following data set. Each point +belongs to one of the two classes: + and x. +(i) +[2 points] Perform 10-fold cross validation on the given data set (i.e. the 10 data +points as shown in the figure), what is the validation error when using 1-nearest +neighbor? +Answer: +Every point is misclassified. So, the validation error is 10/10. +Marking scheme: +ˆ 2 points for giving the correct validation error. +Note: Both 10/10 and 100% are accepted as the correct answers. +(ii) [3 points] Which of the following values of K leads to the minimum 10-fold cross vali- +dation error: 3, 5 or 9? What is the error for that K? If there is a tie, please elaborate. +Answer: +All 3 values of K mis-classify all of the points and have the same classification errors, +10/10. +Marking scheme: +ˆ 2 points for stating all 3 values of K have the same classification errors. +ˆ 1 point for giving the correct error. +Note: Both 10/10 and 100% are accepted as the correct answers.', ARRAY['KNN and Clustering']::TEXT[], 'KNN and Clustering', 'KNN and Clustering', ARRAY['KNN and Clustering']::TEXT[], ARRAY['manual_computation', 'distance_calculation', 'algorithm_tracing']::TEXT[], 'medium', '', '', ''), + ('COMP2211-2022-spring-midterm', '5', NULL, 5, 'long_question', 'long_answer', 'Problem 5 [12 points] K-Means Clustering +Given a 1-dimensional data set {0, 3, 6, 9, 27, 30}, use the K-means clustering algorithm and +Euclidean distance to cluster the given points in the data set into 2 clusters. Assume c1 = 3 +and c2 = 4 are chosen as the initial cluster centers. +(a) [4.5 points] Perform one iteration of K-means clustering by finding the Euclidean distance +between each data point in the data set and the centroids, and assign each data point to +the closest centroid according to the distance found. Fill in the following table with your +computed values. If your distance value is not an integer, write your answer in decimal +form, e.g. 1.234. +Data Point +Distance between the +data point and c1 = 3 +Distance between the +data point and c2 = 4 +Closest Centroid +(c1 or c2?) +(b) +[1.5 points] What are the values of c1 and c2 after one iteration of K-means? If your +answer is not an integer, write your answer in decimal form, e.g. 1.234. +(c) [4.5 points] Perform the second iteration of K-means clustering by finding the Euclidean +distance between each data point in the data set and the computed centroids in part (b), +and assign each data point to the closest centroid according to the distance found. Fill in +the following table with your computed values. If your distance value is not an integer, +write your answer in decimal form, e.g. 1.234. +Data Point +Distance between the +data point and the c1 +computed in part (b) +Distance between the +data point and the c2 +computed in part (b) +Closest Centroid +(c1 or c2?) +(d) +[1.5 points] What are the values of c1 and c2 after the second iteration of K-means? If +your answer is not an integer, write your answer in decimal form, e.g. 1.234.', 12, 11, NULL::jsonb, NULL, NULL, 'Problem 5 [12 points] K-Means Clustering +Given a 1-dimensional data set {0, 3, 6, 9, 27, 30}, use the K-means clustering algorithm and +Euclidean distance to cluster the given points in the data set into 2 clusters. Assume c1 = 3 +and c2 = 4 are chosen as the initial cluster centers. +(a) [4.5 points] Perform one iteration of K-means clustering by finding the Euclidean distance +between each data point in the data set and the centroids, and assign each data point to +the closest centroid according to the distance found. Fill in the following table with your +computed values. If your answer is not an integer, write your answer in decimal form, +e.g. 1.234. +Data Point +Distance between the +data point and c1 = 3 +Distance between the +data point and c2 = 4 +Closest Centroid +(c1 or c2?) +Answer: +Data Point +Distance between the +data point and c1 = 3 +Distance between the +data point and c2 = 4 +Closest Centroid +(c1 or c2?) +c1 +c1 +c2 +c2 +c2 +c2 +Marking scheme: +ˆ 0.25 for giving each correct value. 4.5 points in total. +(b) +[1.5 points] What are the values of c1 and c2 after one iteration of K-means? If your +answer is not an integer, write your answer in decimal form, e.g. 1.234. +Answer: +c1 = 0 + 3 += 1.5 +c2 = 6 + 9 + 27 + 30 += 18 +Marking scheme: +ˆ 0.75 for giving each correct centroid. 1.5 points in total. +(c) [4.5 points] Perform the second iteration of K-means clustering by finding the Euclidean +distance between each data point in the data set and the computed centroids in part (b), +and assign each data point to the closest centroid according to the distance found. Fill +in the following table with your computed values. If your answer is not an integer, write +your answer in decimal form, e.g. 1.234. +Data Point +Distance between the +data point and the c1 +computed in part (b) +Distance between the +data point and the c2 +computed in part (b) +Closest Centroid +(c1 or c2?) +Answer: +Data Point +Distance between the +data point and the c1 +computed in part (b) +Distance between the +data point and the c2 +computed in part (b) +Closest Centroid +(c1 or c2?) +1.5 +c1 +1.5 +c1 +4.5 +c1 +7.5 +c1 +25.5 +c2 +28.5 +c2 +Marking scheme: +ˆ 0.25 for giving each correct value. 4.5 points in total. +(d) +[1.5 points] What are the values of c1 and c2 after the second iteration of K-means? If +your answer is not an integer, write your answer in decimal form, e.g. 1.234. +Answer: +c1 = 0 + 3 + 6 + 9 += 4.5 +c2 = 27 + 30 += 28.5 +Marking scheme: +ˆ 0.75 for giving each correct centroid. 1.5 points in total.', ARRAY['KNN and Clustering']::TEXT[], 'KNN and Clustering', 'KNN and Clustering', ARRAY['KNN and Clustering']::TEXT[], ARRAY['manual_computation', 'distance_calculation', 'algorithm_tracing']::TEXT[], 'medium', '', '', ''), + ('COMP2211-2022-spring-midterm', '6', NULL, 6, 'long_question', 'long_answer', 'Problem 6 [20 points] Perceptron +Given the following training dataset: +x1 +0.5 +x2 +0.5 +T +-1 +-1 +-1 +(a) +[8 points] Show the action of the perceptron algorithm for the above sequence of data +points by completing the following table. Assume η = 1 and we start with the following +initial weights and bias +w1 = 1 +w2 = 1 +θ = 0 +and use the following activation function. +f(z) = + + + +z ≥0 +−1 +otherwise +Updating rules: +∆wi = η(T −O)xi +∆θ = η(T −O) +wi = wi + ∆wi +θ = θ + ∆θ +where i ∈{1, 2}. +If your answer is not an integer, write your answer in decimal form, e.g. 1.234. +x1 +x2 +T +O +∆w1 +w1 +∆w2 +w2 +∆θ +θ +- +- +- +- +- +- +- +(b) +[2 points] According to the values in the table above, state whether the perceptron +algorithm is converged in 1 epoch. If not, explain why. +(c) [10 points] Write a Python program to verify your answers of Part (a). In your program, +you need to define the following variables. +ˆ A 2D NumPy array, X, to store all the attribute values x1, x2, where the shape is +(8,2) +ˆ A 1D NumPy array, T,to store the target values, where the shape is (8,) +ˆ A 1D NumPy array, W, to store the weights, where the shape is (2,) +ˆ A float bias value, b. +and perform the required computations. Also, print the following sequence of values (in +exact order) for each iteration: +x1 < s > x2 < s > T < s > O < s > ∆w1 < s > w1 < s > ∆w2 < s > w2 < s > ∆θ < s > θ < end > +where < s > refers to an empty space, and < end > refers to an end of line character. +The following shows a line of sample output. +0 0 0 0 0 0 0 0 0 0 +Remark: You cannot use any other libraries other than NumPy in your program.', 20, 12, NULL::jsonb, NULL, NULL, 'Problem 6 [20 points] Perceptron +Given the following training dataset: +x1 +0.5 +x2 +0.5 +T +-1 +-1 +-1 +(a) +[8 points] Show the action of the perceptron algorithm for the above sequence of data +points by completing the following table. Assume η = 1 and we start with the following +initial weights and bias +w1 = 1 +w2 = 1 +θ = 0 +and use the following activation function. +f(z) = + + + +z ≥0 +−1 +otherwise +Updating rules: +∆wi = η(T −O)xi +∆θ = η(T −O) +wi = wi + ∆wi +θ = θ + ∆θ +where i ∈{1, 2}. +If your answer is not an integer, write your answer in decimal form, e.g. 1.234. +x1 +x2 +T +O +∆w1 +w1 +∆w2 +w2 +∆θ +θ +- +- +- +- +- +- +- +Answer: +x1 +x2 +T +O +∆w1 +w1 +∆w2 +w2 +∆θ +θ +- +- +- +- +- +- +- +-1 +-2 +-2 +-2 +-1 +-6 +-5 +-6 +-5 +-2 +-4 +-1 +-2 +0.5 +0.5 +-1 +-1 +-1 +-2 +-4 +-4 +-4 +Marking scheme: +ˆ 0.1 point for giving each correct value. 8 points in total. +(b) +[2 points] According to the values in the table above, state whether the perceptron al- +gorithm is converged in 1 epoch. If not, explain why. +Answer: +The algorithm is not converged in 1 epoch. +Since there are changes of weights and +biases. +Marking scheme: +ˆ 1 point for stating the algorithm is not converged. +ˆ 1 point for giving a correct explanation. +(c) [10 points] Write a Python program to verify your answers of Part (a). In your program, +you need to define the following variables +ˆ A 2D NumPy array, X, to store all the attribute values x1, x2, where the shape is +(8,2) +ˆ A 1D NumPy array, T,to store the target values, where the shape is (8,) +ˆ A 1D NumPy array, W, to store the weights, where the shape is (2,) +ˆ A float bias value, b. +and perform the required computations. Also, print the following sequence of values (in +exact order) for each iteration: +x1 < s > x2 < s > T < s > O < s > ∆w1 < s > w1 < s > ∆w2 < s > w2 < s > ∆θ < s > θ < end > +where < s > refers to an empty space, and < end > refers to an end of line character. +The following shows a line of sample output. +0 0 0 0 0 0 0 0 0 0 +Remark: You cannot use any other libraries other than NumPy in your program. +Answer: +import numpy as np +# 0.5 point +X = np.array([[10,10], [0,0], [8,4], [3,3], [4,8], [0.5,0.5], [4,3], [2,5]]) +# 1 point +T = np.array([1,-1,1,-1,1,-1,1,1]) +# 0.5 point +W = np.array([1,1], dtype=float) +# 1 point +b = 0.0 +# 0.5 point +for i in range(X.shape[0]): +# 1 point +y = X[i].dot(W) + b +# 1 point +if y >= 0: +# 0.5 point +O = 1 +# 0.5 point +else: +O = -1 +# 0.5 point +DW = (T[i] - O) * X[i] +# 0.5 point +W += DW +# 0.5 point +Db = (T[i] - O) +# 0.5 point +b += Db +# 0.5 point +print(X[i][0], X[i][1], T[i], O, DW[0], W[0], DW[1], W[1], Db, b) +# 1 point +Remarks: +ˆ -0.25 point for forgetting to specify W = np.array([1,1], dtype=float) +ˆ Can also b = 0, Python will duck-type into float when needed. +ˆ -0.25 point each for not using NumPy array. No overlap with W float penalty, since +Python list supports duck-typing. +ˆ -0.25 point each for wrong array shape. +ˆ -0.5 point for hard-coding for i in range(8):. +ˆ -0.25 point for forgetting bias in the output calculation. +ˆ -0.25 point for minor print formatting errors. +ˆ -0.25 point for incorrect print when output == target. +ˆ -0.25 point each for miscellaneous syntax errors. +ˆ -1 flat point for defining as class/function(s) but not calling. +ˆ -10 floor point for using SKlearn.', ARRAY['Perceptron and MLP']::TEXT[], 'Perceptron and MLP', 'Perceptron and MLP', ARRAY['Perceptron and MLP']::TEXT[], ARRAY['forward_pass', 'backpropagation', 'weight_update']::TEXT[], 'hard', '', '', ''), + ('COMP2211-2022-spring-midterm', '7', NULL, 7, 'long_question', 'long_answer', 'Problem 7 [12 points] Perceptron and Multilayer Perceptron +(a) +[4 points] Can we represent the given boolean function with a single neuron as shown +below? +A +B +f(A,B) +If yes, show the possible weights, bias and activation function that can be used to com- +pute the output of all the data points correctly. If not, explain why not in 1-2 sentences. +(b) [6 points] Suppose we have a neural network as shown below with θ1 = 0, θ2 = 0, θ3 = 0, +and linear activation function (i.e. f(x) = Cx, where C is a constant). +Can any function that is represented by the above network be represented by a single +unit of artificial neural network in the following diagram? If so, detail the weights (w7 +and w8), bias (θ), and the activation function f(x). Otherwise, explain why not. +(c) +[2 points] Given a multilayer perceptron with 3 layers (input layer, hidden layer and +output layer). The number of units in each of the these layers are 3, 4, 2. Assume each +input or neuron is fully-connected. Calculate the number of trainable parameters of this +network. +-------------------- END OF PAPER +--------------------', 12, 14, NULL::jsonb, NULL, NULL, 'Problem 7 [12 points] Perceptron and Multilayer Perceptron +(a) +[4 points] Can we represent the given boolean function with a single neuron as shown +below? +A +B +f(A,B) +If yes, show the possible weights, bias and activation function that can be used to com- +pute the output of all the data points correctly. If not, explain why not in 1-2 sentences. +Answer: +Yes, we can represent this function with a single neuron, since it is linearly separable. +One set of possible weights and bias is: w1 = 1, w2 = −1, θ = −0.5, and the activation +function is +f(x) = + + + +x > 0 +otherwise +Marking scheme: +ˆ 1 point for stating it is possible to represent the given boolean function with a single +neuron. +ˆ 1 point for showing the “standard” activation function. +ˆ 2 points for correct values of w1, w2, and θ, given the activation function is the +“standard” one. +ˆ If the given activation function is not “standard”, but the parameters are suitable, +also get 2 points. +ˆ 0 point for stating not possible to represent. +(b) [6 points] Suppose we have a neural network as shown below with θ1 = 0, θ2 = 0, θ3 = 0, +and linear activation function (i.e. f(x) = Cx, where C is a constant). +Can any function that is represented by the above network be represented by a single +unit of artificial neural network in the following diagram? If so, detail the weights (w7 +and w8), bias (θ), and the activation function f(x). Otherwise, explain why not. +Answer: +Yes, the network can be represented by a single unit of artificial neural network by +setting its weights, w7 = w1w5 + w3w6, w8 = w2w5 + w4w6, and the activation function +f(x) = C2. +Marking scheme: +ˆ 0.5 point for stating any function that is represented by the MLP can be represented +by a single unit of artificial neural network. +ˆ 1.5 points for showing each possible weight w7, w8. 3 points in total. +ˆ 1 point for showing a possible bias value, i.e. 0. +ˆ 1.5 for showing the activation function. +Remarks: +ˆ If stating NO, -6 points. +But among those NO’s, if students put the expression +OUTPUT=C2w5(x1w1 + x2w2 + θ1) + C2w6(x1w3 + x2w4 + θ2) + Cθ3, give 1 point. +ˆ For those stating YES, if missing some value, deduct the score of that value. +ˆ If the order of C is wrong, e.g. one C in w7w8 and no C in activation, or C2 in w7w8 +and one C in activation, -1 point. +ˆ For other wrong values, -full mark for that values. +ˆ If didn’t plug in given values and the expression is wrong, even if the expression may +evaluate to the same value as the answer, -full mark for that expression or -1 point +if it’s an order-of-C issue. +ˆ If didn’t plug in given values, e.g. θs=0, the same C for all units (some use C1C2C3), +but otherwise correct, -0.5 for each of θ & C. +(c) +[2 points] Given a multilayer perceptron with 3 layers (input layer, hidden layer and +output layer). The number of units in each of the these layers are 3, 4, 2. Assume each +input or neuron is fully-connected. Calculate the number of trainable parameters of this +network. +Answer: +3 ∗4 + 4 + 4 ∗2 + 2 = 26. +Marking scheme: +ˆ 2 points for giving the correct answer. +-------------------- END OF PAPER +--------------------', ARRAY['Perceptron and MLP']::TEXT[], 'Perceptron and MLP', 'Perceptron and MLP', ARRAY['Perceptron and MLP']::TEXT[], ARRAY['forward_pass', 'backpropagation', 'weight_update']::TEXT[], 'medium', '', '', ''), + ('COMP2211-2022-spring-final-part-a', '1', NULL, 1, 'true_false', 'true_false', 'Problem 1 [10 points] True/False Questions +Indicate whether the following statements are true or false by putting T or F in the given +table. You get 1 point for each correct answer. +(a) One drawback of the K-means algorithm is that one needs to specify exactly how many +clusters the algorithm should find. +(b) Increasing the number of hidden layers always increases the model performance. +(c) When handling a binary classification task, both softmax and sigmoid functions can be +used as the activation function in the output layer. +(d) Validation accuracy must be lower than training accuracy. +(e) We cannot train/inference deep learning networks using CPU. +(f) Otsu’s thresholding method and affine transformations are point-based image operations. +(g) In the convolutional layer of a CNN, the number of weights depends on the depth of the +input volume and the number of biases is equal to the number of kernels. +(h) After training a neural network, you observe a large gap between the training accuracy +(100%) and the task accuracy (40%). Dropout is commonly used to reduce this gap. +(i) In a minimax-based 3×3 tic-tac-toe game, an AI player will definitely win because it +knows all possible moves of the game. +(j) The alpha-beta pruning algorithm is preferred to minimax because it computes the same +answer as minimax while usually doing so without examining as much of the game tree. +Question +Answer (T/F) +(a) +(b) +(c) +(d) +(e) +(f) +(g) +(h) +(i) +(j)', 10, 2, NULL::jsonb, NULL, NULL, 'Problem 1 [10 points] True/False Questions +Indicate whether the following statements are true or false by putting T or F in the given +table. You get 1 point for each correct answer. +(a) One drawback of the K-means algorithm is that one needs to specify exactly how many +clusters the algorithm should find. +(b) Increasing the number of hidden layers always increases the model performance. +(c) When handling a binary classification task, both softmax and sigmoid functions can be +used as the activation function in the output layer. +(d) Validation accuracy must be lower than training accuracy. +(e) We cannot train/inference deep learning networks using CPU. +(f) Otsu’s thresholding method and affine transformations are point-based image operations. +(g) In the convolutional layer of a CNN, the number of weights depends on the depth of the +input volume and the number of biases is equal to the number of kernels. +(h) After training a neural network, you observe a large gap between the training accuracy +(100%) and the task accuracy (40%). Dropout is commonly used to reduce this gap. +(i) In a minimax-based 3×3 tic-tac-toe game, an AI player will definitely win because it +knows all possible moves of the game. +(j) The alpha-beta pruning algorithm is preferred to minimax because it computes the same +answer as minimax while usually doing so without examining as much of the game tree. +Answer: +Question +Answer (T/F) +(a) +T +(b) +F +(c) +T +(d) +F +(e) +F +(f) +F +(g) +T +(h) +T +(i) +F +(j) +T +Marking scheme: +ˆ 1 point for giving each correct answer. 10 points in total.', ARRAY['True/False']::TEXT[], 'True/False', 'True/False', ARRAY['True/False']::TEXT[], ARRAY['concept_check', 'rapid_reasoning']::TEXT[], 'medium', '', '', ''), + ('COMP2211-2022-spring-final-part-a', '2', NULL, 2, 'long_question', 'long_answer', 'Problem 2 [14 points] Na¨ıve Bayes and K-Nearest Neighbors +Given the training data in the table below. +No. +CGPA +Interest in +Computing Subjects +Practice-oriented +Learner? +COMP 2211 +Grade +Select COMP +as Major +≤3 +High +No +B +No +≤3 +High +No +A +No +> 3 AND ≤4 +High +No +B +Yes +> 4 +Medium +No +B +Yes +> 4 +Low +Yes +B +Yes +> 4 +Low +Yes +A +No +> 3 AND ≤4 +Low +Yes +A +Yes +≤3 +Medium +No +B +No +≤3 +Low +Yes +B +Yes +> 4 +Medium +Yes +B +Yes +≤3 +Medium +Yes +A +Yes +> 3 AND ≤4 +Medium +No +A +Yes +> 3 AND ≤4 +High +Yes +B +Yes +> 4 +Medium +No +A +No +(a) +[6.5 points] Given a new example with the following attribute values. Predict the value +of its “Select COMP as Major” using Na¨ıve Bayes classifier. Show all the steps. +ˆ CGPA ≤3 +ˆ Interest in Computing Subjects = Medium +ˆ Practice-oriented Learner = Yes +ˆ COMP 2211 Grade = B +(b) +[7.5 points] Similar to the above, but this time, predict the value of its “Select COMP +as Major” using K-nearest neighbor for K = 5. Complete the following table and state +the prediction result based on the data in the completed table. For similarity measure, +use a simple match of attribute values: +S(ai, bi) = +X +i=1 +wi ∗distance(ai, bi) +where distance(ai, bi) is 0 if ai equals bi, and 1 otherwise. ai and bi are either CGPA, +interest in computing subjects, practice-oriented learner or COMP 2211 grade. Weights, +wi, are all 1 except for interest in computing subjects, it is 2. +No. +Class +Distance to New Example +No +No +Yes +Yes +Yes +No +Yes +No +Yes +Yes +Yes +Yes +Yes +No', 14, 4, NULL::jsonb, NULL, NULL, 'Problem 2 [14 points] Na¨ıve Bayes and K-Nearest Neighbors +Given the training data in the table below. +No. +CGPA +Interest in +Computing Subjects +Practice-oriented +Learner? +COMP 2211 +Grade +Select COMP +as Major +≤3 +High +No +B +No +≤3 +High +No +A +No +> 3 AND ≤4 +High +No +B +Yes +> 4 +Medium +No +B +Yes +> 4 +Low +Yes +B +Yes +> 4 +Low +Yes +A +No +> 3 AND ≤4 +Low +Yes +A +Yes +≤3 +Medium +No +B +No +≤3 +Low +Yes +B +Yes +> 4 +Medium +Yes +B +Yes +≤3 +Medium +Yes +A +Yes +> 3 AND ≤4 +Medium +No +A +Yes +> 3 AND ≤4 +High +Yes +B +Yes +> 4 +Medium +No +A +No +(a) +[6.5 points] Given a new example with the following attribute values. Predict the value +of its “Select COMP as Major” using Na¨ıve Bayes classifier. Show all the steps. +ˆ CGPA ≤3 +ˆ Interest in Computing Subjects = Medium +ˆ Practice-oriented Learner = Yes +ˆ COMP 2211 Grade = B +Answer: Let +ˆ E be CGPA ≤3, Interest in Computing Subjects = Medium, Practice-oriented +Learner = Yes, COMP 2211 Grade = B +ˆ E1 be CGPA ≤3 +ˆ E2 be interest in computing subjects = medium +ˆ E3 be practice-oriented learner = yes +ˆ E4 be COMP 2211 grade = B +P(Y es|E) = P(E1|Y es)P(E2|Y es)P(E3|Y es)P(E4|Y es)P(Y es) +P(E) +P(Y es) = 9/14 = 0.643 +P(E1|Y es) = 2/9 = 0.222 +P(E2|Y es) = 4/9 = 0.444 +P(E3|Y es) = 6/9 = 0.667 +P(E4|Y es) = 6/9 = 0.667 +P(Y es|E) = (0.222)(0.444)(0.667)(0.668)(0.443) +P(E) += 0.028 +P(E) +P(No|E) = P(E1|No)P(E2|No)P(E3|No)P(E4|No)P(No) +P(E) +P(No) = 5/14 = 0.356 +P(E1|No) = 3/5 = 0.6 +P(E2|No) = 2/5 = 0.4 +P(E3|No) = 1/5 = 0.2 +P(E4|No) = 2/5 = 0.4 +P(No|E) = (0.6)(0.4)(0.2)(0.4)(0.357) +P(E) += 0.007 +P(E) +Hence, the Na¨ıve Bayes classifier predicts “Select COMP as Major” = Yes for the new +example. +Marking scheme: +ˆ 0.5 for giving each conditional and prior probability. 6 points in total. +ˆ 0.5 for giving the correct prediction. +(b) +[7.5 points] Similar to the above, but this time, predict the value of its “Select COMP +as Major” using K-nearest neighbor for K = 5. Complete the following table and state +the prediction result based on the data in the completed table. For similarity measure, +use a simple match of attribute values: +S(ai, bi) = +X +i=1 +wi ∗distance(ai, bi) +where distance(ai, bi) is 0 if ai equals bi, and 1 otherwise. ai and bi are either CGPA, +interest in computing subjects, practice-oriented learner or COMP 2211 grade. Weights, +wi, are all 1 except for interest in computing subjects, it is 2. +No. +Class +Distance to New Example +No +0 + 2 + 1 + 0 = 3 +No +0 + 2 + 1 + 1 = 4 +Yes +1 + 2 + 1 + 0 = 4 +Yes +1 + 0 + 1 + 0 = 2 +Yes +1 + 2 + 0 + 0 = 3 +No +1 + 2 + 0 + 1 = 4 +Yes +1 + 2 + 0 + 1 = 4 +No +0 + 0 + 1 + 0 = 1 +Yes +0 + 2 + 0 + 0 = 2 +Yes +1 + 0 + 0 + 0 = 1 +Yes +0 + 0 + 0 + 1 = 1 +Yes +1 + 0 + 1 + 1 = 3 +Yes +1 + 2 + 0 + 0 = 3 +No +1 + 0 + 1 + 1 = 3 +Among the 5 nearest neighbors, 4 are from class Yes, and 1 from class No. Hence, the +KNN classifier predicts “Select COMP as Major” = Yes for the new example. +Marking scheme: +ˆ 0.5 point for giving each correct answer. 7 points in total. +ˆ 0.5 point for giving the correct prediction.', ARRAY['Probabilistic Models', 'KNN and Clustering']::TEXT[], 'Probabilistic Models', NULL, ARRAY['Probabilistic Models', 'KNN and Clustering']::TEXT[], ARRAY['manual_computation', 'probability_reasoning', 'classification_decision', 'distance_calculation', 'algorithm_tracing']::TEXT[], 'medium', '', '', ''), + ('COMP2211-2022-spring-final-part-a', '3', NULL, 3, 'long_question', 'long_answer', 'Problem 3 [14 points] Multilayer Perceptron (MLP) +This problem is about multilayer perceptron (MLP). Answer all the questions below. +(a) [3 points] State when using the F1 metric is better than using accuracy as an evaluation +metric. Also use a confusion matrix to illustrate the stated situation. +(b) +[2 points] Suppose we are dealing with binary classification tasks using MLP. Explain +why it is inappropriate to use ReLU as the activation function in the output layer. +(c) +[2 points] Design the output layer of an MLP for handling a multilabel classification +problem with n classes by stating the number of neurons and the activation function. +Remark: Multilabel classification is a supervised learning problem where an instance +may be associated with multiple labels. +(d) +[3 points] Explain why it is not good to initialize all weights of an MLP to zero. +Hint: Refer to the following updating rules of weights and biases for MLP. +ˆ δk = (Ok −Tk)Ok(1 −Ok) +ˆ δj = Oj(1 −Oj) P +k∈K δkwjk +ˆ wjk ←wjk −ηδkOj +ˆ wij ←wij −ηδjOi +ˆ θj ←θj −ηδj +ˆ θk ←θk −ηδk +(e) +[2 points] Explain what will happen if the learning rate η of an MLP is +(i) Too large +(ii) Too small +(f) +[2 points] Describe a way to avoid overfitting in MLP. Explain why it works.', 14, 6, NULL::jsonb, NULL, NULL, 'Problem 3 [14 points] Multilayer Perceptron (MLP) +This problem is about multilayer perceptron (MLP). Answer all the questions below. +(a) [3 points] State when using the F1 metric is better than using accuracy as an evaluation +metric. Also use a confusion matrix to illustrate the stated situation. +Answer: +ˆ When the dataset is unbalanced. +ˆ When false-negative and false-positive matter a lot. +Actual/Predicted +Infectious disease=yes +Infectious disease=no +Infectious disease=yes +Infectious disease=no +Accuracy = 0.8016 +F1 score = 0.0741 +Marking scheme: +ˆ 1 point for stating the situation +ˆ 2 points for the confusion matrix +(b) +[2 points] Suppose we are dealing with binary classification tasks using MLP. Explain +why it is inappropriate to use ReLU as the activation function in the output layer. +Answer: +We cannot determine the cut-off threshold to distinguish between the output classes when +there is an unbounded output range. +Marking scheme: +ˆ 2 points for explaining by the “unbounded” range of ReLU so cannot determine the +cut-off +ˆ 1 point if mentioning the range of function (*not describing what ReLU do, but +stating the range) but no further explanation +ˆ 0 point if only stating ReLU can output value more than 0 & 1 without mentioning it +has an unbounded range (bounded function beyond the range from 0 to 1 can work, +just do the mapping) +(c) +[2 points] Design the output layer of an MLP for handling a multilabel classification +problem with n classes by stating the number of neurons and the activation function. +Remark: Multilabel classification is a supervised learning problem where an instance +may be associated with multiple labels. +Answer: +n neurons and sigmoid function. +Marking scheme: +ˆ 1 point for n neurons +ˆ 1 point for sigmoid +(d) +[3 points] Explain why it is not good to initialize all weights of an MLP to zero. +Hint: Refer to the following updating rules of weights and biases for MLP. +ˆ δk = (Ok −Tk)Ok(1 −Ok) +ˆ δj = Oj(1 −Oj) P +k∈K δkwjk +ˆ wjk ←wjk −ηδkOj +ˆ wij ←wij −ηδjOi +ˆ θj ←θj −ηδj +ˆ θk ←θk −ηδk +Answer: +If a network is initialized with all zeros, all the neurons will propagate on the same +gradient, making different neurons learn the same features. Thus, this leads to poor +performance. +Marking scheme: +ˆ 3 points if “the same update/propagate/feature learned/symmetry problem” +ˆ 2 points if “zero updates” as it isn’t always true for all MLP design +ˆ 2 points if stating only gradient computation are the same for neurons +ˆ 1 point if only mentioning deltaj will become zero / stating gradient “may” not +update +ˆ 0 point for low efficiency / poor performance +(e) +[2 points] Explain what will happen if the learning rate η of an MLP is +(i) Too large +(ii) Too small +Answer: +(i) +ˆ Cause the model to coverage too quickly to a sub-optimal solution. +ˆ Unstable training like oscillations. +Marking scheme: +ˆ 1 point for explaining what will happen if the learning rate is too large. +ˆ not accept learn faster/overfit/low accuracy +(ii) Learning will be slow. +Marking scheme: +ˆ 1 point for explaining what will happen if the learning rate is too small. +ˆ not accept underfit +(f) +[2 points] Describe a way to avoid overfitting in MLP. Explain why it works. +Answer: +Adding regulations helps to keep the weights small, such that it is less likely for the +model to have a large variance (i.e. be sensitive to noise and fluctuations in data). +Marking scheme: +ˆ 1 point for method +ˆ 1 point for explanation +ˆ only valid explanation (but not stating the definition or recalling some rule of thumbs) +get the explanation point', ARRAY['Perceptron and MLP']::TEXT[], 'Perceptron and MLP', 'Perceptron and MLP', ARRAY['Perceptron and MLP']::TEXT[], ARRAY['forward_pass', 'backpropagation', 'weight_update']::TEXT[], 'medium', '', '', ''), + ('COMP2211-2022-spring-final-part-a', '4', NULL, 4, 'long_question', 'long_answer', 'Problem 4 [15 points] Digital Image Processing +(a) Assume we apply the following kernel to an 8-bit grayscale image. +K = + + +−1 +−2 +−2 + + +(i) +[2 points] Determine the maximum and minimum possible values that a pixel to +which the given kernel is applied can have. Do not perform any normalization. +(ii) +[2 points] Suggest a grey-level transformation function to ensure that any output of +this kernel will be within the legal range of a standard 8-bit grayscale image. +(b) Consider the following 2 × 2 image: +Apply the following image operations sequentially. +(i) +[3 points] Show the resulting image of size 4 × 4 after adding reflection padding. +(ii) [4 points] Apply a 3×3 averaging kernel to the resulting image of part (b)(i). Assume +the output image after image averaging is in the same shape as the input image by +doing zero padding. Round the number to integer if needed. +(iii) [4 points] Compute the optimal threshold after applying ONE ITERATION of Otsu’s +method on the resulting image of part (b)(ii). Assume the initial threshold is set to +the mean pixel intensity of the resulting image.', 15, 7, NULL::jsonb, NULL, NULL, 'Problem 4 [15 points] Digital Image Processing +(a) Assume we apply the following kernel to an 8-bit grayscale image. +K = + + +−1 +−2 +−2 + + +(i) +[2 points] Determine the maximum and minimum possible values that a pixel to +which the given kernel is applied can have. Do not perform any normalization. +Answer: +Since an 8-bit grayscale image is assumed, the highest value that each pixel can +have is 255, and the lowest value is 0. +After applying the kernel, the maximum +value is achieved when the negative values of the kernel multiply 0s and the pos- +itive values of the kernel multiply 255s. Then, the maximum achievable value is +vmax = (2 + 2 + 1)(255) = 1275. Following the same reasoning, the minimum value +will be vmin = (−2 −2 −1)(255) = −1275. +Marking scheme: +ˆ 1 point for stating the maximum possible value. +ˆ 1 point for stating the minimum possible value. +(ii) +[2 points] Suggest a grey-level transformation function to ensure that any output of +this kernel will be within the legal range of a standard 8-bit grayscale image. +Answer: +A 8-bit image must have a range from 0 to 255. Since the maximum and minimum +possible values for the gray levels are vmax = 1275 and vmin = −1275, respectively, +the function will be +Ioutput = 255 +Iinput −vmin +vmax −vmin + += 255 +Iinput −(−1275) +1275 −(−1275) + += 255 +Iinput + 1275 + +where Iinput and Ioutput are the input and output images, respectively. +Marking scheme: +ˆ 2 points for stating a transformation function. +ˆ -1 point if the function is merely returning legal range. +(b) Consider the following 2 × 2 image: +Apply the following image operations sequentially. +(i) +[3 points] Show the resulting image of size 4 × 4 after adding reflection padding. +Answer: +Marking scheme: +ˆ 0.25 point for each correct value. 3 points in total. +(ii) +[4 points] Apply a 3 × 3 averaging kernel to the resulting image of part (b)(i). As- +sume the output image after image averaging is in the same shape as the input image +by doing zero padding. Round the number to integer if needed. +Answer: +Marking scheme: +ˆ 0.25 point for each correct value. 4 points in total. +(iii) [4 points] Compute the optimal threshold after applying ONE ITERATION of Otsu’s +method on the resulting image of part (b)(ii). Assume the initial threshold is set to +the mean pixel intensity of the resulting image. +Answer: +ˆ Initial threshold = (0 + 2 + 4 + 4 + 4 + 9 + 12 + 10 + 8 + 15 + 18 + 14 + 8 + 14 + +16 + 12)/16 = 9.375 +ˆ µ1 = (0 + 2 + 4 + 4 + 4 + 9 + 8 + 8)/8 = 4.875 +ˆ µ2 = (12 + 10 + 15 + 18 + 14 + 14 + 16 + 12)/8 = 13.875 +ˆ Optimal threshold = (4.875 + 13.875) = 9.375 +Marking scheme: +ˆ 1 point for the answer of initial threshold. +ˆ 1 point for the answer of µ1. +ˆ 1 point for the answer of µ2. +ˆ 1 point for the optimal threshold after 1 iteration. +ˆ -1 point for incorrect input from part b(ii). I.e. incorrect calculation based on +result of part b(ii.)', ARRAY['Vision and CNN']::TEXT[], 'Vision and CNN', 'Vision and CNN', ARRAY['Vision and CNN']::TEXT[], ARRAY['manual_computation', 'filter_computation', 'architecture_reasoning']::TEXT[], 'medium', '', '', ''), + ('COMP2211-2022-spring-final-part-b', '1', NULL, 1, 'long_question', 'long_answer', 'Problem 1 [19 points] Convolutional Neural Network (CNN) +(a) +[1.5 points] Suppose there is a convolutional layer in a CNN with the following parame- +ters. +ˆ Kernel size: 3 × 3 +ˆ Zero padding: 1-pixel border on each side +ˆ Stride: 2-pixel in each direction +Compute the output feature map of the convolutional layer with the following input im- +age and kernel. Assume the number of channels for both input and output images is 1. +Express your answer as a 2D nested list. E.g. [[2,2],[2,2]]. +Input image: +Kernel: +(b) +[1.5 points] Give an example of a data augmentation technique that would be useful for +classifying images of cats and dogs, but not for classifying handwritten digits. Briefly +explain your answer. +(c) [4.5 points] Assume we have a CNN with the architecture as described in Table 1. Com- +plete the table by filling in the input and output shape of each layer in the format: ‘height +x width x channel’ (Note: A space is placed before and after the symbol ’x’). To illustrate +that, the input shape of the 1st convolutional layer has been inserted for you. +Formula: +Convolution output size += [ (size of image dimension - size of kernel dimension + 2 × padding) / stride ] + 1 +Layer (Size, Specifications) +Input Shape +Output Shape +7x7 conv, 64 kernels, stride 2, padding 3, with biases +224 x 224 x 3 +3x3 max pooling, stride 2, padding 1 +3x3 conv, 128 kernels, stride 2, with biases +3x3 conv, 256 kernels, stride 2, padding 1, with biases +3x3 conv, 512 kernels, stride 2, padding 1, with biases +Table 1: Architecture of a CNN +(d) +[2 points] Assume we want to add a fully-connected layer at the back of the CNN as +described in part (b). However, the output of the resulting network still contains a large +number of parameters. State what we should do if we want to reduce the number of +parameters and summarize the output. +Hint: We want to turn N × N × 512 output to 1 × 1 × 512. +Remark: Convolution layer is not an acceptable answer. +(e) +[2.5 points] Calculate the total number of parameters of the model? (i.e. for all the +layers described in Table 1). +(f) Assume the model should classify images into 1000 distinct classes by +(i) [2.5 points] Appending the network shown in Table 2 to the end of the CNN network +(i.e. the one described in Table 1) appended with the structure that you added in +part (c). +Layer (Size, Specifications) +Input Shape +Output Shape +Flatten +1 x 1 x 512 +Fully-connected (Flatten before feed-in) +Table 2: Classification network +Calculate the total number of parameters in the whole network (i.e. +the layers +described in Table 1, your answer in part (c), and the layers described in Table 2). +(ii) [2.5 points] Now, suppose we use the MLP described in Table 3 to classify the images +instead of CNN. +Layer (Size, Specifications) +Input Shape +Output Shape +Flatten +224 x 224 x 3 +Fully-connected (Flatten before feed-in) +Table 3: Classification using MLP +Calculate the total number of parameters in the MLP (i.e. only those described in +Table 3). +(g) +[2 points] State what you observe in part (e). Also, state the property of CNN, which +leads to this difference.', 19, 2, NULL::jsonb, NULL, NULL, 'Problem 1 [19 points] Convolutional Neural Network (CNN) +(a) +[1.5 points] Suppose there is a convolutional layer in a CNN with the following parame- +ters. +ˆ Kernel size: 3 × 3 +ˆ Zero padding: 1-pixel border on each side +ˆ Stride: 2-pixel in each direction +Compute the output feature map of the convolutional layer with the following input im- +age and kernel. Assume the number of channels for both input and output images is 1. +Express your answer as a 2D nested list. E.g. [[2,2],[2,2]]. If your calculated answer +is a floating-point number, convert it to an integer using the floor function. +Input image: +Kernel: +Answer: +[[73]] for using the unflipped kernel OR +[[27]] for using the flipped kernel. +Marking scheme: +ˆ Accepted answers: +[[73 (39, 43, 69)]], [[27 (61, 31, 57)]] (for flipped kernel) +-1 point if the format is wrong. e.g., missing bracket(s). +If there are more than 1 value, the correct answer will be instead a 2x2 array ex- +tracted from the following arrays. There are two possible solutions: +[A[1, 1], A[1, 2]], [A[2, 1], A[2, 2]] +[A[1, 1], A[1, 3]], [A[3, 1], A[3, 3]] (stride 2 with extended zero padding) +A = [[ 2, 16, 38, 28], +[ 5, 27, 61, 53], +[ 8, 31, 57, 60], +[ 3, 13, 21, 27]] +A = [[18, 44, 22, 12], +[25, 73, 39, 17], +[22, 69, 43, 10], +[ 7, 27, 19, +3]] +(this two array is generated by +scipy.signal.convolve2d(in1, in2, mode=''full'', boundary=''fill'', fillvalue=0)) +There are two possible arrays because traditionally, the former one is the “real” con- +volution. I.e. the kernel or image is flipped along both axes before the convolving op- +erations. However, at the latter one (the originally intended answer), it’s calculated +WITHOUT the flipping. This operation is called the cross-correlation operation. +(b) +[1.5 points] Give an example of a data augmentation technique that would be useful for +classifying images of cats and dogs, but not for classifying handwritten digits. Briefly +explain your answer. +Answer: +Flipping the image horizontally. Doing this to a dog/cat image would be reasonable, +but not so for an image of a handwritten digit. +Marking scheme: +ˆ 2 points for “rotate and reflections” as augmentation methods. +ˆ 0 point for image processing methods. +(c) [4.5 points] Assume we have a CNN with the architecture as described in Table 1. Com- +plete the table by filling in the input and output shape of each layer in the format: ‘height +x width x channel’ (Note: A space is placed before and after the symbol ’x’). To illustrate +that, the input shape of the 1st convolutional layer has been inserted for you. +Formula: +Convolution output size += [ (size of image dimension - size of kernel dimension + 2 × padding) / stride ] + 1 +If your calculated answer is a floating-point number, convert it to an integer using the +floor function. +Layer (Size, Specifications) +Input Shape +Output Shape +7x7 conv, 64 kernels, stride 2, padding 3, with biases +224 x 224 x 3 +112 x 112 x 64 +3x3 max pooling, stride 2, padding 1 +112 x 112 x 64 +56 x 56 x 64 +3x3 conv, 128 kernels, stride 2, with biases +56 x 56 x 64 +27 x 27 x 128 +3x3 conv, 256 kernels, stride 2, padding 1, with biases +27 x 27 x 128 +13 x 13 x 256 +3x3 conv, 512 kernels, stride 2, padding 1, with biases +13 x 13 x 256 +7 x 7 x 512 +Table 1: Architecture of a CNN +Marking scheme: +ˆ 0.25 point for number of channels +ˆ 0.25 point for height and width +ˆ -1 point if the format is wrong, e.g. (c, h, w) instead of (h, w, c) +(d) +[2 points] Assume we want to add a fully-connected layer at the back of the CNN as +described in part (b). However, the output of the resulting network still contains a large +number of parameters. State what we should do if we want to reduce the number of +parameters and summarize the output. +Hint: We want to turn N × N × 512 output to 1 × 1 × 512. +Remark: Convolution layer is not an acceptable answer. +Answer: +Max pooling or average pooling. +Marking scheme: +ˆ 2 points for giving the correct answer. +ˆ -0.5 point if type of pooling wasn’t specified. +ˆ -0.5 point if pooling is not mentioned. (e.g. taking average) As pooling is a standard +component of neural networks. +ˆ 1 point for resize. +(e) +[2.5 points] Calculate the total number of parameters of the model? (i.e. for all the +layers described in Table 1). +Answer: +The total number of parameters =(7 × 7 × 3 × 64 + 64)+ +(3 × 3 × 64 × 128 + 128)+ +(3 × 3 × 128 × 256 + 256)+ +(3 × 3 × 256 × 512 + 512) +=9472 + 73856 + 295168 + 1180160 +=1558656 +Marking scheme: +ˆ 2.5 point for giving the correct answer. +(f) Assume the model should classify images into 1000 distinct classes by +(i) [2.5 points] Appending the network shown in Table 2 to the end of the CNN network +(i.e. the one described in Table 1) appended with the structure that you added in +part (c). +Layer (Size, Specifications) +Input Shape +Output Shape +Flatten +1 x 1 x 512 +Fully-connected (Flatten before feed-in) +Table 2: Classification network +Calculate the total number of parameters in the whole network (i.e. the layers de- +scribed in Table 1, your answer in part (d), and the layers described in Table 2). +Answer: +The total number of parameters =1558656 + (512 × 1000 + 1000) +=1558656 + 513000 +=2071656 +Marking scheme: +ˆ 2.5 point for giving the correct answer. +(ii) [2.5 points] Now, suppose we use the MLP described in Table 3 to classify the images +instead of CNN. +Layer (Size, Specifications) +Input Shape +Output Shape +Flatten +224 x 224 x 3 +Fully-connected (Flatten before feed-in) +Table 3: Classification using MLP +Calculate the total number of parameters in the MLP (i.e. only those described in +Table 3). +Answer: +The total number of parameters =(150528 × 1000 + 1000) +=150529000 +Marking scheme: +ˆ 2.5 point for giving the correct answer. +(g) +[2 points] State what you observe in part (f). Also, state the property of CNN, which +leads to this difference. +Answer: +The number of parameters of CNN is significantly less than the number of parameters of +pure MLP. This is because CNN uses shared parameters (kernels) to process the input +instead of using 1 parameter for each individual input. +Marking scheme: +ˆ 1 point for observations. If the observation is wrong, explanation is ignored. +ˆ 1 point for explanation if mentioned “large scale extraction” but “feature extraction” +only gains 0.5 point. +ˆ 1 point for “sparse connection” +ˆ 0 point for reducing output size. +As fully-connected layer could also reduce the +output size for the following fc layer, and yet the number of parameter is even more +bloated.', ARRAY['Vision and CNN']::TEXT[], 'Vision and CNN', 'Vision and CNN', ARRAY['Vision and CNN']::TEXT[], ARRAY['manual_computation', 'filter_computation', 'architecture_reasoning']::TEXT[], 'hard', '', '', ''), + ('COMP2211-2022-spring-final-part-b', '2', NULL, 2, 'long_question', 'coding', 'Problem 2 [13 points] Python Programming: Convolutional Neural Network +Suppose the following CNN model learns human emotions from RGB images of faces and +classifies into 12 emotion categories. +Assumptions: All layers have no padding. Both two convolutional layers have 3 × 3 kernels. +The model is trained with Adam optimizer in default learning rate, and the loss function is +categorical cross-entropy. You don’t need to specify extra metrics like accuracy. +(a) +[9 points] According to all the information given above, write the Python codes to con- +struct and compile the model using Keras library. The following import statements are +provided for you. Also, a reference of useful Keras classes and functions are given in the +appendix. +from keras.models import Sequential +from keras.layers import Conv2D, MaxPooling2D +from keras.layers import Dense, Flatten +(b) +[4 points] Now instead of classification, a student wants to predict a single continuous +real value from an input picture: how positive (or negative) the emotion is, ranging from +0 to 1. Is CNN in general able to do that? If yes, derive a model to complete such a task +from part (a) model. Point out which parts you will change when building and compiling +the model (if any) and explain how you will change it. (You don’t have to write codes, +but state clearly your ideas). If no, also explain why. +Appendix: +Below are some Keras documentation for your reference. Some irrelevant parameters are +omitted for conciseness. +Sequential class +tf.keras.Sequential(layers=None, name=None) +Sequential groups a linear stack of layers into a tf.keras.Model +ˆ add Method +Sequential.add(layer) +– layer: layer instance +ˆ compile Method +Model.compile(optimizer="rmsprop", loss=None) +– optimizer: String (name of optimizer) or optimizer instance. +– loss: Loss function. +Conv2D class +tf.keras.layers.Conv2D( +filters, kernel_size, strides=(1, 1), padding="valid", activation=None, +) +2D convolution layer (e.g. spatial convolution over images). When using this layer as the +first layer in a model, provide the keyword argument input shape (tuple of integers, does +not include the sample axis), e.g. input shape=(128,128,3) for 128x128 RGB pictures in +data format="channels last". +ˆ filters: Integer, the dimensionality of the output space (i.e. the number of output filters +in the convolution). +ˆ kernel size: An integer or tuple/list of 2 integers, specifying the height and width of the +2D convolution window. Can be a single integer to specify the same value for all spatial +dimensions. +ˆ strides: An integer or tuple/list of 2 integers, specifying the strides of the convolution +along the height and width. Can be a single integer to specify the same value for all +spatial dimensions. Specifying any stride value != 1 is incompatible with specifying any +dilation rate value != 1. +ˆ padding: one of “valid” or “same” (case-insensitive). +“valid” means no padding. +“same” results in padding with zeros evenly to the left/right or up/down of the input. +When padding="same" and strides=1, the output has the same size as the input. +ˆ activation: Activation function to use. If you don’t specify anything, no activation is +applied. +MaxPooling2D class +tf.keras.layers.MaxPooling2D( +pool_size=(2, 2), strides=None, padding="valid", +) +Max pooling operation for 2D spatial data. +ˆ pool size: integer or tuple of 2 integers, window size over which to take the maximum. +(2, 2) will take the max value over a 2x2 pooling window. If only one integer is specified, +the same window length will be used for both dimensions. +ˆ strides: Integer, tuple of 2 integers, or None. +Strides values. +Specifies how far the +pooling window moves for each pooling step. If None, it will default to pool size. +ˆ padding: One of “valid” or “same” (case-insensitive). +“valid” means no padding. +“same” results in padding evenly to the left/right or up/down of the input such that +output has the same height/width dimension as the input. +Flatten class +tf.keras.layers.Flatten() +Flattens the input. +Dense class +tf.keras.layers.Dense( +units, activation=None, +) +Regular densely-connected NN layer. +ˆ units: Positive integer, dimensionality of the output space. +ˆ activation: Activation function to use. If you don’t specify anything, no activation is +applied. +Common activation functions (in shorthand strings): “relu”, “sigmoid”, “softmax” +Common loss functions (in shorthand strings): +“categorical crossentropy”, +“sparse categorical crossentropy”, +“mean squared error” (same as “MSE”), +“mean absolute error” (same as “MAE”) +Common optimizer (in shorthand strings): “adam”', 13, 5, NULL::jsonb, NULL, NULL, 'Problem 2 [13 points] Python Programming: Convolutional Neural Network +Suppose the following CNN model learns human emotions from RGB images of faces and +classifies into 12 emotion categories. +Assumptions: All layers have no padding. Both two convolutional layers have 3 × 3 kernels. +The model is trained with Adam optimizer in default learning rate, and the loss function is +categorical cross-entropy. You don’t need to specify extra metrics like accuracy. +(a) +[9 points] According to all the information given above, write the Python codes to con- +struct and compile the model using Keras library. The following import statements are +provided for you. Also, a reference of useful Keras classes and functions are given in the +appendix. +from keras.models import Sequential +from keras.layers import Conv2D, MaxPooling2D +from keras.layers import Dense, Flatten +Answer: +model = Sequential() +model.add(Conv2D(filters=32, kernel_size=(3, 3), activation=''relu'', +strides=(3,3), input_shape=(32, 32, 1))) +model.add(Conv2D(filters=64, kernel_size=(3, 3), activation=''relu'', stride=(2,2))) +model.add(MaxPooling2D(pool_size=(2, 2))) +model.add(Flatten()) +model.add(Dense(units=12, activation=''softmax'')) +model.compile(optimizer=''adam'', loss=''categorical_crossentropy'') +Marking scheme: +ˆ Apply the following rules until the score is 0. +– -1 for each of Pooling, Flatten, Dense, Compile lines if it is missing or has any +wrong parameter +– -1 for each of the wrong or missing parameters in a Conv2D layer. +This in- +cludes any parameter that is good by default but you break it by setting another +absolutely wrong value. +– -1 if the input shape is not specified in any manner +– -1 for syntax errors like messed up brackets, the layers are not actually added to +a model, etc. Small typos on keywords, etc. are not penalized +(b) +[4 points] Now instead of classification, a student wants to predict a single continuous +real value from an input picture: how positive (or negative) the emotion is, ranging from +0 to 1. Is CNN in general able to do that? If yes, derive a model to complete such a task +from part (a) model. Point out which parts you will change when building and compiling +the model (if any) and explain how you will change it. (You don’t have to write codes, +but state clearly your ideas). If no, also explain why. +Answer: +Yes, CNN is able to do that in general, and the following parts need to be changed. +ˆ Change the Dense layer to units=1. +ˆ Change the Dense layer activation to any other than softmax, e.g., relu or sigmoid. +ˆ Change the loss to any regression loss, e.g., MSE, MAE. +Marking scheme: +ˆ 1 point for stating CNN is able to do that in general. +ˆ 1 point for changing the output Dense unit to 1 +ˆ 1 point for changing output activation to anything not related to probability and +range containing [0,1] (sigmoid, linear, relu, etc.) +ˆ 1 point for changing the loss function to any real value comparison (subtraction), +e.g. Mean Squared Error, Mean Absolute Error, or other similar self-defined losses. +ˆ -0.5 point for each change if the student doesn’t specify to what it changes or changes +it to a wrong value. +Appendix: +Below are some Keras documentation for your reference. Some irrelevant parameters are +omitted for conciseness. +Sequential class +tf.keras.Sequential(layers=None, name=None) +Sequential groups a linear stack of layers into a tf.keras.Model +ˆ add Method +Sequential.add(layer) +– layer: layer instance +ˆ compile Method +Model.compile(optimizer="rmsprop", loss=None) +– optimizer: String (name of optimizer) or optimizer instance. +– loss: Loss function. +Conv2D class +tf.keras.layers.Conv2D( +filters, kernel_size, strides=(1, 1), padding="valid", activation=None, +) +2D convolution layer (e.g. spatial convolution over images). When using this layer as the +first layer in a model, provide the keyword argument input shape (tuple of integers, does +not include the sample axis), e.g. input shape=(128,128,3) for 128x128 RGB pictures in +data format="channels last". +ˆ filters: Integer, the dimensionality of the output space (i.e. the number of output filters +in the convolution). +ˆ kernel size: An integer or tuple/list of 2 integers, specifying the height and width of the +2D convolution window. Can be a single integer to specify the same value for all spatial +dimensions. +ˆ strides: An integer or tuple/list of 2 integers, specifying the strides of the convolution +along the height and width. Can be a single integer to specify the same value for all +spatial dimensions. Specifying any stride value != 1 is incompatible with specifying any +dilation rate value != 1. +ˆ padding: one of “valid” or “same” (case-insensitive). +“valid” means no padding. +“same” results in padding with zeros evenly to the left/right or up/down of the input. +When padding="same" and strides=1, the output has the same size as the input. +ˆ activation: Activation function to use. If you don’t specify anything, no activation is +applied. +MaxPooling2D class +tf.keras.layers.MaxPooling2D( +pool_size=(2, 2), strides=None, padding="valid", +) +Max pooling operation for 2D spatial data. +ˆ pool size: integer or tuple of 2 integers, window size over which to take the maximum. +(2, 2) will take the max value over a 2x2 pooling window. If only one integer is specified, +the same window length will be used for both dimensions. +ˆ strides: Integer, tuple of 2 integers, or None. +Strides values. +Specifies how far the +pooling window moves for each pooling step. If None, it will default to pool size. +ˆ padding: One of “valid” or “same” (case-insensitive). +“valid” means no padding. +“same” results in padding evenly to the left/right or up/down of the input such that +output has the same height/width dimension as the input. +Flatten class +tf.keras.layers.Flatten() +Flattens the input. +Dense class +tf.keras.layers.Dense( +units, activation=None, +) +Regular densely-connected NN layer. +ˆ units: Positive integer, dimensionality of the output space. +ˆ activation: Activation function to use. If you don’t specify anything, no activation is +applied. +Common activation functions (in shorthand strings): “relu”, “sigmoid”, “softmax” +Common loss functions (in shorthand strings): +“categorical crossentropy”, +“sparse categorical crossentropy”, +“mean squared error” (same as “MSE”), +“mean absolute error” (same as “MAE”) +Common optimizer (in shorthand strings): “adam”', ARRAY['Python Fundamentals']::TEXT[], 'Python Fundamentals', 'Python Fundamentals', ARRAY['Python Fundamentals']::TEXT[], ARRAY['implementation', 'code_tracing', 'debugging']::TEXT[], 'medium', '', '', ''), + ('COMP2211-2022-spring-final-part-b', '3', NULL, 3, 'long_question', 'long_answer', 'Problem 3 [8 points] Minimax and Alpha-Beta Pruning +Two players, MAX and MIN, are playing a game that can be represented by a tree, as shown +below. +(a) +[3.5 points] Complete the following table by estimating the minimax value of each non- +terminal node. +A +B +C +D +E +F +G +(b) +[0.5 point] State the proper move of the maximizer by writing down one of the root’s +outgoing edges (i.e. E-a or E-b). +Note: The root node of the given tree is A. +(c) +[1.5 points] State whether minimax-based AI will choose to make a move which will +result in a slower victory. Explain your answer. +(d) +[2.5 points] Suppose we now apply alpha-beta pruning on the game tree. Indicate the +edge(s) that would be pruned (eliminated from consideration) by writing down the edge +labels (i.e. E-a, E-b, E-c, . . ., E-n). You may assume that the branches are explored +from left to right.', 8, 9, NULL::jsonb, NULL, NULL, 'Problem 3 [8 points] Minimax and Alpha-Beta Pruning +Two players, MAX and MIN, are playing a game that can be represented by a tree, as shown +below. +(a) +[3.5 points] Complete the following table by estimating the minimax value of each non- +terminal node. +Answer: +A +B +C +-1 +D +E +F +-1 +G +Marking scheme: +ˆ 0.5 point for each correct answer. 3.5 points in total. +(b) +[0.5 point] State the proper move of the maximizer by writing down one of the root’s +outgoing edges (i.e. E-a or E-b). +Note: The root node of the given tree is A. +Answer: +E-a +Marking scheme: +ˆ 0.5 point for the correct answer. +(c) +[1.5 points] State whether minimax-based AI will choose to make a move which will +result in a slower victory. Explain your answer. +Answer: +Yes, minimax-based AI may choose to make a move, resulting in a slower victory. Since +we may have two moves with the same maximum minimax value, it picks the one in +slower victory. +Marking scheme: +ˆ 1 point for stating minimax-based AI will choose to make a move which will result +in a slower victory. +ˆ 0.5 point for giving the proper explanation. +(d) +[2.5 points] Suppose we now apply alpha-beta pruning on the game tree. Indicate the +edge(s) that would be pruned (eliminated from consideration) by writing down the edge +labels (i.e. E-a, E-b, E-c, . . ., E-n). You may assume that the branches are explored +from left to right. +Answer: +E-j and E-f would be pruned. +Marking scheme: +ˆ If E-j appears: +1 point. +ˆ If E-f appears: +1.5 point. +ˆ If E-m or E-n appears: +0 point. +ˆ If other label appears: then d) become 0 points, no matter E-j and E-f appears or +not.', ARRAY['Search and Games']::TEXT[], 'Search and Games', 'Search and Games', ARRAY['Search and Games']::TEXT[], ARRAY['tree_search', 'pruning', 'manual_tracing']::TEXT[], 'easy', '', '', ''), + ('COMP2211-2022-spring-final-part-b', '4', NULL, 4, 'long_question', 'short_answer', 'Problem 4 [7 points] Ethics of Artificial Intelligence +This question consists of five sub-questions, four of them are multiple-choice questions, and +one is a short question. Choose the BEST ANSWER among the given choices for each +multiple-choice question and put your answer in the given table, while for the short question, +answer it in a few sentences. +(a) +[1 point] Ethics in artificial intelligence is +(A) Something that is not an issue. +(B) Something that somebody else will do in the future. +(C) Something that we need to apply today. +(D) Something that is entirely solved in current AI systems. +(b) +[1 point] One approach that helps developers avoid unintentionally creating bias in AI +systems is +(A) Using a wide variety of appropriately diverse data for training. +(B) Using highly specific training data from a narrow range. +(C) Not using any training data. +(D) None of the above +(c) +[1 point] What are some of the ethical concerns around artificial intelligence? +I. Racial, gender or other types of bias. +II. Loss of jobs due to AI replacing workers performing repetitive tasks. +III. Concern about the trustworthiness of decision-making supported by AI systems. +IV. Privacy, for example, as human faces are photographed and recognized in public +spaces. +(A) I and II only +(B) I, II, and IV only +(C) All of the above +(D) None of the above +(d) [1 point] What is a significantly way in which developers of AI systems can guard against +introducing bias? +(A) Using only examples from their own environment as training data. +(B) Providing effective training data and performing regular tests and audits. +(C) Using less varied AI systems and datasets. +(D) Using government approved algorithms. +Question +Answer +(a) +(b) +(c) +(d) +(e) +[3 points] State THREE ethical issues involved with the introduction of autonomous +vehicles.', 7, 10, NULL::jsonb, NULL, NULL, 'Problem 4 [7 points] Ethics of Artificial Intelligence +This question consists of five sub-questions, four of them are multiple-choice questions, and +one is a short question. Choose the BEST ANSWER among the given choices for each +multiple-choice question and put your answer in the given table, while for the short question, +answer it in a few sentences. +(a) +[1 point] Ethics in artificial intelligence is +(A) Something that is not an issue. +(B) Something that somebody else will do in the future. +(C) Something that we need to apply today. +(D) Something that is entirely solved in current AI systems. +(b) +[1 point] One approach that helps developers avoid unintentionally creating bias in AI +systems is +(A) Using a wide variety of appropriately diverse data for training. +(B) Using highly specific training data from a narrow range. +(C) Not using any training data. +(D) None of the above +(c) +[1 point] What are some of the ethical concerns around artificial intelligence? +I. Racial, gender or other types of bias. +II. Loss of jobs due to AI replacing workers performing repetitive tasks. +III. Concern about the trustworthiness of decision-making supported by AI systems. +IV. Privacy, for example, as human faces are photographed and recognized in public +spaces. +(A) I and II only +(B) I, II, and IV only +(C) All of the above +(D) None of the above +(d) [1 point] What is a significantly way in which developers of AI systems can guard against +introducing bias? +(A) Using only examples from their own environment as training data. +(B) Providing effective training data and performing regular tests and audits. +(C) Using less varied AI systems and datasets. +(D) Using government approved algorithms. +Question +Answer +(a) +C +(b) +A +(c) +C +(d) +B +Marking scheme: +ˆ 1 point for each correct answer. 4 points in total. +(e) +[3 points] State THREE ethical issues involved with the introduction of autonomous +vehicles. +Answer: +ˆ Who is to blame in an accident? +ˆ In an emergency situation who should the car prioritize? +ˆ Increase in use of cars is bad for the environment. +ˆ Cost of the cars. +Marking scheme: +ˆ 1 point for giving each correct ethical issue. 3 points in total.', ARRAY['Ethics of AI']::TEXT[], 'Ethics of AI', 'Ethics of AI', ARRAY['Ethics of AI']::TEXT[], ARRAY['concept_explanation', 'argumentation', 'comparison']::TEXT[], 'easy', '', '', ''), + ('COMP2211-2023-spring-midterm', '1', NULL, 1, 'true_false', 'true_false', 'Problem 1 [10 points] True/False Questions +Indicate whether the following statements are true or false by circling T or F. You get 1 +point for each correct answer. +(a) +T +F +Na¨ıve Bayes classifier is a probabilistic algorithm that computes the probability of an +instance belonging to each class and selects the class with the highest probability as the +output. +(b) +T +F +Na¨ıve Bayes classifier can be used for multi-class classification task. +(c) +T +F +K-Nearest Neighbors is a supervised learning and parametric algorithm that can be used +to solve both classification and regression problems. +(d) +T +F +In K-Nearest Neighbors algorithm, the value of K should always be odd to avoid ties. +(e) +T +F +In D-fold cross validation, an increase of D will result in a longer time required to cross- +validate the result. +(f) +T +F +After centroids initialization, K-Means Clustering is sensitive to the order in which the +data points are processed, meaning that changing the order of the input data points may +lead to different clustering results. +(g) +T +F +K-Median Clustering is robust to the presence of outliers and noise in the dataset, as it +uses the median of the data points as the center of each cluster. +Note: The median is the middle number in a sorted, ascending or descending list of +numbers. +(h) +T +F +A perceptron with different initialization of weights and bias may result in different +decision boundaries. +(i) +T +F +For perceptron, larger learning rates always lead to faster convergence. +(j) +T +F +Multilayer Perceptron with more layers are more expressive than Single Layer Perceptron +regardless of the activation function is linear or not.', 10, 3, NULL::jsonb, NULL, NULL, 'Problem 1 [10 points] True/False Questions +Indicate whether the following statements are true or false by circling T or F. You get 1 +point for each correct answer. +(a) +T +F +Na¨ıve Bayes classifier is a probabilistic algorithm that computes the probability of an +instance belonging to each class and selects the class with the highest probability as the +output. +(b) +T +F +Na¨ıve Bayes classifier can be used for multi-class classification task. +(c) +T +F +K-Nearest Neighbors is a supervised learning and parametric algorithm that can be used +to solve both classification and regression problems. +(d) +T +F +In K-Nearest Neighbors algorithm, the value of K should always be odd to avoid ties. +(e) +T +F +In D-fold cross validation, an increase of D will result in a longer time required to cross- +validate the result. +(f) +T +F +After centroids initialization, K-Means Clustering is sensitive to the order in which the +data points are processed, meaning that changing the order of the input data points may +lead to different clustering results. +(g) +T +F +K-Median Clustering is robust to the presence of outliers and noise in the dataset, as it +uses the median of the data points as the center of each cluster. +Note: The median is the middle number in a sorted, ascending or descending list of +numbers. +(h) +T +F +A perceptron with different initialization of weights and bias may result in different +decision boundaries. +(i) +T +F +For perceptron, larger learning rates always lead to faster convergence. +(j) +T +F +Multilayer Perceptron with more layers are more expressive than Single Layer Perceptron +regardless of the activation function is linear or not.', ARRAY['True/False']::TEXT[], 'True/False', 'True/False', ARRAY['True/False']::TEXT[], ARRAY['concept_check', 'rapid_reasoning']::TEXT[], 'medium', '', '', ''), + ('COMP2211-2023-spring-midterm', '2', NULL, 2, 'long_question', 'coding', 'Problem 2 [21 points] Python Fundamentals +(a) +[9 points] Consider the following Numpy arrays: +import numpy as np +# np.arange(stop) +# return an array of evenly spaced values within the half-open interval [0,stop), +# the default step size is 1. +A = np.arange(10) +B = np.array([[5, 10, 15], +[20, 25, 30], +[35, 40, 45]]) +Write the output for each of the following Python statements. If the output is an empty +array, write “Empty Array”. If an error occurs, write “Error”. +(i) print(A[1:5]) +(ii) print(A[1:5:2]) +(iii) print(A[5::-2]) +(iv) print(B[:2,1:]) +(v) print(B[A[:1]]) +(vi) print(B[A[1:]]) +(vii) A[A%3 == 0] = 100 +print(A) +(viii) # np.mean(a, axis) +# return the average of the array elements over the specified axis. +print(np.mean(B, axis=-1)) +(ix) # np.ndarray.transpose(*axis) +# return a view of the array with axes transposed. +print(B.transpose((1,0))) +(b) +[6 points] Write the output for the following Python code segments. If the output is an +empty array, write “Empty Array”. If an error occurs, write “Error”. +(i) import numpy as np +A = np.array([1,2]) +B = np.array([[1,2], +[2,4], +[3,6], +[4,8]]) +print(B/A) +(ii) import numpy as np +A = np.array([[0, 0, 0], +[1, 2, 3], +[4, 5, 6], +[7, 8, 9]]) +B = np.array([10, 11, 12, 13]) +print(A + B) +(iii) import numpy as np +A = np.array([0, 10, 20, 30]) +B = np.array([1, 2, 3]) +# np.newaxis +# increase the dimension of an array by adding new axis +print(A[:, np.newaxis] + B) +(c) +[6 points] The distance function between two points x and y in the Poincare ball model +can be calculated by the following formula: +arccosh + +1 + 2 +||x −y||2 +(1 −||x||2)(1 −||y||2) + += arccosh + +1 + 2 +Pn +i=1(xi −yi)2 +(1 −Pn +i=1 x2 +i )(1 −Pn +i=1 y2 +i ) + +For example, if x = (0.0, 0.0), y = (0.0, 0.1), their Poincare distance is +arccosh + +1 + 2 +(0.0 −0.0)2 + (0.0 −0.1)2 +(1 −(0.02 + 0.02))(1 −(0.02 + 0.12)) + +≈0.2006707 +Given the following NumPy arrays, X and Y, where each 1-D array represents a data +point: +X = np.array([[0.0, 0.0], [0.1, 0.1], [0.2, 0.2]]) +Y = np.array([[0, 0.1], [0.2, 0.3]]) +Compute the Poincare distance between each data point in X and each data point in Y +with a one-line Python expression, such that the result of the expression is +[[0.2006707 0.75504766] +[0.2027011 0.47971794] +[0.46441642 0.22308802]] +Note: +ˆ An expression is a combination of values, variables, operators, and calls to functions. +Your expression should work with any numbers of data points in X and Y and any +number of values in the data points. +ˆ You can assume that the number of attribute values in each data point are the same +for both X and Y. +ˆ There must be no explicit loops in your expression. +You may find the following attribute or functions useful for this question. +ˆ numpy.expand_dims(a, axis) +Insert a new axis to a that will appear at the axis position in the expanded array +shape. +ˆ numpy.square(x) +Return the element-wise square of the input x. +ˆ numpy.sum(a, axis) +Return the sum of array a’s elements over a given axis. +ˆ a.T +The transposed array of a. +ˆ numpy.matmul(a, b) +Return the matrix product of a and b. +ˆ numpy.arccosh(x) +Return the element-wise arccosh of the input x. +Write your one-line Python expression below.', 21, 4, NULL::jsonb, NULL, NULL, 'Problem 2 [21 points] Python Fundamentals +(a) +[9 points] Consider the following Numpy arrays: +import numpy as np +# np.arange(stop) +# return an array of evenly spaced values within the half-open interval [0,stop), +# the default step size is 1. +A = np.arange(10) +B = np.array([[5, 10, 15], +[20, 25, 30], +[35, 40, 45]]) +Write the output for each of the following Python statements. If the output is an empty +array, write “Empty Array”. If an error occurs, write “Error”. +(i) print(A[1:5]) +Answer: +[1 2 3 4] +(ii) print(A[1:5:2]) +Answer: +[1 3] +(iii) print(A[5::-2]) +Answer: +[5 3 1] +(iv) print(B[:2,1:]) +Answer: +[[10 15] +[25 30]] +(v) print(B[A[:1]]) +Answer: +[[5 10 15]] +(vi) print(B[A[1:]]) +Answer: +Error +(vii) A[A%3 == 0] = 100 +print(A) +Answer: +[100 1 2 100 4 5 100 7 8 100] +(viii) # np.mean(a, axis) +# return the average of the array elements over the specified axis. +print(np.mean(B, axis=-1)) +Answer: +[10 25 40] +(ix) # np.ndarray.transpose(*axis) +# return a view of the array with axes transposed. +print(B.transpose((1,0))) +Answer: +[[5 20 35] +[10 25 40] +[15 30 45]] +Marking Scheme: +ˆ 1 point for each sub-question. No partial point. The brackets must be correct in +order to get the points. 9 points in total. +(b) +[6 points] Write the output for the following Python code segments. If the output is an +empty array, write “Empty Array”. If an error occurs, write “Error”. +(i) import numpy as np +A = np.array([1,2]) +B = np.array([[1,2], +[2,4], +[3,6], +[4,8]]) +print(B/A) +Answer: +[[1 1] +[2 2] +[3 3] +[4 4]] +(ii) import numpy as np +A = np.array([[0, 0, 0], +[1, 2, 3], +[4, 5, 6], +[7, 8, 9]]) +B = np.array([10, 11, 12, 13]) +print(A + B) +Answer: Error +(iii) import numpy as np +A = np.array([0, 10, 20, 30]) +B = np.array([1, 2, 3]) +# np.newaxis +# increase the dimension of an array by adding new axis +print(A[:, np.newaxis] + B) +Answer: +[[1 2 3] +[11 12 13] +[21 22 23] +[31 32 33]] +Marking Scheme: +ˆ 2 points for each sub-question. No partial point. The bracket must be correct in +order to get the points. 6 points in total. +(c) +[6 points] The distance function between two points x and y in the Poincare ball model +can be calculated by the following formula: +arccosh + +1 + 2 +||x −y||2 +(1 −||x||2)(1 −||y||2) + += arccosh + +1 + 2 +Pn +i=1(xi −yi)2 +(1 −Pn +i=1 x2 +i )(1 −Pn +i=1 y2 +i ) + +For example, if x = (0.0, 0.0), y = (0.0, 0.1), their Poincare distance is +arccosh + +1 + 2 +(0.0 −0.0)2 + (0.0 −0.1)2 +(1 −(0.02 + 0.02))(1 −(0.02 + 0.12)) + +≈0.2006707 +Given the following NumPy arrays, X and Y, where each 1-D array represents a data +point: +X = np.array([[0.0, 0.0], [0.1, 0.1], [0.2, 0.2]]) +Y = np.array([[0, 0.1], [0.2, 0.3]]) +Compute the Poincare distance between each data point in X and each data point in Y +with a one-line Python expression, such that the result of the expression is +[[0.2006707 0.75504766] +[0.2027011 0.47971794] +[0.46441642 0.22308802]] +Note: +ˆ An expression is a combination of values, variables, operators, and calls to functions. +Your expression should work with any numbers of data points in X and Y and any +number of values in the data points. +ˆ You can assume that the number of attribute values in each data point are the same +for both X and Y. +ˆ There must be no explicit loops in your expression. +You may find the following attribute or functions useful for this question. +ˆ numpy.expand_dims(a, axis) +Insert a new axis to a that will appear at the axis position in the expanded array +shape. +ˆ numpy.square(x) +Return the element-wise square of the input x. +ˆ numpy.sum(a, axis) +Return the sum of array a’s elements over a given axis. +ˆ a.T +The transposed array of a. +ˆ numpy.matmul(a, b) +Return the matrix product of a and b. +ˆ numpy.arccosh(x) +Return the element-wise arccosh of the input x. +Write your one-line Python expression below. +Answer: +print(np.arccosh(1 + 2 * np.sum((np.expand_dims(X, axis=1) - Y) ** 2, axis=2) / +np.matmul(np.expand_dims(1 - np.sum(X ** 2, axis=1), axis=1), +np.expand_dims(1 - np.sum(Y ** 2, axis=1), axis=1).T))) +Marking Scheme: +ˆ 1.5 points: correct usage of np.expand dims(), 0.5 points each. The axis must be +correct in order to get the points. Can be replaced by np.newaxis or other equivalent +functions. +ˆ 1.5 points: correct usage of np.sum(), 0.5 points each. The axis must be correct in +order to get the points. +ˆ 1.5 points: Ccorrect usage of np.square(), 0.5 points each. Can be replaced by **2. +ˆ 0.5 points: correct usage of np.matmul(). The second argument should have the +transpose (np.transpose() or T.). But if the second no.expand dims() in the denom- +inator is expanded in axis = 0, the transpose should not occur. Can be replaced by +np.dot() or @. +ˆ 0.5 points: correct usage of np.arccosh(). +ˆ 0.5 points: correct basic formula as arccosh(1 + 2*(nominator/denominator)). Gen- +erally the students can get this 0.5 points if they write their answers.', ARRAY['Python Fundamentals']::TEXT[], 'Python Fundamentals', 'Python Fundamentals', ARRAY['Python Fundamentals']::TEXT[], ARRAY['implementation', 'code_tracing', 'debugging']::TEXT[], 'hard', '', '', ''), + ('COMP2211-2023-spring-midterm', '3', NULL, 3, 'long_question', 'long_answer', 'Problem 3 [8 points] Na¨ıve Bayes Classifier +Given the following training dataset +Humidity +Wind +Temperature +Play Tennis +Day 1 +High +Weak +Mild +Yes +Day 2 +? +Weak +Cool +Yes +Day 3 +Normal +Strong +Cool +No +Day 4 +Normal +Strong +? +Yes +Day 5 +High +Weak +Hot +No +Suppose we want to classify whether we will play tennis on Day 6 with the following attributes: +Humidity=High, Wind=Weak, Temperature=? +using Na¨ıve Bayes Classifier with the given training dataset. +Unfortunately, some of the training data is missing, which is denoted as “?”. Can Na¨ıve +Bayes Classifier handle this dataset with missing data? i.e., Can you still yield a classifica- +tion output of whether you will play tennis on Day 6? +If not, explain why. Otherwise, show how you will approach this problem. +In your answer, you should clearly mention why your explanation is valid with reference to +the property of the Na¨ıve Bayes algorithm.', 8, 9, NULL::jsonb, NULL, NULL, 'Problem 3 [8 points] Na¨ıve Bayes Classifier +Given the following training dataset +Humidity +Wind +Temperature +Play Tennis +Day 1 +High +Weak +Mild +Yes +Day 2 +? +Weak +Cool +Yes +Day 3 +Normal +Strong +Cool +No +Day 4 +Normal +Strong +? +Yes +Day 5 +High +Weak +Hot +No +Suppose we want to classify whether we will play tennis on Day 6 with the following attributes: +Humidity=High, Wind=Weak, Temperature=? +using Na¨ıve Bayes Classifier with the given training dataset. +Unfortunately, some of the training data is missing, which is denoted as “?”. Can Na¨ıve +Bayes Classifier handle this dataset with missing data? i.e., Can you still yield a classifica- +tion output of whether you will play tennis on Day 6? +If not, explain why. Otherwise, show how you will approach this problem. +In your answer, you should clearly mention why your explanation is valid with reference to +the property of the Na¨ıve Bayes algorithm. +Answer: +Na¨ıve Bayes can handle a dataset with missing value. +Attributes are handled separately +by the algorithm at both model construction time and prediction time. Therefore, the miss- +ing attributes can simply be ignored while preparing the model, and also ignored when a +probability is calculated for a class value. +Marking Scheme: +ˆ Case 1: Student’s answer is a clear “Yes”, or suggests that the Na¨ıve Bayes can be used +in this scenario. +– 2 points for indicating/suggesting that Na¨ıve Bayes can be used in this scenario. +– 3 points for mentioning the property of the Na¨ıve Bayes, where attributes are handled +separately and/or independently. +– 3 points for mentioning the data entry with the missing value in the table can simply +be ignored. +However, answers that suggest ignoring the data for the entire day +(row), or the data for the entire attribute (column) are not acceptable. +Answer +should also include that the attribute “Temperature” for Day 6 can be ignored for +when calculating the classification result. +ˆ Case 2: Student’s answer is a clear “No”. +– 0 point, and partial credit is NOT given for the subsequent explanation. Because, +whatever explanation is for the wrong claim.', ARRAY['Probabilistic Models']::TEXT[], 'Probabilistic Models', 'Probabilistic Models', ARRAY['Probabilistic Models']::TEXT[], ARRAY['manual_computation', 'probability_reasoning', 'classification_decision']::TEXT[], 'easy', '', '', ''), + ('COMP2211-2023-spring-midterm', '4', NULL, 4, 'long_question', 'long_answer', 'Problem 4 [14 points] K-Nearest Neighbors +Suppose you are building a K-Nearest Neighbors classifier that predicts user preference on +movie genre based on their rating history (a 5-point scale). +User +Movie Ratings +Preferred Genre +Movie1 +Movie2 +Movie3 +Alice +Romance +Bob +Romance +Charlie +Action +Table 1: Training Dataset +User +Movie Ratings +Preferred Genre +Movie1 +Movie2 +Movie3 +David +? +Table 2: Test Dataset +(a) +[8 points] Based on the movie ratings in Table 1 and Table 2, calculate the Cosine and +Euclidean distance between ratings of each training data and test data (round to the +third decimal place). Fill in the distance columns in the following table, and determine +the class of David for each distance when K=1. You can find the following approxima- +tions and equations helpful for this question. +Approximated values: +√ +5 ≈2.236 +√ +12 ≈3.464 +√ +14 ≈3.742 +√ +√ +22 = +√ +242 ≈15.556 +√ +√ +66 = +√ +1452 ≈38.105 +Cosine distance formula: +cosθ = +Pn +i=1 Xtrain +i +× Xtest +i +qPn +i=1(Xtrain +i +)2 + pPn +i=1(Xtest +i +)2 + +Cosine Distance = 1 −cosθ +where Xtrain +i +and Xtest +i +are the feature value of training data and test data, respectively, +and n is the number of features. +Euclidean distance formula: +Euclidean Distance = +v +u +u +t +n +X +i=1 +(Xtrain +i +−Xtest +i +)2 +where Xtrain +i +and Xtest +i +are the feature value of training data and test data, respectively, +and n is the number of features. +User +Movie Ratings +Movie1 +Movie2 +Movie3 +Preferred +Genre +Cosine +Distance +Euclidean +Distance +Alice +Romance +Bob +Romance +Charlie +Action +David +? +Class for cosine distance: +Class for Euclidean distance: +(b) +[6 points] Let’s say that Alice tends to rate movies highly, while David tends to rate +them relatively poorly. Given this scenario, which distance metric is more appropriate? +Also, describe why it makes the classifier better.', 14, 10, NULL::jsonb, NULL, NULL, 'Problem 4 [14 points] K-Nearest Neighbors +Suppose you are building a K-Nearest Neighbors classifier that predicts user preference on +movie genre based on their rating history (a 5-point scale). +User +Movie Ratings +Preferred Genre +Movie1 +Movie2 +Movie3 +Alice +Romance +Bob +Romance +Charlie +Action +Table 1: Training Dataset +User +Movie Ratings +Preferred Genre +Movie1 +Movie2 +Movie3 +David +? +Table 2: Test Dataset +(a) +[8 points] Based on the movie ratings in Table 1 and Table 2, calculate the Cosine and +Euclidean distance between ratings of each training data and test data (round to the +third decimal place). Fill in the distance columns in the following table, and determine +the class of David for each distance when K=1. You can find the following approxima- +tions and equations helpful for this question. +Approximated values: +√ +5 ≈2.236 +√ +12 ≈3.464 +√ +14 ≈3.742 +√ +√ +22 = +√ +242 ≈15.556 +√ +√ +66 = +√ +1452 ≈38.105 +Cosine distance formula: +cosθ = +Pn +i=1 Xtrain +i +× Xtest +i +qPn +i=1(Xtrain +i +)2 + pPn +i=1(Xtest +i +)2 + +Cosine Distance = 1 −cosθ +where Xtrain +i +and Xtest +i +are the feature value of training data and test data, respectively, +and n is the number of features. +Euclidean distance formula: +Euclidean Distance = +v +u +u +t +n +X +i=1 +(Xtrain +i +−Xtest +i +)2 +where Xtrain +i +and Xtest +i +are the feature value of training data and test data, respectively, +and n is the number of features. +User +Movie Ratings +Movie1 +Movie2 +Movie3 +Preferred +Genre +Cosine +Distance +Euclidean +Distance +Alice +Romance +Bob +Romance +Charlie +Action +David +? +Class for cosine distance: +Class for Euclidean distance: +User +Movie Ratings +Movie1 +Movie2 +Movie3 +Preferred +Genre +Cosine +Distance +Euclidean +Distance +Alice +Romance +0.003 +3.464 +Bob +Romance +0.029 +3.741 or 3.742 +Charlie +Action +0.100 +2.236 +David +? +Class for cosine distance: Romance +Class for Euclidean distance: Action +Marking Scheme: +ˆ 1 point for giving each correct value. 6 points in total. +– 0.5 point for each correct value that is not rounded to the 3rd decimal place. +– No point for others (e.g., square root values or equations). +ˆ 1 point for giving each correct class. 2 points in total. +(b) +[6 points] Let’s say that Alice tends to rate movies highly, while David tends to rate +them relatively poorly. Given this scenario, which distance metric is more appropriate? +Also, describe why it makes the classifier better. +Answer: +In this scenario, the Cosine distance metric performs better, indicating that the ground +truth class is “Romance”. The reason for this is that the Cosine distance metric con- +siders the direction of the vectors being compared, while the Euclidean distance metric +takes both direction and magnitude into account. Since the Cosine distance metric is +less sensitive to variations in magnitude, it is better suited for comparing vectors with +different scales. Given that this data contains bias in terms of scale, the Cosine distance +metric is a better choice in this case. +Marking Scheme: +ˆ 1 point for stating Cosine distance metric performs better, indicating that the ground +truth class is “Romance”. +ˆ 2 points for stating Cosine distance metric considers the direction, while Euclidean +distance metric takes both direction and magnitude into account. +– Note that direction or angle should be mentioned in the state of Cosine dis- +tance metric, while magnitude should be mentioned in the state of Euclidean +distance metric. +ˆ 3 points for stating Cosine distance metric is less sensitive to variations in mag- +nitude, and the data contain bias in terms of scale, Cosine distance is a better +choice. +– 1 point for stating that Cosine distance metric is less sensitive to variations. +– 1 point for stating that Cosine distance metric is less sensitive to the biased data +which is described in the problem.', ARRAY['KNN and Clustering']::TEXT[], 'KNN and Clustering', 'KNN and Clustering', ARRAY['KNN and Clustering']::TEXT[], ARRAY['manual_computation', 'distance_calculation', 'algorithm_tracing']::TEXT[], 'medium', '', '', ''), + ('COMP2211-2023-spring-midterm', '5', NULL, 5, 'long_question', 'long_answer', 'Problem 5 [19 points] K-Means Clustering +Consider a 2-dimensional dataset with the following 5 data points: +A(1, 4), B(2, 3), C(4, 6), D(5, 7), E(8, 3) +Perform K-Means clustering on this dataset with K = 2. Use the following initial centroids +for your calculations: +Centroid 1: A(1, 4), Centroid 2: D(5, 7) +If a tie occurs, assign the points to Centroid 1. All calculations round to two decimal places. +(a) [7 points] Calculate the Euclidean distances between each data point and both centroids. +Fill in the distances in the table. Then, assign each point to the nearest centroid. +Distance +A +B +C +D +E +Centroid 1 +Centroid 2 +Data points assigned to Centroid 1: +Data points assigned to Centroid 2: +(b) +[7 points] Recalculate the centroids using the mean of the points assigned to each cen- +troid. Fill in the values of new centroids after C1 and C2 in the table. Then repeat the +process of assigning points and recalculating centroids until convergence. You may not +need all the provided table templates. Leave them blank if the algorithm has already +converged. Report the final cluster assignments and centroids. +Distance +A +B +C +D +E +C1( +, +) +C2( +, +) +Data points assigned to Centroid 1: +Data points assigned to Centroid 2: +Distance +A +B +C +D +E +C1( +, +) +C2( +, +) +Data points assigned to Centroid 1: +Data points assigned to Centroid 2: +Distance +A +B +C +D +E +C1( +, +) +C2( +, +) +Data points assigned to Centroid 1: +Data points assigned to Centroid 2: +(c) [5 points] The choice of the number of clusters K in K-Means clustering can significantly +impact the clustering results. Selecting too few or too many clusters can lead to overgen- +eralization or overfitting. Why this limitation is critical for K-Means clustering? Given +a dataset with an unknown number of clusters, please explain one way to determine a +suitable K.', 19, 12, NULL::jsonb, NULL, NULL, 'Problem 5 [19 points] K-Means Clustering +Consider a 2-dimensional dataset with the following 5 data points: +A(1, 4), B(2, 3), C(4, 6), D(5, 7), E(8, 3) +Perform K-Means clustering on this dataset with K = 2. Use the following initial centroids +for your calculations: +Centroid 1: A(1, 4), Centroid 2: D(5, 7) +If a tie occurs, assign the points to Centroid 1. All calculations round to two decimal places. +(a) [7 points] Calculate the Euclidean distances between each data point and both centroids. +Fill in the distances in the table. Then, assign each point to the nearest centroid. +Distance +A +B +C +D +E +Centroid 1 +Centroid 2 +Data points assigned to Centroid 1: +Data points assigned to Centroid 2: +Answers: +Distance +A +B +C +D +E +Centroid 1 +1.41 +3.61 +5.00 +7.07 +Centroid 2 +5.00 +5.00 +1.41 +5.00 +Data points assigned to Centroid 1: A, B +Data points assigned to Centroid 2: C, D, E +Marking Scheme: +ˆ 0.5 point for each correct value. 5 points in total. +ˆ 1 point for giving the data points assigned to each centroid. 2 points in total. +ˆ -0.5 points for not rounding to two decimal places. +(b) +[7 points] Recalculate the centroids using the mean of the points assigned to each cen- +troid. Fill in the values of new centroids after C1 and C2 in the table. Then repeat the +process of assigning points and recalculating centroids until convergence. You may not +need all the provided table templates. Leave them blank if the algorithm has already +converged. Report the final cluster assignments and centroids. +Distance +A +B +C +D +E +C1( +, +) +C2( +, +) +Data points assigned to Centroid 1: +Data points assigned to Centroid 2: +Distance +A +B +C +D +E +C1( +, +) +C2( +, +) +Data points assigned to Centroid 1: +Data points assigned to Centroid 2: +Distance +A +B +C +D +E +C1( +, +) +C2( +, +) +Data points assigned to Centroid 1: +Data points assigned to Centroid 2: +Answer: +Distance +A +B +C +D +E +C1(1.5, 3.5) +0.71 +0.71 +3.54 +4.95 +6.52 +C2(5.67, 5.33) +4.86 +4.35 +1.80 +1.80 +3.30 +Data points assigned to Centroid 1: A, B +Data points assigned to Centroid 2: C, D, E +Marking Scheme: +ˆ 0.5 points for each correct value. 5 points in total. +ˆ 1 point for giving the data points assigned to each centroid. 2 points in total. +ˆ -0.5 points for not rounding to two decimal places. +ˆ -1 point for filling in/copying more than 1 table template (incorrect convergence +statement). +(c) [5 points] The choice of the number of clusters K in K-Means clustering can significantly +impact the clustering results. Selecting too few or too many clusters can lead to overgen- +eralization or overfitting. Why this limitation is critical for K-Means clustering? Given +a dataset with an unknown number of clusters, please explain one way to determine a +suitable K. +Answer: +Clustering is an unsupervised learning method. In the scenario where you try to adopt K- +Means clustering or any other unsupervised models, you don’t know anything about the +shape, the number of clusters, or the distribution of the dataset. Such value k is exactly +what you are looking for and will never be given beforehand. One common method to +determine the optimal number of clusters is the elbow method, which identifies the point +where adding more clusters does not improve the clustering performance significantly. +However, this method can be subjective and may not always yield the optimal number +of clusters. Other methods, such as silhouette analysis or gap statistics, can also be used +to determine the optimal number of clusters. +Marking Scheme: +ˆ 2 points for stating that K-Means is an unsupervised method and the best value K +will never be given beforehand. +ˆ 3 points for giving and explaining a way to determine a suitable K. +ˆ -1 point for stating why the limitation is critical for K-Means clustering but the +answer is minor. +ˆ -1 point for paraphrasing the prompt. +ˆ -1 point for using only SSE to evaluate the model and use the K at minimum SSE.', ARRAY['KNN and Clustering']::TEXT[], 'KNN and Clustering', 'KNN and Clustering', ARRAY['KNN and Clustering']::TEXT[], ARRAY['manual_computation', 'distance_calculation', 'algorithm_tracing']::TEXT[], 'hard', '', '', ''), + ('COMP2211-2023-spring-midterm', '6', NULL, 6, 'long_question', 'long_answer', 'Problem 6 [12 points] Perceptron +Given the following training dataset and test dataset: +Training +Test +x1 +-2 +-1 +x2 +-2 +-1 +T +-1 +-1 +-1 +-1 +(a) +[2 points] Suppose we use the training dataset to train a perceptron. At some point +during the training, the weights and bias of the perceptron have not changed after four +consecutive updates, but the current epoch is not yet finished. +Should we stop the +training? Explain why. +(b) +[2 points] +After the training has converged, what is the range of possible accuracy on the test +dataset? +A. 50% +B. 50% ∼75% +C. 50% ∼100% +D. 25% ∼75% +E. 0% ∼100% +(c) +[2 points] Consider the following implementation of a perceptron: +import numpy as np +def fit(X, T, W0, b0, eta, max_epochs): +W = np.ones(X.shape[0]) * W0 +b = b0 +for epoch in range(max_epochs): +for i in range(T.shape[0]): +O = predict(X, W, b) +E = T[i] - O +W = W + E * X +b = b + E +return W, b +def predict(X, W, b): +Z = np.dot(X, W) + b +O = (Z <= 0) * 2 - 1 +return O +The predict function takes three inputs: +ˆ A 1-D or 2-D NumPy array, X, storing test example(s). The shape of X is (d,) or (n, +d) where n is the number of test examples and d is the number of features; +ˆ A 1-D NumPy array, W, storing the weights of the perceptron. The shape of W is (d,) +where d is the number features; +ˆ A bias value, b, for the perceptron. +Based on how the predict function is implemented, what is the activation function f(z) +of this perceptron? +(d) +[6 points] +The fit function takes six inputs: +ˆ A 2-D NumPy array, X, storing training examples. The shape of X is (m, d) where +m is the number of training examples and d is the number of features; +ˆ A 1-D NumPy array, T, storing training targets. The shape of T is (m,) where m is +the number training examples; +ˆ An initial value, W0, for the weights of the perceptron; +ˆ An initial value, b0, for the bias of the perceptron; +ˆ The learning rate, eta; +ˆ The maximum number of training epochs, max epochs. +Given these inputs, however, the fit function is not implemented correctly. Identify all +the errors by giving the line numbers that cause the errors, and propose ways to fix them. +You may find the following attribute or functions useful for this question. +ˆ numpy.ones(shape) +Return a new array of given shape, shape, filled with ones. +ˆ range(stop) +Return a sequence of numbers starting from 0, incrementing by 1, and ending at the +value stop. +ˆ ndarray.shape +Tuple of array dimensions. +ˆ numpy.dot(a, b) +Return the dot product of a and b if a and b are both 1-D arrays. If a is an N-D +array and b is a 1-D array, it is a sum product over the last axis of a and b is +returned.', 12, 15, NULL::jsonb, NULL, NULL, 'Problem 6 [12 points] Perceptron +Given the following training dataset and test dataset: +Training +Test +x1 +-2 +-1 +x2 +-2 +-1 +T +-1 +-1 +-1 +-1 +(a) +[2 points] Suppose we use the training dataset to train a perceptron. At some point +during the training, the weights and bias of the perceptron have not changed after four +consecutive updates, but the current epoch is not yet finished. Should we stop the train- +ing? Explain why. +Answer: +Yes, the training has converged so we should stop it from wasting time and resources. +Marking Scheme: +ˆ 1 point for stating we should stop the training. +ˆ 1 point for giving correct explanations. +(b) +[2 points] +After the training has converged, what is the range of possible accuracy on the test +dataset? +A. 50% +B. 50% ∼75% +C. 50% ∼100% +D. 25% ∼75% +E. 0% ∼100% +Answer: +D +Marking Scheme: +ˆ 2 points for the correct answer. +(c) +[2 points] Consider the following implementation of a perceptron: +import numpy as np +def fit(X, T, W0, b0, eta, max_epochs): +W = np.ones(X.shape[0]) * W0 +b = b0 +for epoch in range(max_epochs): +for i in range(T.shape[0]): +O = predict(X, W, b) +E = T[i] - O +W = W + E * X +b = b + E +return W, b +def predict(X, W, b): +Z = np.dot(X, W) + b +O = (Z <= 0) * 2 - 1 +return O +The predict function takes three inputs: +ˆ A 1-D or 2-D NumPy array, X, storing test example(s). The shape of X is (d,) or (n, +d) where n is the number of test examples and d is the number of features; +ˆ A 1-D NumPy array, W, storing the weights of the perceptron. The shape of W is (d,) +where d is the number features; +ˆ A bias value, b, for the perceptron. +Based on how the predict function is implemented, what is the activation function f(z) +of this perceptron? +Answer: +f(z) = + + + +if z ≤0 +−1 +otherwise +Marking Scheme: +ˆ 2 points for giving the correct activation function. +(d) +[6 points] +The fit function takes six inputs: +ˆ A 2-D NumPy array, X, storing training examples. The shape of X is (m, d) where +m is the number of training examples and d is the number of features; +ˆ A 1-D NumPy array, T, storing training targets. The shape of T is (m,) where m is +the number training examples; +ˆ An initial value, W0, for the weights of the perceptron; +ˆ An initial value, b0, for the bias of the perceptron; +ˆ The learning rate, eta; +ˆ The maximum number of training epochs, max epochs. +Given these inputs, however, the fit function is not implemented correctly. Identify all +the errors by giving the line numbers that cause the errors, and propose ways to fix them. +You may find the following attribute or functions useful for this question. +ˆ numpy.ones(shape) +Return a new array of given shape, shape, filled with ones. +ˆ range(stop) +Return a sequence of numbers starting from 0, incrementing by 1, and ending at the +value stop. +ˆ ndarray.shape +Tuple of array dimensions. +ˆ numpy.dot(a, b) +Return the dot product of a and b if a and b are both 1-D arrays. If a is an N-D +array and b is a 1-D array, it is a sum product over the last axis of a and b is +returned. +Answer: +ˆ On line 4, change X.shape[0] into X.shape[1] +ˆ On line 8 and 10, change X into X[i] +ˆ On line 9, multiply the right-hand side with eta +ˆ On line 9, change T[i]-O into O-T[i], or equivalently, on line 10 and 11, change + +into -. +The resulting fit function is as follows: +def fit(X, T, W0, b0, eta, max_epochs): +W = np.ones(X.shape[1]) * W0 +b = b0 +for epoch in range(max_epochs): +for i in range(T.shape[0]): +O = predict(X[i], W, b) +E = eta * (O - T[i]) +W = W + E * X[i] +b = b + E +return W, b +Other equivalent solutions also exist. +Marking Scheme: +ˆ 2 points for each error; 1 point for identifying the error (pointing out only the line +number does not count), and 1 point for correctly fixing the error. Partial points are +given to incomplete fixes of an error. +ˆ For fixes that correctly fixed an error but accidentally introduced other errors, points +are partially deducted. No extra points are deducted for purely irrelevant/erroneous +modifications of the code. +ˆ The final mark is capped at 6 points.', ARRAY['Perceptron and MLP']::TEXT[], 'Perceptron and MLP', 'Perceptron and MLP', ARRAY['Perceptron and MLP']::TEXT[], ARRAY['forward_pass', 'backpropagation', 'weight_update']::TEXT[], 'medium', '', '', ''), + ('COMP2211-2023-spring-midterm', '7', NULL, 7, 'long_question', 'long_answer', 'Problem 7 [16 points] Multilayer Perceptron +(a) +[4 points] Consider the following dataset +x1 +x2 +F(x1,x2) +Given the following single layer perceptron, where activation function f is defined as +f(z) = +( +1, for z ≥0 +0, otherwise +Can we classify all the data points correctly using this perceptron? If yes, give a set of +possible parameters. If no, briefly explain the reason. +(b) +[2 points] Consider the following dataset +x1 +x2 +F(x1,x2) +Using the same perceptron architecture in part (a), can we classify all the data points +correctly using this perceptron? If yes, give a set of possible parameters. If no, briefly +explain the reason. +(c) +[10 points] Consider the same dataset in part (b), given the following multilayer percep- +tron, where activation function f is defined as identical with that in part (a), +can we classify all the data points correctly using this multilayer perceptron? If yes, give +a set of possible parameters. If no, briefly explain the reason. +Hint: You don’t need to use gradient descent to calculate the results. +You may try +to find a set of parameters to make the data points linear separable after the first layer +mapping. +-------------------- END OF PAPER --------------------', 16, 18, NULL::jsonb, NULL, NULL, 'Problem 7 [16 points] Multilayer Perceptron +(a) +[4 points] Consider the following dataset +x1 +x2 +F(x1,x2) +Given the following single layer perceptron, where activation function f is defined as +f(z) = +( +1, for z ≥0 +0, otherwise +Can we classify all the data points correctly using this perceptron? If yes, give a set of +possible parameters. If no, briefly explain the reason. +Answer: +Yes, the four data points are linearly separable. +One possible set of parameters is +w1 = 1, w2 = 1, θ = −1.5. +Marking Scheme: +ˆ 1 point for ‘yes’ answer. +ˆ 3 points for correct parameter (no partial points). +(b) +[2 points] Consider the following dataset +Using the same perceptron architecture in part (a), can we classify all the data points +correctly using this perceptron? If yes, give a set of possible parameters. If no, briefly +x1 +x2 +F(x1,x2) +explain the reason. +Answer: +No, the 4 data points are not linearly separable, so they can’t be fit with 100% accuracy +using single layer perceptron. +Marking Scheme: +ˆ 1 point for ‘yes’ answer. +ˆ 3 points for correct parameters (no partial points). +(c) +[10 points] Consider the same dataset in part (b), given the following multilayer percep- +tron, where activation function f is defined as identical with that in part (a), +can we classify all the data points correctly using this multilayer perceptron? If yes, give +a set of possible parameters. If no, briefly explain the reason. +Hint: You don’t need to use gradient descent to calculate the results. +You may try +to find a set of parameters to make the data points linear separable after the first layer +mapping. +Answer: +Yes, one possible set of parameters can be obtained as following procedure: +we first use two linear functions to seperate the 4 datatpoints, e.g, w1 = −1, w2 = 1, θ1 = +−0.5, w3 = −1, w4 = 1, θ2 = 0.5, the related diagram is as followed, +(1,1) +(0,1) +(x1) +(x2) +(1,0) +(0,0) +𝑤1𝑥1 + 𝑤2𝑥2 + 𝜃1 +𝑤3𝑥1 + 𝑤4𝑥2 + 𝜃2 +After first layer mapping, the result will be +x1 +x2 +y1 = w1x1 + w2x2 + θ1 +y2 = w3x1 + w4x2 + θ2 +f(y1) +f(y2) +F(x1, x2) +-0.5 +0.5 +0.5 +1.5 +-1 +-0.5 +-0.5 +0.5 +For last three columns of the table, we can see the datapoints are linear separable after +the first layer, where we can set w5 = −1, w6 = 1, θ3 = −0.5 to fulfill the requirement. +To sum up, one possible set of parameters are +w1 = −1, w2 = 1, θ1 = −0.5, w3 = −1, w4 = 1, θ2 = 0.5, w5 = −1, w6 = 1, θ3 = −0.5 +Marking Scheme: +ˆ 1 point for ‘yes’ answer. +ˆ 6 points if first layer mapping makes all data points linear separable (3 points if +the data points are ‘almost’ linear separable, e.g., one set of parameters are correct +except that the signs are opposite). +ˆ 3 points for correct second layer parameters. +-------------------- END OF PAPER --------------------', ARRAY['Perceptron and MLP']::TEXT[], 'Perceptron and MLP', 'Perceptron and MLP', ARRAY['Perceptron and MLP']::TEXT[], ARRAY['forward_pass', 'backpropagation', 'weight_update']::TEXT[], 'hard', '', '', ''), + ('COMP2211-2024-spring-midterm', '1', NULL, 1, 'true_false', 'true_false', 'Problem 1 [5 points] True/False Questions +Indicate whether the following statements are true or false by putting T or F in the given +table. You get 0.5 point for each correct answer. +Question +(a) +(b) +(c) +(d) +(e) +(f) +(g) +(h) +(i) +(j) +Answer +(a) In the following code +import numpy as np +a = np.array([0, 1, 1, 2, 5, 5, 3]) +b = np.array([0, 1, 2, 3, 4, 5]) +c = (b == a.reshape(7, 1)) +The array c has the shape (7, 1). +(b) After executing the following block of code: +import numpy as np +a = np.array([[1, 2], [3, 4], [5, 6]]) +b = np.array([[1, 2, 3], [0, 0, 0], [1, 0, 0]]) +c = a.dot(b) +The array c is array([[22, 28], [0, 0], [1, 2]]). +(c) The Na¨ıve Bayes Classifier operates under the assumption that the presence of a partic- +ular feature in a class is independent of the presence of any other feature. +(d) In Na¨ıve Bayes, we assume that P(B|e1, e2) = P(B|e1)P(B|e2) where B is our belief +and (e1, e2) are evidence. +(e) In Na¨ıve Bayes, given P(B = b), P(e1|B = b), and P(e2|B = b) for each possible belief +b, we can compute P(B = b′|e1, e2) for any b′. +(f) K-Nearest Neighbors Classifier CANNOT handle data with categorical features since +it is difficult to find the distance between categorical features. +(g) In K-Nearest Neighbors for binary classification, odd values of k are usually preferred. +(h) A 6-fold cross validation for K-nearest neighbors algorithm means that for each value +of K, we randomly select 1/6 of the training data as the validation set to evaluate the +model which is trained by the remaining (5/6) of the training data. +(i) The result of the K-Means Clustering DOES NOT depend on the initial centroids. +(j) It is possible that after new cluster centroids are computed by the K-Means Clustering +Algorithm, a cluster centroid may be associated with an empty cluster (i.e., with zero +points in it).', 5, 3, NULL::jsonb, NULL, NULL, 'Problem 1 [5 points] True/False Questions +Indicate whether the following statements are true or false by putting T or F in the given +table. You get 0.5 point for each correct answer. +Question +(a) +(b) +(c) +(d) +(e) +(f) +(g) +(h) +(i) +(j) +Answer +(a) In the following code +import numpy as np +a = np.array([0, 1, 1, 2, 5, 5, 3]) +b = np.array([0, 1, 2, 3, 4, 5]) +c = (b == a.reshape(7, 1)) +The array c has the shape (7, 1). +(b) After executing the following block of code: +import numpy as np +a = np.array([[1, 2], [3, 4], [5, 6]]) +b = np.array([[1, 2, 3], [0, 0, 0], [1, 0, 0]]) +c = a.dot(b) +The array c is array([[22, 28], [0, 0], [1, 2]]). +(c) The Na¨ıve Bayes Classifier operates under the assumption that the presence of a partic- +ular feature in a class is independent of the presence of any other feature. +(d) In Na¨ıve Bayes, we assume that P(B|e1, e2) = P(B|e1)P(B|e2) where B is our belief +and (e1, e2) are evidence. +(e) In Na¨ıve Bayes, given P(B = b), P(e1|B = b), and P(e2|B = b) for each possible belief +b, we can compute P(B = b′|e1, e2) for any b′. +(f) K-Nearest Neighbors Classifier CANNOT handle data with categorical features since +it is difficult to find the distance between categorical features. +(g) In K-Nearest Neighbors for binary classification, odd values of k are usually preferred. +(h) A 6-fold cross validation for K-nearest neighbors algorithm means that for each value +of K, we randomly select 1/6 of the training data as the validation set to evaluate the +model which is trained by the remaining (5/6) of the training data. +(i) The result of the K-Means Clustering DOES NOT depend on the initial centroids. +(j) It is possible that after new cluster centroids are computed by the K-Means Clustering +Algorithm, a cluster centroid may be associated with an empty cluster (i.e., with zero +points in it). +Question +(a) +(b) +(c) +(d) +(e) +(f) +(g) +(h) +(i) +(j) +Answer +F +F +T +F +T +F +T +F +F +T', ARRAY['True/False']::TEXT[], 'True/False', 'True/False', ARRAY['True/False']::TEXT[], ARRAY['concept_check', 'rapid_reasoning']::TEXT[], 'easy', '', '', ''), + ('COMP2211-2024-spring-midterm', '2', NULL, 2, 'long_question', 'coding', 'Problem 2 [19 points] Advanced Python for Artificial Intelligence +(a) +[13 points] Consider the following NumPy arrays: +import numpy as np +# np.arange(start, stop) +# - return an array of evenly spaced values within the half-open interval +# +[start,stop), the default step size is 1. +# np.ones(shape): +# - return a new array of given shape, filled with ones. +A = np.arange(5,15) +B = np.ones((3,2)) +C = np.array([[[0,1,2,3], +[4,5,6,7]], +[[8,9,10,11], +[12,13,14,15]], +[[16,17,18,19], +[20,21,22,23]]]) +Suppose the following Python statements are running consecutively. Write the output +for each of the following Python statements. +If the output is an empty array, write +“Empty Array”. If an error occurs, write “Error”. +(i) print(A[2:-2:3]) +(ii) # np.ndarray.reshape(shape) +# - return an array containing the same data with a new shape. +print(B.reshape(3,-1,2)) +(iii) Please write a line of Python code to get the same result in part (a)(ii) by using +np.expand dims on B. +# np.expand_dims(a, axis) +# - insert a new axis to a that will appear at the axis position in the +# +expanded array shape. Return an array that is the view of a with the +# +number of dimensions increased. +(iv) Please write a line of Python code to create the array C by using the functions +np.ndarray.reshape and np.arange(). +(v) # np.mean(a, axis) +# - return a new array containing the average values of a over the specified axis. +print(np.mean(C,axis = 2)) +(vi) # np.transpose(a, axes) +# - return an array with axes transposed in a. +print(np.transpose(C, (1, 0, 2))) +(vii) # a@b +# - return the matrix multiplication of the two arrays a and b. +D = A.reshape(2, 5) +print(B@D) +(viii) # np.dot(a,b) +# - return the dot product of the two arrays a and b. If both a and b are +# +1-D arrays, it is the inner product of vectors and returns a scalar. +# +If both a and b are bool arrays, the output is in bool datatype. +# np.ndarray.astype(dtype) +# - return the copy of the array with a specified dtype. +E = A < 12 +F = A % 5 == 0 +print(np.dot(E, F).astype(int)) +(ix) print(np.dot(E, F.astype(int))) +(x) print( C / B ) +(xi) # np.newaxis +# - increase the dimension of an array by adding new axis. +print(C / B[..., np.newaxis]) +(xii) G = C[0, :, 2:] +print(G) +(xiii) C[0, 1, 2] = 100 +print(G) +Scheme: +ˆ 1 point for giving the correct answer for each part. 13 points in total. +(b) +[6 points] In recommendation systems, we often recommend items similar to what the +user likes. This is known as content-based filtering. In content-based filtering, an item is +represented as a feature vector (or a 1D array). Given the following code which computes +the cosine similarity between feature vectors of items in explicit loops: +def compute_cosine_similarity_loops(X): +num_items, num_features = X.shape +# --- BLOCK TO REWRITE --- +X_normalized = np.zeros((num_items, num_features)) +for i in range(num_items): +feature_norm = np.sqrt(np.sum(X[i] ** 2)) +X_normalized[i] = X[i] / feature_norm +similarities = np.zeros((num_items, num_items)) +for i in range(num_items): +for j in range(num_items): +similarities[i, j] = np.sum(X_normalized[i] * X_normalized[j]) +# --- BLOCK TO REWRITE --- +return similarities +X = np.array([[0, 2], [1, -1], [1, 1]]) +print(compute_cosine_similarity_loops(X)) +# Output: +# [[ 1. +-0.70710678 +0.70710678] +# +[-0.70710678 +1. +0. +] +# +[ 0.70710678 +0. +1. +]] +Rewrite the block of code between the comment lines “# --- BLOCK TO REWRITE --- ” +using no explicit loops in the space provided. You may find the following functions +useful for this question. +ˆ Element-wise square of an array: +np.square(A) +– A is the input array +This is equivalent to A ** 2. +ˆ Element-wise square root of an array: +np.sqrt(A) +– A is the input array +ˆ Sum of array elements over a given axis: +np.sum(A, axis) +– A is the input array +– axis is the axis across which the array is summed +ˆ Insert a new axis of size 1 to an array: +np.expand_dims(A, axis) +– A is the input array +– axis is the position where the axis is to be inserted +If axis is 0, this is equivalent to A[np.newaxis] and A[None]. If axis is 1, this is +equivalent to A[:, np.newaxis] and A[:, None]. +ˆ Transpose of an array: +np.transpose(A) +– A is the input array +This is equivalent to A.T. +ˆ Matrix multiplication: +np.matmul(A, B) +– A is the left array for matrix multiplication +– B is the right array for matrix multiplication +This is equivalent to A @ B. +More information on matrix multiplication: suppose A.shape[1] == B.shape[0], +then +C = np.matmul(A, B) +means that for each i and j, +C[i, j] == np.sum(A[i] * B[:, j]) +Write your code in the space below.', 19, 4, NULL::jsonb, NULL, NULL, 'Problem 2 [19 points] Advanced Python for Artificial Intelligence +(a) +[13 points] Consider the following NumPy arrays: +import numpy as np +# np.arange(start, stop) +# - return an array of evenly spaced values within the half-open interval +# +[start,stop), the default step size is 1. +# np.ones(shape): +# - return a new array of given shape, filled with ones. +A = np.arange(5,15) +B = np.ones((3,2)) +C = np.array([[[0,1,2,3], +[4,5,6,7]], +[[8,9,10,11], +[12,13,14,15]], +[[16,17,18,19], +[20,21,22,23]]]) +Suppose the following Python statements are running consecutively. Write the output +for each of the following Python statements. +If the output is an empty array, write +“Empty Array”. If an error occurs, write “Error”. +(i) print(A[2:-2:3]) +Answer: +[7 10] +(ii) # np.ndarray.reshape(shape) +# - return an array containing the same data with a new shape. +print(B.reshape(3,-1,2)) +Answer: +[[[1 1]] +[[1 1]] +[[1 1]]] +(iii) Please write a line of Python code to get the same result in part (a)(ii) by using +np.expand dims on B. +# np.expand_dims(a, axis) +# - insert a new axis to a that will appear at the axis position in the +# +expanded array shape. Return an array that is the view of a with the +# +number of dimensions increased. +Answer: +np.expand dims(B, 1) +(also correct if adding print()) +(iv) Please write a line of Python code to create the array C by using the functions +np.ndarray.reshape and np.arange(). +Answer: +np.arange(24).reshape(3, 2, 4) +(also correct if adding C = ) +(v) # np.mean(a, axis) +# - return a new array containing the average values of a over the specified axis. +print(np.mean(C,axis = 2)) +Answer: +[[1.5 5.5] +[9.5 13.5] +[17.5 21.5]] +(vi) # np.transpose(a, axes) +# - return an array with axes transposed in a. +print(np.transpose(C, (1, 0, 2))) +Answer: +[[[0 1 2 3] +[8 9 10 11] +[16 17 18 19]] +[[4 5 6 +7] +[12 13 14 15] +[20 21 22 23]]] +(vii) # a@b +# - return the matrix multiplication of the two arrays a and b. +D = A.reshape(2, 5) +print(B@D) +Answer: +[[15 17 19 21 23] +[15 17 19 21 23] +[15 17 19 21 23]] +(viii) # np.dot(a,b) +# - return the dot product of the two arrays a and b. If both a and b are +# +1-D arrays, it is the inner product of vectors and returns a scalar. +# +If both a and b are bool arrays, the output is in bool datatype. +# np.ndarray.astype(dtype) +# - return the copy of the array with a specified dtype. +E = A < 12 +F = A % 5 == 0 +print(np.dot(E, F).astype(int)) +Answer: +(ix) print(np.dot(E, F.astype(int))) +Answer: +(x) print( C / B ) +Answer: +Error +(xi) # np.newaxis +# - increase the dimension of an array by adding new axis. +print(C / B[..., np.newaxis]) +Answer: +[[[0,1,2,3], +[4,5,6,7], +[[8,9,10,11], +[12,13,14,15]], +[[16,17,18,19], +[20,21,22,23]]] +(xii) G = C[0, :, 2:] +print(G) +Answer: +[[2 3] +[6 7]] +(xiii) C[0, 1, 2] = 100 +print(G) +Answer: +[[2 3] +[100 7]] +Scheme: +ˆ 1 point for giving the correct answer for each part. 13 points in total. +(b) +[6 points] In recommendation systems, we often recommend items similar to what the +user likes. This is known as content-based filtering. In content-based filtering, an item is +represented as a feature vector (or a 1D array). Given the following code which computes +the cosine similarity between feature vectors of items in explicit loops: +def compute_cosine_similarity_loops(X): +num_items, num_features = X.shape +# --- BLOCK TO REWRITE --- +X_normalized = np.zeros((num_items, num_features)) +for i in range(num_items): +feature_norm = np.sqrt(np.sum(X[i] ** 2)) +X_normalized[i] = X[i] / feature_norm +similarities = np.zeros((num_items, num_items)) +for i in range(num_items): +for j in range(num_items): +similarities[i, j] = np.sum(X_normalized[i] * X_normalized[j]) +# --- BLOCK TO REWRITE --- +return similarities +X = np.array([[0, 2], [1, -1], [1, 1]]) +print(compute_cosine_similarity_loops(X)) +# Output: +# [[ 1. +-0.70710678 +0.70710678] +# +[-0.70710678 +1. +0. +] +# +[ 0.70710678 +0. +1. +]] +Rewrite the block of code between the comment lines “# --- BLOCK TO REWRITE --- ” +using no explicit loops in the space provided. You may find the following functions +useful for this question. +ˆ Element-wise square of an array: +np.square(A) +– A is the input array +This is equivalent to A ** 2. +ˆ Element-wise square root of an array: +np.sqrt(A) +– A is the input array +ˆ Sum of array elements over a given axis: +np.sum(A, axis) +– A is the input array +– axis is the axis across which the array is summed +ˆ Insert a new axis of size 1 to an array: +np.expand_dims(A, axis) +– A is the input array +– axis is the position where the axis is to be inserted +If axis is 0, this is equivalent to A[np.newaxis] and A[None]. If axis is 1, this is +equivalent to A[:, np.newaxis] and A[:, None]. +ˆ Transpose of an array: +np.transpose(A) +– A is the input array +This is equivalent to A.T. +ˆ Matrix multiplication: +np.matmul(A, B) +– A is the left array for matrix multiplication +– B is the right array for matrix multiplication +This is equivalent to A @ B. +More information on matrix multiplication: suppose A.shape[1] == B.shape[0], +then +C = np.matmul(A, B) +means that for each i and j, +C[i, j] == np.sum(A[i] * B[:, j]) +Write your code in the space below. +Answer: +X_norm = np.sqrt(np.sum(X ** 2, 1)) +# (n,) +# 2 points +X_normalized = X / X_norm[:, None] +# (n, d) # 2 points +similarities = X_normalized @ X_normalized.T +# (n, n) # 2 points +The solution is not unique.', ARRAY['Python Fundamentals']::TEXT[], 'Python Fundamentals', 'Python Fundamentals', ARRAY['Python Fundamentals']::TEXT[], ARRAY['implementation', 'code_tracing', 'debugging']::TEXT[], 'hard', '', '', ''), + ('COMP2211-2024-spring-midterm', '3', NULL, 3, 'long_question', 'coding', 'Problem 3 [16 points] Model Evaluation and Advanced Python Programming +In this problem, you need to implement the evaluation metrics for multi-class classifiers. +Specifically, you need to implement the confusion matrix, accuracy, precision, recall, and +macro F1 score. We provide the related definitions and formulas as follows. +(a) +[5.5 points] Suppose there is a test dataset consisting of 10 data points, their actual +classes are [2, 1, 1, 2, 0, 1, 0, 0, 1, 1], and their predicted classes by a classifier model +are [2, 1, 2, 1, 0, 2, 1, 0, 0, 0]. What is the confusion matrix for the prediction results? +What are TP, TN, FP, FN for each class? +Confusion matrix: a table that summarized actual labels and the predictions of classifi- +cation. For multi-class classification, the confusion matrix has the shape (num classes, +num classes) that records the number of occurrences between actual labels and the pre- +dictions. The classes are listed in the same order in the rows as in the columns, therefore +the correctly classified elements are located on the main diagonal. +For each class i, TPi, TNi, FPi, FNi represent the instance numbers of true positive, +true negative, false positive, and false negative, respectively. +ˆ True positive: A test result where the classifier correctly predicts the positive class +as positive. +ˆ True negative: A test result where the classifier correctly predicts the negative class +as negative. +ˆ False positive: A test result where the classifier incorrectly predicts the negative class +as positive. +ˆ False negative: A test result where the classifier incorrectly predicts the positive class +as negative. +Please fill in the confusion matrix (the rows represent actual class and the columns +represent predicted class) and the TP, TN, FP, FN table. +Predicted +Actual +Class +Class +TP +TN +FP +FN +(b) +[1 point] What is the accuracy score of the classifier model on the above test data? +Accuracy = +PN +i=1 TPi +num testdata, where N is the number of classes. +(c) +[2.5 points] What are the precisions, recalls, and F1 scores for each class of the classifier +model on the above test data? Please fill in the table using fractions or keep 3 decimals. +For each class i, Precisioni = +TPi +TPi+FPi +For each class i, Recalli = +TPi +TPi+FNi +For each class i, F1i = 2·Precisioni·Recalli +Precisioni+Recalli +Class +Precision +Recall +F1 score +(d) +[1 point] What is the macro F1 score of the classifier model on the above test data? +Please keep 3 decimals in your answer. +The macro F1 score is the unweighted mean of the F1 scores of all classes: +Macro-F1 = +PN +i=1 F1i +N +, where N is the number of classes. +(e) [6 points] Given two NumPy 1D arrays with the same shape (num_testdata,): test_actual +and test_predict, representing the actual class labels and predicted class labels for the +test data, please implement the following functions by filling in the blanks. For each +TODO, please use a one-line Python expression. +import numpy as np +def generate_confusion_matrix(test_actual, test_predict): +# TODO 1: Get num_classes, the number of classes in the test data. +# Note that the classes in the test_actual and test_predict are represented +# in integer indices from [0, 1, ..., num_classes - 1]. +num_classes = _________________________________________ +confusion_matrix = np.zeros((num_classes, num_classes)) +# TODO 2: Get the values of confusion_matrix, where the rows represent +# actual class and the columns represent predicted class. +for i in range(0, num_classes): +for j in range(0, num_classes): +confusion_matrix[i, j] = _____________________________________ +return confusion_matrix +def calculate_evaluation_metrics(test_actual, test_predict): +confusion_matrix = generate_confusion_matrix(test_actual, test_predict) +# TODO 3: Get the accuracy score, which is a scalar value. +accuracy = ______________________________________________________ +# TODO 4: Get the precisions for all classes, which is a 1D array +# with shape (num_classes, ). +precision = _____________________________________________________ +# TODO 5: Get the recalls for all classes, which is a 1D array +# with shape (num_classes, ). +recall = ________________________________________________________ +# TODO 6: Get the macro F1 score, which is a scalar value. +macro_f1 = ______________________________________________________ +return accuracy, precision, recall, macro_f1 +Note: +ˆ An expression is a combination of values, variables, operators, and calls to functions. +ˆ There must be no explicit loops in your expression. +ˆ Your implemented functions should work with any number of test data points and +any number of classes. +ˆ You cannot use any variable that is not defined inside the function or any global +variable. +You may find the following attribute or functions useful for this problem. +ˆ np.max(a, axis = None) +- return the maximum of the array a along the given axis. If axis is None, the result +is a scalar value. +ˆ np.ndarray.sum(axis = None) +- return the sum of the array over the given axis. If axis is None, the result is a +scalar value. +ˆ np.ndarray.diagonal() +- if the array is 2D, then a 1D array containing the diagonal elements is returned. +ˆ np.mean(a, axis) +- return a new array containing the average values of a over the specified axis. If +axis is None, the result is a scalar value. +Write your code in the space below.', 16, 10, NULL::jsonb, NULL, NULL, 'Problem 3 [16 points] Model Evaluation and Advanced Python Programming +In this problem, you need to implement the evaluation metrics for multi-class classifiers. +Specifically, you need to implement the confusion matrix, accuracy, precision, recall, and +macro F1 score. We provide the related definitions and formulas as follows. +(a) +[5.5 points] Suppose there is a test dataset consisting of 10 data points, their actual +classes are [2, 1, 1, 2, 0, 1, 0, 0, 1, 1], and their predicted classes by a classifier model +are [2, 1, 2, 1, 0, 2, 1, 0, 0, 0]. What is the confusion matrix for the prediction results? +What are TP, TN, FP, FN for each class? +Confusion matrix: a table that summarized actual labels and the predictions of classifi- +cation. For multi-class classification, the confusion matrix has the shape (num classes, +num classes) that records the number of occurrences between actual labels and the pre- +dictions. The classes are listed in the same order in the rows as in the columns, therefore +the correctly classified elements are located on the main diagonal. +For each class i, TPi, TNi, FPi, FNi represent the instance numbers of true positive, +true negative, false positive, and false negative, respectively. +ˆ True positive: A test result where the classifier correctly predicts the positive class +as positive. +ˆ True negative: A test result where the classifier correctly predicts the negative class +as negative. +ˆ False positive: A test result where the classifier incorrectly predicts the negative class +as positive. +ˆ False negative: A test result where the classifier incorrectly predicts the positive class +as negative. +Please fill in the confusion matrix (the rows represent actual class and the columns +represent predicted class) and the TP, TN, FP, FN table. +Predicted +Actual +Class +Class +TP +TN +FP +FN +Answer: +Predicted +Actual +Class +Class +TP +TN +FP +FN +Scheme: +ˆ 0.25 point for each correct numeric value. 5.25 points in total. +ˆ An extra 0.25 point is given for those who gave all the correct numeric values. +(b) +[1 point] What is the accuracy score of the classifier model on the above test data? +Accuracy = +PN +i=1 TPi +num testdata, where N is the number of classes. +Answer: +0.4 +Scheme: +ˆ 1 point for the correct answer. +(c) +[2.5 points] What are the precisions, recalls, and F1 scores for each class of the classifier +model on the above test data? Please fill in the table using fractions or keep 3 decimals. +For each class i, Precisioni = +TPi +TPi+FPi +For each class i, Recalli = +TPi +TPi+FNi +For each class i, F1i = 2·Precisioni·Recalli +Precisioni+Recalli +Class +Precision +Recall +F1 score +Answer: +Class +Precision +Recall +F1 score +1/2 +2/3 +4/7 +1/3 +1/5 +1/4 +1/3 +1/2 +2/5 +Scheme: +ˆ 0.25 point for each correct numeric value (or fraction). 2.25 points in total. +ˆ An extra 0.25 point is given for those who gave all the correct numeric values (or +fractions). +(d) +[1 point] What is the macro F1 score of the classifier model on the above test data? +Please keep 3 decimals in your answer. +The macro F1 score is the unweighted mean of the F1 scores of all classes: +Macro-F1 = +PN +i=1 F1i +N +, where N is the number of classes. +Answer: +0.407 +Scheme: +ˆ 1 point for the correct answer. +(e) [6 points] Given two NumPy 1D arrays with the same shape (num_testdata,): test_actual +and test_predict, representing the actual class labels and predicted class labels for the +test data, please implement the following functions by filling in the blanks. For each +TODO, please use a one-line Python expression. +import numpy as np +def generate_confusion_matrix(test_actual, test_predict): +# TODO 1: Get num_classes, the number of classes in the test data. +# Note that the classes in the test_actual and test_predict are represented +# in integer indices from [0, 1, ..., num_classes - 1]. +num_classes = _________________________________________ +confusion_matrix = np.zeros((num_classes, num_classes)) +# TODO 2: Get the values of confusion_matrix, where the rows represent +# actual class and the columns represent predicted class. +for i in range(0, num_classes): +for j in range(0, num_classes): +confusion_matrix[i, j] = _____________________________________ +return confusion_matrix +def calculate_evaluation_metrics(test_actual, test_predict): +confusion_matrix = generate_confusion_matrix(test_actual, test_predict) +# TODO 3: Get the accuracy score, which is a scalar value. +accuracy = ______________________________________________________ +# TODO 4: Get the precisions for all classes, which is a 1D array +# with shape (num_classes, ). +precision = _____________________________________________________ +# TODO 5: Get the recalls for all classes, which is a 1D array +# with shape (num_classes, ). +recall = ________________________________________________________ +# TODO 6: Get the macro F1 score, which is a scalar value. +macro_f1 = ______________________________________________________ +return accuracy, precision, recall, macro_f1 +Note: +ˆ An expression is a combination of values, variables, operators, and calls to functions. +ˆ There must be no explicit loops in your expression. +ˆ Your implemented functions should work with any number of test data points and +any number of classes. +ˆ You cannot use any variable that is not defined inside the function or any global +variable. +You may find the following attribute or functions useful for this problem. +ˆ np.max(a, axis = None) +- return the maximum of the array a along the given axis. If axis is None, the result +is a scalar value. +ˆ np.ndarray.sum(axis = None) +- return the sum of the array over the given axis. If axis is None, the result is a +scalar value. +ˆ np.ndarray.diagonal() +- if the array is 2D, then a 1D array containing the diagonal elements is returned. +ˆ np.mean(a, axis) +- return a new array containing the average values of a over the specified axis. If +axis is None, the result is a scalar value. +Write your code in the space below. Answer: +TODO 1: np.max(test actual) + 1 or np.max(test predict) + 1 +TODO 2: (test actual == 1) & (test predict == j)).sum() +TODO 3: confusion matrix.diagonal().sum() / confusion matrix.sum() +TODO 4: confusion matrix.diagonal() / confusion matrix.sum(axis = 0) +TODO 5: confusion matrix.diagonal() / confusion matrix.sum(axis = 1) +TODO 6: np.mean(2 * precision * recall) / (precision + recall)) +Scheme: +ˆ 1 point for each TODO. 6 points in total.', ARRAY['Evaluation and Validation']::TEXT[], 'Evaluation and Validation', 'Evaluation and Validation', ARRAY['Evaluation and Validation']::TEXT[], ARRAY['metric_computation', 'experimental_design', 'reasoning']::TEXT[], 'hard', '', '', ''), + ('COMP2211-2024-spring-midterm', '4', NULL, 4, 'long_question', 'long_answer', 'Problem 4 [16 points] Na¨ıve Bayes Classifier +Based on the given training data in the table below, which includes both numerical and +categorical attributes, make a prediction about the degree classification (DC) (i.e., First-Class +Honors or Second-Class Honors D1 or Second-Class Honors D2, or Third-Class Honors) of +the student with the following attribute values using Na¨ıve Bayes classifier. +ˆ Study Attitude (SA): Serious +ˆ Part-time Job (PTJ): No +ˆ Average Energy Level (AEL): 7.2 +ˆ Courses Taught by Desmond and Pearl (CDP): Yes +ˆ Number of Friends in the Study Group (NFSG): 4 +Student +Study +Attitude +(SA) +Categorical +Part-time +Job +(PTJ) +Categorical +Average +Energy Level +(AEL) +Numerical +Courses +Taught by +Desmond +and Pearl +(CDP) +Categorical +Number of +Friends in the +Study Group +(NFSG) +Categorical +Degree +Classification +(DC) +Serious +No +8.5 +Yes +First +Moderate +Yes +6.2 +No +Second D1 +Serious +No +7.8 +Yes +Second D1 +Moderate +Yes +5.9 +No +Third +Serious +No +8.1 +Yes +First +Casual +Yes +6.5 +Yes +Second D2 +Serious +No +7.3 +No +Second D1 +Serious +No +7.9 +Yes +First +Moderate +Yes +5.7 +No +Third +Serious +No +8.2 +Yes +First +Casual +Yes +6.1 +Yes +Second D2 +Serious +No +7.6 +No +Second D2 +Moderate +Yes +6.4 +Yes +Third +Serious +No +8.0 +Yes +First +Casual +Yes +5.8 +No +Second D2 +Serious +No +7.5 +Yes +Second D1 +Serious +No +7.7 +Yes +First +Moderate +Yes +6.0 +No +Third +Serious +No +7.8 +Yes +First +Casual +Yes +6.3 +Yes +Second D2 +Assume each categorical attribute has the following possible values: +ˆ Study Attitude: Serious, Moderate, Casual +ˆ Part-time Job: Yes, No +ˆ Courses Taught by Desmond and Pearl: Yes, No +ˆ Number of Friends in the Study Group: 1, 2, 3, 4, 5, 6 +Assume that the numerical data follow a Gaussian distribution: +f(x) = +σ +√ +2πexp + +−(x −µ)2 +2σ2 + +where +numerical training data = (x1, x2, . . . , xn) +µ = 1 +n +n +X +i=1 +xi, +σ = +v +u +u +t +n −1 +n +X +i=1 +(xi −µ)2 +If needed, apply 1-Laplace Smoothing to the likelihood probabilities of the affected feature +only. The affected feature means that the categorical feature has a category given some +belief in the test dataset, which was NOT observed in the training dataset. Please provide +all the steps. +You may find the following equation useful for this question: +BNB = argmaxBiP(Bi)(P(e1|Bi)P(e2|Bi)P(e3|Bi) · · · P(ed|Bi)) +.(a) +[4 points] Calculate the mean (µ) and standard deviation (σ) of the Average Energy +Level (AEL) of Degree Classification: First-Class Honors and Second-Class Honors D1. +(b) +[3 points] Calculate the test data sample’s likelihoods of Average Energy Level (AEL) +of First-Class Honors and Second-Class Honors D1. +(c) [4 points] Calculate the test data sample’s likelihoods of Study Attitude (SA), Part-time +Job (PTJ), Courses Taught by Desmond and Pearl (CDP), and Number of Friends in +the Study Group (NFSG) of all Degree Classification(s) (DC). +(d) +[2 points] Calculate the prior probabilities. +(e) +[3 points] Finally, calculate the posterior probabilities and make the prediction. +Assume that the likelihood of Average Energy Level (AEL) of Second-Class Honors D2 +and Third-Class Honors are: +ˆ P(AEL=7.2|Second-Class Honors D2) = 0.32515 +ˆ P(AEL=7.2|Third-Class Honors) = 0.000334158', 16, 14, NULL::jsonb, NULL, NULL, 'Problem 4 [16 points] Na¨ıve Bayes Classifier +Based on the given training data in the table below, which includes both numerical and +categorical attributes, make a prediction about the degree classification (DC) (i.e., First-Class +Honors or Second-Class Honors D1 or Second-Class Honors D2, or Third-Class Honors) of +the student with the following attribute values using Na¨ıve Bayes classifier. +ˆ Study Attitude (SA): Serious +ˆ Part-time Job (PTJ): No +ˆ Average Energy Level (AEL): 7.2 +ˆ Courses Taught by Desmond and Pearl (CDP): Yes +ˆ Number of Friends in the Study Group (NFSG): 4 +Student +Study +Attitude +(SA) +Categorical +Part-time +Job +(PTJ) +Categorical +Average +Energy Level +(AEL) +Numerical +Courses +Taught by +Desmond +and Pearl +(CDP) +Categorical +Number of +Friends in the +Study Group +(NFSG) +Categorical +Degree +Classification +(DC) +Serious +No +8.5 +Yes +First +Moderate +Yes +6.2 +No +Second D1 +Serious +No +7.8 +Yes +Second D1 +Moderate +Yes +5.9 +No +Third +Serious +No +8.1 +Yes +First +Casual +Yes +6.5 +Yes +Second D2 +Serious +No +7.3 +No +Second D1 +Serious +No +7.9 +Yes +First +Moderate +Yes +5.7 +No +Third +Serious +No +8.2 +Yes +First +Casual +Yes +6.1 +Yes +Second D2 +Serious +No +7.6 +No +Second D2 +Moderate +Yes +6.4 +Yes +Third +Serious +No +8.0 +Yes +First +Casual +Yes +5.8 +No +Second D2 +Serious +No +7.5 +Yes +Second D1 +Serious +No +7.7 +Yes +First +Moderate +Yes +6.0 +No +Third +Serious +No +7.8 +Yes +First +Casual +Yes +6.3 +Yes +Second D2 +Assume each categorical attribute has the following possible values: +ˆ Study Attitude: Serious, Moderate, Casual +ˆ Part-time Job: Yes, No +ˆ Courses Taught by Desmond and Pearl: Yes, No +ˆ Number of Friends in the Study Group: 1, 2, 3, 4, 5, 6 +Assume that the numerical data follow a Gaussian distribution: +f(x) = +σ +√ +2πexp + +−(x −µ)2 +2σ2 + +where +numerical training data = (x1, x2, . . . , xn) +µ = 1 +n +n +X +i=1 +xi, +σ = +v +u +u +t +n −1 +n +X +i=1 +(xi −µ)2 +If needed, apply 1-Laplace Smoothing to the likelihood probabilities of the affected feature +only. The affected feature means that the categorical feature has a category given some +belief in the test dataset, which was NOT observed in the training dataset. Please provide +all the steps. +You may find the following equation useful for this question: +BNB = argmaxBiP(Bi)(P(e1|Bi)P(e2|Bi)P(e3|Bi) · · · P(ed|Bi)) +.(a) +[4 points] Calculate the mean (µ) and standard deviation (σ) of the Average Energy +Level (AEL) of Degree Classification: First-Class Honors and Second-Class Honors D1. +Answer: +ˆ Mean of average energy level given first-class honors = 8.02857 +ˆ Standard deviation of average energy level given first-class honors = 0.26904 +ˆ Mean of average energy level given second-class honors, D1 = 7.2 +ˆ Standard deviation of average energy level given second-class honors, D1 = 0.69761 +Scheme: +ˆ 1 point for each correct mean value. 2 points in total. +ˆ 1 point for each correct standard deviation value. 2 points in totals. +(b) +[3 points] Calculate the test data sample’s likelihoods of Average Energy Level (AEL) +of First-Class Honors and Second-Class Honors D1. +Answer: +P(AEL = 7.2|First) = +0.26904 +√ +2πexp + +−(7.2 −8.02857)2 +2(0.26904)2 + += 0.01293 +P(AEL = 7.2|SecondD1) = +0.69761 +√ +2πexp + +−(7.2 −7.2)2 +2(0.69761)2 + += 0.57187 +Scheme: +ˆ 1.5 points for each correct test data sample’s likelihood. 3 points in total. +(c) [4 points] Calculate the test data sample’s likelihoods of Study Attitude (SA), Part-time +Job (PTJ), Courses Taught by Desmond and Pearl (CDP), and Number of Friends in +the Study Group (NFSG) of all Degree Classification(s) (DC). +Answer: +ˆ As P(SA = Serious|Third) = 0, we need to apply a add-one-trick for the study +attitude. We assume that three values (Serious, Moderate, Casual) are equally prob- +able: +– P(SA = Serious|First) = 7+1 +7+3 = 0.8 +– P(SA = Serious|SecondD1) = 3+1 +4+3 = 0.57 +– P(SA = Serious|SecondD2) = 1+1 +5+3 = 0.25 +– P(SA = Serious|Third) = 0+1 +4+3 = 0.1429 +ˆ As P(PTJ = No|Third) = 0, we need to apply a add-one-trick for the Part-time +Job. We assume that three values (Yes, No) are equally probable: +– P(PTJ = No|First) = 7+1 +7+2 = 0.89 +– P(PTJ = No|SecondD1) = 3+1 +4+2 = 0.67 +– P(PTJ = No|SecondD2) = 1+1 +5+2 = 0.286 +– P(PTJ = No|Third) = 0+1 +4+2 = 0.167 +ˆ P(CDP = Y es|First) = 7 +7 = 1 +ˆ P(CDP = Y es|SecondD1) = 2 +4 = 0.5 +ˆ P(CDP = Y es|SecondD2) = 3 +5 = 0.6 +ˆ P(CDP = Y es|Third) = 1 +4 = 0.25 +ˆ As P(NFSG = 4|First) = 0, we need to apply a add-one-trick for the Number +of friends in the Study Group. We assume that six values (1,2,3,4,5,6) are equally +probable: +– P(NFSG = 4|First) = 0+1 +7+6 = 0.0769 +– P(NFSG = 4|SecondD1) = 3+1 +4+6 = 0.4 +– P(NFSG = 4|SecondD2) = 1+1 +5+6 = 0.18 +– P(NFSG = 4|Third) = 0+1 +4+6 = 0.1 +Scheme: +ˆ 0.25 point for each correct test data sample’s likelihood. 4 points in total. +(d) +[2 points] Calculate the prior probabilities. +Answer: +ˆ P(First) = 7 +20 = 0.35 +ˆ P(SecondD1) = 4 +20 = 0.2 +ˆ P(SecondD2) = 5 +20 = 0.25 +ˆ P(Third) = 4 +20 = 0.2 +Scheme: +ˆ 0.5 point for each correct prior probability. 2 points in total. +(e) +[3 points] Finally, calculate the posterior probabilities and make the prediction. +Assume that the likelihood of Average Energy Level (AEL) of Second-Class Honors D2 +and Third-Class Honors are: +ˆ P(AEL=7.2|Second-Class Honors D2) = 0.32515 +ˆ P(AEL=7.2|Third-Class Honors) = 0.000334158 +Answer: +ˆ P(First|E) = (0.35)(0.8)(0.89)(0.01293)(1)(0.0769) +P(E) += 0.0002478 +P(E) +ˆ P(SecondD1|E) = (0.2)(0.57)(0.67)(0.57187)(0.5)(0.4) +P(E) += 0.0087359 +P(E) +ˆ P(SecondD2|E) = (0.25)(0.25)(0.286)(0.32515)(0.6)(0.18) +P(E) += 0.0006277 +P(E) +ˆ P(Third|E) = (0.2)(0.1429)(0.167)(0.000334158)(0.25)(0.1) +P(E) += 3.9872×10−8 +P(E) +Therefore, the Na¨ıve Bayes classifier predicts “Degree Classification” = Second-Class +Honors D2 for the student. +Scheme: +ˆ 0.5 point for each correct posterior probability. 2 points in total. +ˆ 1 point for making the correct prediction.', ARRAY['Probabilistic Models']::TEXT[], 'Probabilistic Models', 'Probabilistic Models', ARRAY['Probabilistic Models']::TEXT[], ARRAY['manual_computation', 'probability_reasoning', 'classification_decision']::TEXT[], 'hard', '', '', ''), + ('COMP2211-2024-spring-midterm', '5', NULL, 5, 'long_question', 'long_answer', 'Problem 5 Part(b) Continued +(c) +[2 points] Based on the chosen K and those 6 training samples in part (b), calculate the +test error for the test dataset below. +Note: +ˆ When selecting a neighbor, to resolve ties, choose the neighbor with the lowest index. +ˆ When evaluating the error, use +– Number of wrong predictions / Number of test data points +. +Attribute 1 +Attribute 2 +Class +3.4 +B +2.8 +A +3.5 +A +2.5 +B', 18, 19, NULL::jsonb, NULL, NULL, 'Problem 5 Part(b) Continued +(c) +[2 points] Based on the chosen K and those 6 training samples in part (b), calculate the +test error for the test dataset below. +Note: +ˆ When selecting a neighbor, to resolve ties, choose the neighbor with the lowest index. +ˆ When evaluating the error, use +– Number of wrong predictions / Number of test data points +. +Attribute 1 +Attribute 2 +Class +3.4 +B +2.8 +A +3.5 +A +2.5 +B +Answer: +Using K=3, test error is 0. +Details: +**Test Data** +([3.4, 3.0], B) k nearest neighbors, [[3.2, 2.9], B, [2.7, 3.0], A, [3.4, 3.8], B] predict label B error: 0 +([3.0, 2.8], A) k nearest neighbors, [[3.2, 2.9], B, [2.7, 3.0], A, [2.6, 2.0], A] predict label A error: 0 +([2.0, 3.5], A) k nearest neighbors, [[2.5, 3.7], A, [2.7, 3.0], A, [3.4, 3.8], B] predict label A error: 0 +([2.5, 7.0], B) k nearest neighbors, [[2.5, 3.7], A, [3.5, 4.0], B, [3.4, 3.8], B] predict label B error: 0 +total error for test data: 0/4 +Scheme: +ˆ 2 points for giving the total error for 6-cross validation for K = 3.', ARRAY['KNN and Clustering']::TEXT[], 'KNN and Clustering', 'KNN and Clustering', ARRAY['KNN and Clustering']::TEXT[], ARRAY['manual_computation', 'distance_calculation', 'algorithm_tracing']::TEXT[], 'hard', '', '', ''), + ('COMP2211-2024-spring-midterm', '6', NULL, 6, 'long_question', 'long_answer', 'Problem 6 [16 points] Leader Clustering +Consider the following cluster method called Leader Clustering. It receives two parameters: +an integer K and a real-value threshold T. Similar to K-means clustering, it starts by selecting +K instance (which will be called leaders) and assigns each training instance to the cluster of +the closest leader. During this assignment step, however, if the distance of a training instance +to its closest leader is greater than the input threshold T, then this training instance forms a +new cluster and becomes the initial leader of this new cluster. After all the training instances +have been assigned to a cluster, the leader of each cluster is updated as the mean of the +cluster. The process is then repeated until the cluster assignments do not change. +(a) +[6 points] Given a 1-dimensional data set { 1, 3, 5, 9, 11, 13, 15 }, use the Leader +Clustering algorithm and Euclidean distance to cluster the given points in the data set +into 2 clusters. Assume c1 = 3 and c2 = 11 are chosen as the initial K = 2 leaders +and the threshold for forming new clusters T = 5. Fill in the following table of the first +assignment iteration with your completed values and what are the leaders after the first +assignment iteration? (If new clusters are formed in the process, named their leaders as +c3, c4, ... based on the order. Leave the distance c3/c4/... blank if the new cluster(s) +are not formed yet.) +Data +point +Distance +between the +data point and c1 +Distance +between the +data point and c2 +Distance +between the +data point and c3 +(if needed) +Distance +between the +data point and c4 +(if needed) +Closest +Centroid +The leaders after the first assignment iteration: +(b) [6 points] Given a 1-dimensional data set {5, 9, 11, 13, 17, 19}, use the Leader Clustering +algorithm and Euclidean distance to cluster the given points in the data set into 2 clusters. +Assume c1 = 5 and c2 = 11 are chosen as the initial K = 2 leaders and the threshold for +forming new clusters T = 5. Fill in the following table of the first assignment iteration +with your computed values and what are the leaders after the first assignment iteration? +(If new clusters are formed in the process, named their leaders as c3, c4, ... based on the +order. Leave the distance c3/c4/... blank if the new cluster(s) are not formed yet.) +Data +point +Distance +between the +data point and c1 +Distance +between the +data point and c2 +Distance +between the +data point and c3 +(if needed) +Distance +between the +data point and c4 +(if needed) +Closest +Centroid +The leaders after the first assignment iteration: +(c) +[2 points] Which of the two methods, K-Means Clustering or Leader Clustering, will be +better at dealing with outliers? Please briefly explain. +(d) +[2 points] During lectures, we have learned that one drawback of the K-Means Cluster- +ing algorithm is that we need to specify K for the algorithm, but usually, we don’t know +how many clusters there should be for an unlabeled dataset. Will the Leader Clustering +mitigate this drawback? Will there be any related limitations of the Leader Clustering +algorithm? Please briefly explain.', 16, 21, NULL::jsonb, NULL, NULL, 'Problem 6 [16 points] Leader Clustering +Consider the following cluster method called Leader Clustering. It receives two parameters: +an integer K and a real-value threshold T. Similar to K-means clustering, it starts by selecting +K instance (which will be called leaders) and assigns each training instance to the cluster of +the closest leader. During this assignment step, however, if the distance of a training instance +to its closest leader is greater than the input threshold T, then this training instance forms a +new cluster and becomes the initial leader of this new cluster. After all the training instances +have been assigned to a cluster, the leader of each cluster is updated as the mean of the +cluster. The process is then repeated until the cluster assignments do not change. +(a) +[6 points] Given a 1-dimensional data set { 1, 3, 5, 9, 11, 13, 15 }, use the Leader +Clustering algorithm and Euclidean distance to cluster the given points in the data set +into 2 clusters. Assume c1 = 3 and c2 = 11 are chosen as the initial K = 2 leaders +and the threshold for forming new clusters T = 5. Fill in the following table of the first +assignment iteration with your completed values and what are the leaders after the first +assignment iteration? (If new clusters are formed in the process, named their leaders as +c3, c4, ... based on the order. Leave the distance c3/c4/... blank if the new cluster(s) +are not formed yet.) +Data +point +Distance +between the +data point and c1 +Distance +between the +data point and c2 +Distance +between the +data point and c3 +(if needed) +Distance +between the +data point and c4 +(if needed) +Closest +Centroid +The leaders after the first assignment iteration: +Answer: +Data +point +Distance +between the +data point and c1 +Distance +between the +data point and c2 +Distance +between the +data point and c3 +(if needed) +Distance +between the +data point and c4 +(if needed) +Closest +Centroid +c1 +c1 +c1 +c2 +c2 +c2 +c2 +After the first assignment iteration, c1 = (1+3+5)/3 = 3, c2 = (9+11+13+15)/4 = 12. +Scheme: +ˆ 0.25 point for each correct numeric value (or label). 5.25 points in total. +ˆ 0.25 point for the correct values of the leaders. +If the values of both leader are correct, 0.75. If only one leader is correct, +0.25. +(b) [6 points] Given a 1-dimensional data set {5, 9, 11, 13, 17, 19}, use the Leader Clustering +algorithm and Euclidean distance to cluster the given points in the data set into 2 clusters. +Assume c1 = 5 and c2 = 11 are chosen as the initial K = 2 leaders and the threshold for +forming new clusters T = 5. Fill in the following table of the first assignment iteration +with your computed values and what are the leaders after the first assignment iteration? +(If new clusters are formed in the process, named their leaders as c3, c4, ... based on the +order. Leave the distance c3/c4/... blank if the new cluster(s) are not formed yet.) +Data +point +Distance +between the +data point and c1 +Distance +between the +data point and c2 +Distance +between the +data point and c3 +(if needed) +Distance +between the +data point and c4 +(if needed) +Closest +Centroid +The leaders after the first assignment iteration: +Answer: +Data +point +Distance +between the +data point and c1 +Distance +between the +data point and c2 +Distance +between the +data point and c3 +(if needed) +Distance +between the +data point and c4 +(if needed) +Closest +Centroid +c1 +c2 +c2 +c2 +c3 +c3 +After the first assignment iteration, c1 = 5, c2 = (9+11+13)/3 = 11, c3 = (17+19)/2 = +18. +Scheme: +ˆ 0.25 point for each correct numeric value (or label). 5 points in total. +ˆ 1 point for the correct values of the leaders. 1 point in total. +If there are any incorrect leaders in the answer, 0 point +(c) +[2 points] Which of the two methods, K-Means Clustering or Leader Clustering, will be +better at dealing with outliers? Please briefly explain. +Answer: +Leader clustering is more robust (better at dealing with) outliers. This is because new +cluster will be generated and outliers will be assigned to the new cluster without influ- +encing the other clusters. +Scheme: +ˆ 1 point for stating which method is better at dealing with outliers. +ˆ 1 point for the explanation. +(d) +[2 points] During lectures, we have learned that one drawback of the K-Means Cluster- +ing algorithm is that we need to specify K for the algorithm, but usually, we don’t know +how many clusters there should be for an unlabeled dataset. Will the Leader Clustering +mitigate this drawback? Will there be any related limitations of the Leader Clustering +algorithm? Please briefly explain. +Answer: +The Leader Clustering can mitigate this drawback of specifying K because the algorithm +may get increment on the number of clusters during training. But now the limitation is +that we have to specify T for the Leader Clustering algorithm. The threshold value will +influence whether the algorithm increases new leader (new cluster) or not. The clustering +results will be sensitive to T. +Scheme: +ˆ 1 point for stating whether the Leader Clustering mitigate the drawback. +ˆ 1 point for the explanation. +Explanations including: +1. More computation/complexity +2. Sensitive to the initial choice of leaders +3. Cannot control the number of clusters +will not get any point.', ARRAY['KNN and Clustering']::TEXT[], 'KNN and Clustering', 'KNN and Clustering', ARRAY['KNN and Clustering']::TEXT[], ARRAY['manual_computation', 'distance_calculation', 'algorithm_tracing']::TEXT[], 'hard', '', '', ''), + ('COMP2211-2024-spring-midterm', '7', NULL, 7, 'long_question', 'coding', 'Problem 7 Continued: +-------------------- END OF PAPER +-------------------- +/* Rough work */ +/* Rough work */ +/* Rough work */ +/* Rough work */', 10, 24, NULL::jsonb, NULL, NULL, 'Problem 7 Continued: +Answer: +D = 2, average accuracy = 0%: In this case, the dataset was divided into two folds. Since +the samples from each class were grouped together, one fold contained samples from classes +0 and 1, and the other fold contained samples from classes 2 and 3. As a result, the classifier +failed to correctly classify any of the samples because it was not trained on the classes present +in the test fold. +D = 3, average accuracy = 50%: In this case, the dataset was divided into three folds. +Fold 1 contained samples from classes 0 and 1, fold 2 contained samples from classes 1 and 2, +and fold 3 contained samples from classes 2 and 3. Using fold 1 and fold 2 as training, and +fold 3 as testing, only 25% of test points are classified correctly. +ˆ Training: Fold 1, 2, Testing: Fold 3, Accuracy = 25% +ˆ Training: Fold 1, 3, Testing: Fold 2, Accuracy = 100% +ˆ Training: Fold 2, 3, Testing: Fold 1, Accuracy = 25% +So, the average accuracy is (25% + 100% + 25%)/3 = 50%. +D = 5, accuracy = 100%: In this case, the dataset was divided into five folds. +Fold 1 +contained samples from class 0, fold 2 contained samples from class 0 and 1, fold 3 contained +samples from class 1 and 2, fold 4 contained samples from class 2 and 3, and fold 5 contained +samples from class 3. +ˆ Training: Fold 1, 2, 3, 4, Testing: Fold 5, Accuracy: 100% +ˆ Training: Fold 1, 2, 3, 5, Testing: Fold 4, Accuracy: 100% +ˆ Training: Fold 1, 2, 4, 5, Testing: Fold 3, Accuracy: 100% +ˆ Training: Fold 1, 3, 4, 5, Testing: Fold 2, Accuracy: 100% +ˆ Training: Fold 2, 3, 4, 5, Testing: Fold 1, Accuracy: 100% +So, the average accuracy is (100% + 100% + 100% + 100% + 100%)/5 = 100%. +To improve the implementation of D-fold cross-validation, the dataset should be shuffled +before dividing it into folds. This will ensure that each fold contains a representative dis- +tribution of samples from all classes, reducing bias and providing more reliable evaluation +results. +Scheme: +ˆ 2 points for explaining the result obtained for D = 2. +ˆ 3 points for explaining the result obtained for D = 3. +ˆ 3 points for explaining the result obtained for D = 5. +ˆ 2 points for suggesting improvement(s) to the implementation. +-------------------- END OF PAPER +-------------------- +/* Rough work */ +/* Rough work */ +/* Rough work */ +/* Rough work */', ARRAY['Evaluation and Validation']::TEXT[], 'Evaluation and Validation', 'Evaluation and Validation', ARRAY['Evaluation and Validation']::TEXT[], ARRAY['metric_computation', 'experimental_design', 'reasoning']::TEXT[], 'medium', '', '', ''), + ('COMP2211-2024-spring-final', '1', NULL, 1, 'true_false', 'true_false', 'Problem 1 [10 points] True/False Questions +Indicate whether the following statements are true or false by putting T or F in the given +table in the answer book. You get 1 point for each correct answer. +(a) The console output of the following Python code is [1,2,3,4,5,6,7]. +import numpy as np +# arange(start, stop): Values are generated within half-open interval [start,stop). +array = np.arange(1,10) +print(array[:-2]) +(b) There is no way for the Na¨ıve Bayes classifier to make a prediction if a categorical feature +(e.g., color) has a new category (e.g., blue) not observed in the training data set. +(c) K-Nearest Neighbors classifier is a non-parametric machine learning algorithm with an +assumption that the data are uniformly distributed. +(d) It is possible for K-Means Clustering to return empty clusters if certain initial centroid +positions are unfortunate. +(e) If there are only two classes to predict, the following Multi-layer Perceptron (MLP) +models will have the same output, given that they have the same initial weights and +biases, and are trained in the same manner: +ˆ MLP 1: Input layer, hidden dense layer with ReLU activation function, output layer +with sigmoid activation function. +ˆ MLP 2: Input layer, hidden dense layer with ReLU activation function, output layer +with softmax activation function. +(f) An affine transformation may preserve distances and angles. +(g) The number of floating point multiplications involved when a 32×32 pixel RGB image +is passed through a 2D convolutional layer with 8 3×3 kernels, padding of 3, and stride +length of 1 is 8×3×3×36×36. +(h) Setting the dropout rate of a Convolutional Neural Network to 0.5 means that more than +50% of its layer’s outputs are non-zero. +(i) Alpha-beta pruning can sometimes change the final decision made by the minimax algo- +rithm, resulting in a different move being selected for the current player. +(j) Researches that involves human participants should require informed consent.', 10, 2, NULL::jsonb, NULL, NULL, 'Problem 1 [10 points] True/False Questions +Indicate whether the following statements are true or false by putting T or F in the given +table. You get 1 point for each correct answer. +Question +(a) +(b) +(c) +(d) +(e) +(f) +(g) +(h) +(i) +(j) +Answer +T +F +F +T +F +T +F +F +F +T +Scheme: +ˆ 1 point for giving each correct answer. 10 points in total.', ARRAY['True/False']::TEXT[], 'True/False', 'True/False', ARRAY['True/False']::TEXT[], ARRAY['concept_check', 'rapid_reasoning']::TEXT[], 'medium', '', '', ''), + ('COMP2211-2024-spring-final', '2', NULL, 2, 'long_question', 'coding', 'Problem 2 [10 points] Advanced Python: Image Processing with NumPy +You are working on an image processing project that involves manipulating arrays to perform +various operations on images. You have been provided with a NumPy array img representing +a grayscale image of size 512 × 512: +import numpy as np +img = np.random.randint(0, 256, size=(512, 512), dtype=np.uint8) +# Note: random.randint(low, high=None, size=None, dtype=int) +# returns random integers from low (inclusive) to high (exclusive). +(a) +[4 points] Image masking: +Implement the following function which creates a new array img masked by applying a +mask to img. The mask is defined as a 2D array mask of size 512 × 512, where mask[i, +j] = 1 if the pixel at img[i, j] is within a circle (boundary included) of radius 100 +centered at a given position [center x, center y], and mask[i, j] = 0 otherwise. +def apply_circle_mask(img, center_x, center_y): +# --- YOUR CODE HERE --- +return img_masked +Below is an example for center x = 100 and center y = 200. +img +img_masked +Do NOT use any explicit loop to implement the function. You may find the following +functions useful for this question. +ˆ np.arange(start, stop, step) returns spaced values within a given interval. +– start (optional) is the start of interval. The default start value is 0. +– stop is the end of interval. The returned interval does not include this value. +– step (optional) is the spacing between values. The default spacing is 1. +ˆ np.square(a) returns the element-wise square of an array. +– a is the input array. +This is equivalent to a ** 2. +ˆ np.sqrt(a) returns the element-wise square root of an array. +– a is the input array. +ˆ np.expand dims(a, axis) returns a new array with a new axis of size 1 inserted. +– a is the input array. +– axis is the position where the axis is to be inserted. +If axis is 0, this is equivalent to a[np.newaxis] and a[None]. If axis is 1, this is +equivalent to a[:, np.newaxis] and a[:, None]. +(b) +[6 points] Image blurring: +Your task is to use NumPy to apply the following 3 × 3 blur filter to img with zero +padding so that img blur has the same shape as img. +blur_filter = np.array([[1/9, 1/9, 1/9], +[1/9, 1/9, 1/9], +[1/9, 1/9, 1/9]]) +Implement the following function so that after the whole code snippet below is executed, +img blur stores the desired result. +def img_flatten_conv_1d(img, v): +# --- YOUR CODE HERE --- +return img_conv +v = blur_filter.sum(0) # ''sum(0)'' returns the sum of the array elements over axis 0. +img_blur = img_flatten_conv_1d(img, v) +img_blur = img_flatten_conv_1d(img_blur.T, v).T +Do NOT use any explicit loop in your code. You may find the following functions useful +for this question. +ˆ np.convolve(a, v, mode=''full'') returns the discrete, linear convolution of two +one-dimensional sequences. +– a of shape (N, ): First one-dimensional input array (or array-like structure, e.g., +list). +– v of shape (M, ): Second one-dimensional input array (or array-like structure, +e.g., list). +– mode (optional): The convolution mode. It must be one of ''full'', ''valid'', +''same''. Note: use ''valid'' for this question. When ''valid'' is used, np.convolve +is equivalent to the following function: +def convolve_valid(a, v): +if len(a) < len(v): +a, v = v, a +# swap the array if v is longer than a +c = np.zeros(len(a) - len(v) + 1) +for i in range(len(c)): +c[i] = np.sum(a[i:i+len(v)] * v[::-1]) +return c +Examples: +>>> np.convolve([1,2,3],[0,1,0.5], ''valid'') +array([2.5]) +# this is the output array +>>> np.convolve([1,2,3,4],[0,1,0.5], ''valid'') +array([2.5, 3.5]) +# this is the output array +ˆ np.zeros(shape, dtype=float) returns a new array of given shape and type, filled +with zeros. +– shape is the shape of the new array, e.g., (2, 3) or 2. +– dtype (optional) is the desired data type for the array, e.g., np.int8. The default +is np.float64. +ˆ np.concatenate((a1, a2, ...), axis=0) joins a sequence of arrays along an ex- +isting axis. +– a1, a2, ... is a sequence of arrays (or array-like structure, e.g., list). The +arrays must have the same shape, except in the dimension corresponding to +axis (the first, by default). +– axis (optional) is the axis along which the arrays will be joined. If axis is None, +arrays are flattened before use. The default is 0. +ˆ np.reshape(a, newshape) gives a new shape to an array without changing its data. +– a is the array to be reshaped. +– newshape is the new shape. It should be compatible with the original shape. If +an integer, then the result will be a 1D array of that length. One shape dimension +can be −1. In this case, the value is inferred from the length of the array and +the remaining dimensions (if any). +This is equivalent to a.reshape(newshape). +ˆ np.transpose(a) returns the transpose of an array. +– a is the input array. +This is equivalent to a.T. +Hints: +(1) In this case, applying blur filter to the image can also be done by consecutively +applying two 1D filters, one vertically and the other horizontally, to the image. +(2) Since np.convolve only accepts 1D arrays, you may consider flattening the image +array, applying np.convolve to the flattened array, and then reshaping it back to a +2D array. +(3) Be aware of the boundaries since np.convolve with mode=''valid'' does not pad +the array and the output array does not always have the same shape as the input +array. Also, remember to remove the padding (if any) after convolutions.', 10, 3, NULL::jsonb, NULL, NULL, 'Problem 2 [10 points] Advanced Python: Image Processing with NumPy +Solution: +(a) def apply_circle_mask(img, center_x, center_y): +indices = np.arange(512) +x_dist = indices - center_x +y_dist = indices - center_y +dist = np.sqrt(x_dist ** 2 + y_dist[:, None] ** 2) +img_masked = img * (dist <= 100) +return img_masked +(b) def img_flatten_conv_1d(img, v): +zeros = np.zeros((512, 1)) +img_padded = np.concatenate((zeros, img, zeros), axis=1) +img_flat = img_padded.reshape(-1) +img_flat_conv = np.convolve(img_flat, v, ''valid'') +img_flat_conv = np.concatenate(([0], img_flat_conv, [0])) +img_conv = img_flat_conv.reshape(512, 514)[:, 1:-1] +return img_conv +v = [1/3, 1/3, 1/3] +img_blur = img_flatten_conv_1d(img, v) +img_blur = img_flatten_conv_1d(img_blur.T, v).T +Scheme: +(a) If no explicit loop is used, 1 point for each of the following: +ˆ correct broadcasting to create 2D distance array; +ˆ correct distance values; +ˆ correct mask (0.5 point for the opposite mask); +ˆ correct final result. +If explicit loops are used, 1 point in total if the final result is correct. +(b) If no explicit loop is used, 1 point for each of the following: +ˆ correct padding before flattening the image array (0.5 point if padding is applied +after flattening); +ˆ correct flattening of the image array; +ˆ correct dimensions of inputs (1D arrays) to np.convolve; +ˆ correct values of inputs to np.convolve; +ˆ correct padding after np.convolve; +ˆ correct reshaping back to 2D image and removing padding (0.5 each). +If explicit loops are used, 1 point in total if the final result is correct.', ARRAY['Python Fundamentals']::TEXT[], 'Python Fundamentals', 'Python Fundamentals', ARRAY['Python Fundamentals']::TEXT[], ARRAY['implementation', 'code_tracing', 'debugging']::TEXT[], 'medium', '', '', ''), + ('COMP2211-2024-spring-final', '3', NULL, 3, 'long_question', 'long_answer', 'Problem 3 [12 points] Na¨ıve Bayes, K-Nearest Neighbors and Perceptron +Below is some health data of patients with and without dengue fever. +Patient # +Diagnosis (B) +Body Temperature +(Celsius) (e1) +Pulse Rate +(bpm) (e2) +Dengue +Dengue +Dengue +No dengue +No dengue +36.5 +No dengue +You can assume the data follows Gaussian distribution: +f(x) = +√ +2πσ2 exp(−(x −µ)2 +2σ2 +) +where +µ = 1 +n +n +X +i=1 +xi +σ = +v +u +u +t +n −1 +n +X +i=1 +(xi −µ)2 +(a) +[6 points] Using the data above, calculate the following. Round off calculations to the +fourth decimal place. +(i) P(e1 = 36 | B = No dengue) +(ii) P(e2 = 85 | B = No dengue) +(iii) P(e1 = 36 | B = No dengue) P(e2 = 85 | B = No dengue) P(B = No dengue) +(iv) Calculate the posterior probability that the belief is ‘No dengue’ for the sample with +evidence E = {e1 = 36, e2 = 85} if +P((e1 = 36, e2 = 85)|B = Dengue)P(B = Dengue) = 0.0000036 +(b) +[4.5 points] Identify the reasons for inaccurate predictions when using the following +number of folds to evaluate the performance of a 3-Nearest Neighbors classifier model on +the given dataset (i.e., not including the sample introduced in part (a)) without shuffling. +(i) No. of folds = 6 +(ii) No. of folds = 3 +(iii) No. of folds = 2 +(c) +[1.5 points] If the true label of the sample {e1 = 36, e2 = 85} is ‘No dengue’, will the +perceptron model make a good prediction for the sample? Provide explanations with +evidence for why or why not.', 12, 6, NULL::jsonb, NULL, NULL, 'Problem 3 [12 points] Na¨ıve Bayes, K-Nearest Neighbors and Perceptron +Solution: +(a) +(i) +p +2π(0.5)2 exp + +−(36 −36.5)2 +2(0.52) + += 0.4839 +(ii) +p +2π(15)2 exp + +−(85 −90)2 +2(152) + += 0.0252 +(iii) +(0.4839)(0.0252)(0.5) = 0.0061 +(iv) +0.0061 +0.0061 + 0.0000036 = 0.9994 +(b) +(i) This will predict 5/6 correctly if uniform distance (prediction for Patient 5 is wrong). +Also, if inverse distance is used, the prediction for Patient 3 will also be wrong. +(ii) It is possible that the data will be folded in such a way that the class opposite to +the true label is typically the majority class e.g. +(Patient 1, Patient 2), (Patient 3, +Patient 4), (Patient 5, Patient 6) . In this case, most folds will have 50% accuracy. +The performance may be even worse as there is one pair of samples from opposite +classes with minimum pairwise Euclidean distance (Patient 3 and Patient 5). +(iii) This is not acceptable because it is possible that the training set for the KNN model +will have the class opposite to the true label as its majority class. In this case, the +prediction for all samples will be wrong. +(c) Yes, because in that case the data is linearly separable. +Scheme: +(a) +(i) 1.5 points for giving the correct answer. +(ii) 1.5 points for giving the correct answer. +(iii) 1.5 points for giving the correct answer. +(iv) 1.5 points for giving the correct answer. +(b) +(i) 1.5 point for giving the correct reason. +(ii) 1.5 point for giving the correct reason. +(iii) 1.5 point for giving the correct reason. +(c) 0.5 point for stating “Yes”, i.e., perceptron model will make a good prediction for the +sample. 1 point for giving the correct explanation.', ARRAY['Probabilistic Models', 'KNN and Clustering', 'Perceptron and MLP']::TEXT[], 'Probabilistic Models', NULL, ARRAY['Probabilistic Models', 'KNN and Clustering', 'Perceptron and MLP']::TEXT[], ARRAY['manual_computation', 'probability_reasoning', 'classification_decision', 'distance_calculation', 'algorithm_tracing', 'forward_pass', 'backpropagation', 'weight_update']::TEXT[], 'medium', '', '', ''), + ('COMP2211-2024-spring-final', '4', NULL, 4, 'long_question', 'long_answer', 'Problem 4 [11 points] Multi-layer Perceptron +(a) +[5 points] Amanda wants to train a multi-layer perceptron to classify various renting +options’ popularity. +Each housing option is represented with X, a three-dimensional +vector (x1, x2, x3). +x1 is the noise level rated from 1 to 5. x2 is the proximity to campus, measured in minutes +it takes to arrive at the north gate. x3 is the availability of food options rated on a scale +from 1 (no food available) to 5 (a wide variety of food available). Amanda has collected +the opinions of a group of students on their preferences, classifying the housing options +into three types: low, medium, and high, represented as one-hot vector Y = (y1, y2, y3). +When designing the network architecture, Amanda has two ideas. +Model A +Model B +Layer (type) +Output Shape +Layer (type) +Output Shape +dense 1 (Dense) +(None, 6) +dense 1 (Dense) +(None, 3) +dense 2 (Dense) +(None, 4) +dense 2 (Dense) +(None, 5) +dense 3 (Dense) +(None, 3) +dense 3 (Dense) +(None, 2) +dense 4 (Dense) +(None, 3) +(i) In a multi-layer perceptron, suppose there are L hidden layers, each layer k (starting +from 1) has lk hidden nodes. The input data is a n-dimensional vector, and the +output data is m-dimensional. +(I) Calculate the number of updated parameters in the MLP model. Represent your +result using n, m, L, and lk for k = 1, . . . , L. +(II) Apply your result in part (a)(i)(I) to Model A and Model B separately. +(ii) Please help Amanda decide on which multi-layer perceptron (A or B) to choose for +potentially better performance. Please briefly explain why. +(b) [1 point] Why do we use activation functions in multi-layer perceptron neural networks? +(c) +[2 points] Consider the two activation functions we have learned in class: +ˆ Sigmoid activation function: σ(x) = +1+e−x . +ˆ Binary step activation function: +f(x) = + + + +if x ≤0 +otherwise +The binary step activation function gives a hard threshold at 0, and its gradients are 0 +almost everywhere. What is the problem if we use the binary step activation function in +a multi-layer perceptron network? If we want to avoid the problem while approximating +the binary step activation function, how to make use of the sigmoid activation function +to achieve that? +(d) +[3 points] You are given an multi-layer perceptron model with the architecture shown +below. +ˆ ReLU: f(z) = max(0, z). +ˆ Softmax: f(zi) = +ezi +Pn−1 +j=0 ezj , where z = [z0, z1, . . . , zn−1]. +(i) For a sample with features x1=1 and x2=1, what are the outputs of the hidden layer +and the output layer? If necessary, round off the values to two decimal places. +(ii) If the target labels have values Tk1=1,Tk2=0 for the sample in part d(i), calculate the +new values of the weights: w5, w7, and w1 after one round of backward propagation +if the learning rate is 0.4. Round off the values to four decimal places. +For reference, here are some of the equations used in the back propagation. +δk = (Ok −Tk)Ok(1 −Ok) +δj = Oj(1 −Oj) +X +k∈K +δkwjk +wjk ←wjk −ηδkOj +wij ←wij −ηδjOi', 11, 7, NULL::jsonb, NULL, NULL, 'Problem 4 [11 points] Multi-layer Perceptron +Solution: +(a) +(i) (I) For any hidden layer and the output layer, the parameters include the weight +and the bias. +(n + 1) × l1 + +L−1 +X +k=1 +lk+1 × (lk + 1) + m × (lL + 1) +(II) Model A: 67; Model B: 53. +(ii) Model A is better because it has more parameters and therefore more expressive. +(b) To add non-linearity to the neural network model so that it has more powerful modeling +capability. +(c) The problem is that we cannot learn the parameters using gradient descent since the +gradients are 0 almost everywhere. We can solve the problem while approximating a +hard threshold by scaling up the weights in a sigmoid activation function. For example, +σ(cx) is steeper than σ(x) and more similar with the binary step function, for c > 1. +(d) +(i) Hidden layer Oj1 = 0.2, Oj2 = 0 +Output layer Ok1 = 0.48, Ok2 = 0.52 +(ii) w +′ +5 = w5 −δk1ηOj1 = 0.1 −(−1298)(0.4)(0.2) = 0.1104 +w +′ +7 = w7 −δk2ηOj1 = 0 −(1298)(0.4)(0.2) = −0.0104 +δj1 = Oj1(1−Oj1)(δk1w5+δk2w7) = 0.2(1−0.2)(−0.1298(0.1)+0.1298(0)) = −0.0021 +w +′ +1 = w1 −δj1ηx1 = 0.2 −(0.4)(−0.0021)(1) = 0.2008 +Scheme: +(a) +(i) (I) 1.5 points for giving the correct formula. +(II) 1 point for each correct answer. 2 points in total. +(ii) 0.5 point for stating Model A is better. 1 point for giving the correct explanation. +1.5 points in total. +(b) 1 point for giving the correct answer for why we use activation functions in multi-layer +perceptron. +(c) 1 point for stating the problem. 1 point for explaining how to make use of the sigmoid +function to avoid the problem. 2 points in total. +(d) +(i) 0.5 point for giving each correct output. 1.5 points in total. +(ii) 0.5 point for giving each correct weight value. 1.5 points in total.', ARRAY['Perceptron and MLP']::TEXT[], 'Perceptron and MLP', 'Perceptron and MLP', ARRAY['Perceptron and MLP']::TEXT[], ARRAY['forward_pass', 'backpropagation', 'weight_update']::TEXT[], 'medium', '', '', ''), + ('COMP2211-2024-spring-final', '5', NULL, 5, 'long_question', 'long_answer', 'Problem 5 [13 points] Digital Image Processing +(a) [4.5 points] The following is a grayscale image of the HKUST redbird and its correspond- +ing histogram. An grayscale image histogram is a distribution showing the frequency of +occurrence of each gray-level value. +After some transformation on the original image, we get the transformed images shown +below (first row). Their histograms are shuffled and shown in the rows below (second, +third, and fourth row). Please state which transformations are applied to get the resulting +images for (a),(b),(c), and what is the correct pairing for the images and histograms +between {a,b,c} and {d,e,f}. Please also briefly state why. +(b) +[3 points] Consider the following 3 × 3 image. +Perform binary thresholding of the +image using Otsu’s method. The initial threshold is T=100, and we apply one iteration. +What is the resulting threshold and the resulting image after thresholding? What is the +advantage of using Otsu’s Method for image thresholding compared to the regular image +thresholding algorithm? +(c) +[2 points] What is the resulting image of size 7 × 7 after adding reflection padding of +size 2 on the original image in part (b)? +(d) +[1.5 points] Please briefly explain the effect of convolving an image with the following +kernels. +(i) Kernel 1: +1/9 +1/9 +1/9 +1/9 +1/9 +1/9 +1/9 +1/9 +1/9 +(ii) Kernel 2: +-1 +-1 +-1 +(iii) Kernel 3: +-1 +-1 +-1 +-1 +-1 +-1 +-1 +-1 +(e) +[2 points] Is it possible to design a 3 × 3 kernel and apply convolution with the kernel +to flip a 64Ö64 image horizontally or vertically? If yes, please give such a kernel. If not, +please explain why.', 13, 9, NULL::jsonb, NULL, NULL, 'Problem 5 [13 points] Digital Image Processing +Solution: +(a) (b)-(f): image (horizontal) flipping because the histogram is the same as the original +image. +(c)-(d): binary thresholding because there are only two values 0 and 255 in the histogram +(a)-(e): contrast stretching because the intensity value in the histogram has been stretched +to a wider range. +(b) After the first iteration µ1=21 , µ2=128, T = 74.5 The resulting images are +Compared to regular image thresholding algorithms, Otsu’s method has advantages (i) +can automatically determine the threshold value T (ii) the resulting threshold value is +reproducible. Given the same image, two researchers using Otsu’s algorithm must arrive +at the same threshold. +(c) Resulting image: +(d) (i) smoothing, (ii) vertical edge detection, (iii) sharpening. +(e) No, because the image flipping is a global operation on the image, but convolution with +a 3 × 3 kernel is a local operation. Concretely, the 3 × 3 kernels can only capture the +input value of 3 × 3 neighbors, but the flipping requires the pixel value information at a +longer distance. The longest dependency distance can be 64. +Scheme: +(a) 1 for stating each transformation correctly. 3 points in total. 0.5 point for giving each +correct pairing. 1.5 points in total. +(b) 1 point for giving the correct resulting threshold. 1 point for giving the correct result +image. 1 point for stating the advantage of using Otsu’s method. 3 points in total. +(c) 0.05 for giving each correct value (40 values). 2 points in total. +(d) 0.5 point for stating each effect of convolving with the given kernel correctly. 1.5 points +in total. +(e) 0.5 point for stating it is impossible to design a 3×3 kernel and apply it to flip the 64×64 +image. 1.5 points for giving the explanation.', ARRAY['Vision and CNN']::TEXT[], 'Vision and CNN', 'Vision and CNN', ARRAY['Vision and CNN']::TEXT[], ARRAY['manual_computation', 'filter_computation', 'architecture_reasoning']::TEXT[], 'medium', '', '', ''), + ('COMP2211-2024-spring-final', '6', NULL, 6, 'long_question', 'long_answer', 'Problem 6 [13 points] Dilated Convolution and Dropout +(a) +[10 points] Dilated convolution is a variation of the standard convolution operation that +involves skipping input elements by a certain dilation rate. By doing this, the convo- +lutional kernel can be made to “see” a larger area of the input data without actually +increasing the number of parameters it has. The dilation rate determines the spacing +between the values in the kernel. +The figure below demonstrates how a 3×3 kernel is applied to a 7×7 image using a +dilation factor of 2. +Given the incomplete implementation of the Python function dilated convolution that +takes the following inputs: +ˆ input array: a 2D NumPy array representing the input data +ˆ kernel: a 2D NumPy array representing the convolutional kernel +ˆ dilation rate: an integer representing the dilation rate (default value is 1) +ˆ stride: an integer representing the stride (default value is 1) +ˆ padding: a string representing the padding type, either ‘valid’ (no padding) or ‘same’ +(padding to preserve input dimensions) (default value is ‘valid’) +and returns a 2D NumPy array representing the output of the dilated convolution oper- +ation. +Complete the missing parts of the function using NumPy, without using any special- +ized deep learning libraries so that the execution of the test script produces the required +output. Make sure that your implementation supports stride and padding options. +You may find the following formula for determining the size of output image of reg- +ular image convolution useful for this question. +(Size of image dimension - Size of kernel dimension + 2 × Padding) / Stride + 1 +import numpy as np +# Zero pad the input_array. For example +# a = [[1,2,3]] +# np.pad(a, ((1,2),(3,4)), ''constant'') +# >> array([[0, 0, 0, 0, 0, 0, 0, 0, 0, 0], +# >> +[0, 0, 0, 1, 2, 3, 0, 0, 0, 0], +# >> +[0, 0, 0, 0, 0, 0, 0, 0, 0, 0], +# >> +[0, 0, 0, 0, 0, 0, 0, 0, 0, 0]]) +def dilated_convolution(input_array, kernel, dilation_rate=1, +stride=1, padding=''valid''): +# Apply padding to the input array if specified +if padding == ''same'': +pad = #______ TODO 1 ______ +padded_input = np.pad(input_array, ((pad, pad), (pad, pad)), mode=''constant'') +else: +padded_input = input_array +# Calculate the output shape +output_rows = #______ TODO 2 ______ +output_cols = #______ TODO 3 ______ +kernel_rows, kernel_cols = kernel.shape +# Initialize the output array with zeros +output_array = np.zeros((output_rows, output_cols)) +# Iterate through the kernel and perform the convolution +for i in range(kernel_rows): +for j in range(kernel_cols): +# Calculate the input indices for the current kernel position +input_row_indices = #______ TODO 4 ______ +input_col_indices = #______ TODO 5 ______ +# Perform the convolution and accumulate the results in the output array +output_array += #______ TODO 6 ______ +return output_array +# Test script +input_array = np.array(np.arange(100).reshape(10,10)) +kernel = np.array([[1, 0, 0], [0, 1, 0], [0, 0, 1]]) +dilation_rate = 2; stride = 2; padding = ''same'' +output_array = dilated_convolution(input_array, kernel, +dilation_rate, stride, padding) +print(output_array) +# Output: +# [[ 22. +26. +30. +34. +8.] +# +[ 62. +66. +72. +78. +34.] +# +[102. 126. 132. 138. +74.] +# +[142. 186. 192. 198. 114.] +# +[ 80. 142. 146. 150. 154.]] +You may find the following functions useful for this question. +ˆ np.arange(start, stop, step) returns spaced values with a given interval. +– start (optional) is the start of interval. The default start value is 0. +– stop is the end of interval. The returned interval does not include this value. +– step (optional) is the spacing between values. The default spacing is 1. +ˆ np.array(object) creates an array. +– object is an array or any sequence. +ˆ np.reshape(a, newshape) gives a new shape to an array without changing its data. +– a is the array to be reshaped. +– newshape is the new shape. It should be compatible with the original shape. If +an integer, then the result will be a 1D array of that length. One shape dimension +can be -1. In this case, the value is inferred from the length of the array and the +remaining dimensions (if any). +This is equivalent to a.reshape(newshape). +ˆ ndarray.shape returns a tuple of array dimensions. +ˆ ndarray.zeros(shape, dtype=float) returns a new array of given shape and type, +filled with zeros. +– shape is the shape of the new array, e.g., (2,3) or 2. +– dtype(optional) is the desired data type for the array, e.g., np.int8. The default +is np.float64. +ˆ range(n) returns a numeric series starting with 0 and extending up to but not +including n. +(b) [3 points] You are given an incomplete implementation of the Python function dropout, +which implements the Dropout technique for regularization in neural networks. +The +function takes the following input: +ˆ input array: a 2D NumPy array representing the input data +ˆ p: a dropout probability +The function generates a binary mask with the same shape as the input, where each +element is 1 with probability 1-p and 0 with probability p. The input is then multiplied +element-wise by the mask, effectively dropping out random elements. The function re- +turns a NumPy array representing the output of the dropout operation. +Complete the missing part of the function using NumPy, without using any special- +ized deep learning libraries so that the execution of the test script produces the required +output. +You may find the following function useful for this question. +ˆ np.random.rand(d0, d1, . . ., dn) returns an array of the given shape and popu- +late it with random samples from a uniform distribution over [0, 1). +– d0, d1,. . ., dn (optional) represent the dimensions of the returned array, must +be non-negative. If no argument is given, a single Python float is returned. +import numpy as np +def dropout(input_array, p): +mask = #______ TODO ______ +return input_array * mask', 13, 12, NULL::jsonb, NULL, NULL, 'Problem 6 [13 points] Dilated Convolution and Dropout +Solution: +(a) Dilated Convolution +TODO # +Answer +((kernel.shape[0] - 1) * dilation_rate) // 2 +1.5 points +(input_array.shape[0] - kernel.shape[0] * dilation_rate + 2 * pad) // stride + 1 +1.5 points +(input_array.shape[1] - kernel.shape[1] * dilation_rate + 2 * pad) // stride + 1 +1.5 points +np.arange(0, output_rows * stride, stride) + i * dilation_rate +1.5 points +np.arange(0, output_cols * stride, stride) + j * dilation_rate +1.5 points +kernel[i, j] * padded_input[np.ix_(input_row_indices, input_col_indices)] +2.5 points +(b) Dropout +(np.random.rand(input_array.shape[0],input_array.shape[1]) < p) / p +# 3 points', ARRAY['Vision and CNN']::TEXT[], 'Vision and CNN', 'Vision and CNN', ARRAY['Vision and CNN']::TEXT[], ARRAY['manual_computation', 'filter_computation', 'architecture_reasoning']::TEXT[], 'medium', '', '', ''), + ('COMP2211-2024-spring-final', '7', NULL, 7, 'long_question', 'long_answer', 'Problem 7 [18 points] Convolutional Neural Network +(a) +[8 points] Consider the following Keras implementation of a Convolutional Neural Net- +work: +from keras.models import Sequential +from keras.layers import Conv2D, MaxPooling2D +from keras.layers import Dense, Flatten +model = Sequential() +model.add(Conv2D(filters=32, kernel_size=(5, 5), padding=''same'', +activation=''relu'', input_shape=(32, 32, 3))) +model.add(Conv2D(filters=64, kernel_size=(3, 3), padding=''same'', +activation=''relu'')) +model.add(MaxPooling2D(pool_size=(2, 2))) +model.add(Conv2D(filters=64, kernel_size=(3, 3), padding=''same'', +activation=''relu'', strides=(2, 2))) +model.add(Conv2D(filters=64, kernel_size=(3, 3), padding=''same'', +activation=''relu'')) +model.add(MaxPooling2D(pool_size=(2, 2))) +model.add(Flatten()) +model.add(Dense(units=128, activation=''relu'')) +model.add(Dense(units=10, activation=''softmax'')) +(i) What is the padding size (in terms of border width) of the first convolutional layer? +(ii) Fill in the blanks in the answer book in the model.summary() of the network. Assume +all convolutional layers have biases. Numbers shown as ? are kept secret and you +may ignore them. +Hint: When padding = “same” and strides = 2, the output has the half size of the +input. +(iii) Suppose you observe the following loss curves as you train the Convolutional Neural +Network. What is the most likely problem with the model? When does the problem +usually occur? Describe a common way to mitigate the problem and explain why it +works (in a few sentences). +(b) [5 points] What is the output of the following parts of this very tiny convolutional neural +network? This network was trained to classify whether the input 4×4 image depicts the +letter “L” or not. Stride is 1, padding size is 0 for all layers, and the pool size for the +max pooling layer is (2, 2). Show your calculation step by step. You only need to keep +1 decimal number. +ˆ ReLU activation function f(z) = max(0, x). +ˆ Sigmoid activation function f(z) = +1+e−z , where e = 2.718. +Note: Assume the given kernels have already been flipped. +(i) Feature map after Max-Pooling +(ii) Feature in the fully-connected layer +(iii) Output +(c) [5 points] In video processing, 3D convolution is particularly valuable due to its ability to +capture temporal dynamics as well as spatial features, which is essential for understanding +content across frames. Unlike 2D convolution, which only analyzes spatial patterns within +a single frame, 3D convolution extends the analysis across multiple consecutive frames, +treating time as the third dimension alongside height and width. This approach allows a +Convolutional Neural Network (CNN) to not only perceive static features in individual +frames but also to understand movements and changes over time, which are crucial for +tasks such as action recognition, video classification, and anomaly detection in video +streams. +Suppose we have video data of shape (32, 400, 300, 3), corresponding to (#keyframes, +width, height, #channels). In the first layer, we want to apply a 3D convolution with +100 kernels in the shape of (3, 3, 3), corresponding to (keyframe, width, height). The +padding is (0, 2, 2), and the stride is (1, 2, 2). Please answer the following question with +calculation steps or rationales. +(i) What is the shape of the output after this layer? +(ii) How many weight parameters are there? +(iii) How many bias parameters are there?', 18, 16, NULL::jsonb, NULL, NULL, 'Problem 7 [18 points] Convolutional Neural Network +Solution: +(a) +(i) 2 +(ii) 16, 16, 64 +8, +8, +(iii) The model has overfit the training dataset. +Overfitting occurs when a model is +too complex and learns the noise in the training data rather than the underlying +patterns. +Dropout can be used to mitigate the problem. Dropout is a technique where, during +training, some neurons in the network are randomly dropped out (i.e., their outputs +are set to zero) with a certain probability, typically 0.2 or 0.5. This means that, +at each training iteration, a different subset of neurons is randomly selected to be +“dropped out.” +Dropout helps prevent overfitting in several ways (full marks to any of the fol- +lowing): +ˆ Reducing capacity: By randomly dropping out neurons, the network’s capacity +is reduced, making it less prone to overfitting. With fewer neurons, the network +has fewer opportunities to memorize the training data. +ˆ Forcing feature sharing: Dropout encourages feature sharing among neurons. +When a neuron is dropped out, the network must rely on other neurons to make +predictions, which promotes feature sharing and reduces overfitting. +ˆ Preventing complex co-adaptations: Dropout breaks the complex co-adaptations +between neurons, which can lead to overfitting. By randomly dropping out neu- +rons, the network is forced to learn simpler, more generalizable representations. +ˆ Improving generalization: Dropout can be seen as a form of data augmentation. +By randomly dropping out neurons, the network is forced to generalize to new, +unseen situations, which improves its ability to generalize to new data. +ˆ Reducing the risk of over-reliance on a single neuron: Dropout prevents the +network from relying too heavily on a single neuron or a small group of neurons. +This reduces the risk of overfitting, as the network is forced to use multiple +neurons to make predictions. +ˆ Ensemble-like behavior: Dropout can be seen as an ensemble method, where +multiple sub-networks are trained simultaneously. Each sub-network is a different +subset of neurons, and the final prediction is an ensemble of these sub-networks. +This ensemble-like behavior improves generalization and reduces overfitting. +The answer is not unique. +(b) Answer: +(c) +(i) For any dimension (let’s denote it generically as D): Output D = floor((Input D + +2 × Padding - Kernel D ) / Stride) + 1. Therefore, we have: +Output keyframe = floor((32 + 2 * 0 - 3) / 1) + 1 = 30 +Output width = floor((400 + 2 * 2 - 3) / 2) + 1 = 201 +Output height = floor((300 + 2 * 2 - 3) / 2) + 1 = 151 +The output shape is (30, 201, 151, 100). +(ii) There are 100 kernels in the shape of (3, 3, 3). Therefore, the number of weight +parameters is 1000 Ö 3 Ö 3 Ö 3 x 3 = 8100. +(iii) There is 1 bias parameter per kernel. So the total biases is 100. +Scheme: +(a) +(i) 1 point for giving the correct padding size. +(ii) 1 point for giving each correct shape (3 shape values). 1 point for giving the correct +number of parameters. 4 points in total. +(iii) 1 point for stating the model has overfit the training dataset. 1 point for explaining +what does the problem usually occur. 1 point for describing a way to mitigate the +problem. 3 points in total. +(b) 1 point for giving each correct feature map (2 feature maps). 1 point for giving each +feature in the fully-connected layer (2 features). 1 point for giving the correct output. 5 +points in total. +(c) +(i) 2 points for giving the correct output shape after this layer. +(ii) 1.5 points for giving the correct number of weight parameters. +(iii) 1.5 points for giving the correct total number of biases.', ARRAY['Vision and CNN']::TEXT[], 'Vision and CNN', 'Vision and CNN', ARRAY['Vision and CNN']::TEXT[], ARRAY['manual_computation', 'filter_computation', 'architecture_reasoning']::TEXT[], 'hard', '', '', ''), + ('COMP2211-2024-spring-final', '8', NULL, 8, 'long_question', 'long_answer', 'Problem 8 [10 points] Minimax and Alpha-Beta Pruning +(a) +[5 points] The figure below shows a tree in a minimax game with two players. +A +B +C +D +E +F +G +H +I +J +MIN +MAX +Terminal +nodes +Score +(i) Calculate the best score for the non-terminal nodes with the minimax algorithm. +(ii) Suppose we are using an alpha-beta pruning algorithm. Indicate which edge will be +pruned. Gives the alpha-beta values when running. We denote the edge between +nodes A and B as AB. +(iii) How do you make a minimax algorithm to find the shortest path to victory? Explain +in one sentence. +(b) [5 points] Consider the non zero-sum generalization in which the sum of the two players’ +utilities are not necessarily zero. Because player A’s utility no longer determines player +B’s utility exactly, the leaf utilities are written as pairs (UA, UB), with the first and second +component indicating the utility of that leaf to player A and player B respectively. In +this generalized setting, player A seeks to maximize UA, the first component, while player +B seeks to maximize UB, the second component. +(i) Complete the table in the answer book by estimating the value (as pairs) at each of +the internal node. Assume that each player maximizes their own utility. +(ii) Is alpha-beta pruning still applicable in this case? Briefly explain why and provide +an example. (Hint: you can think about the case where UA(s) = UB(s) for all nodes.)', 10, 18, NULL::jsonb, NULL, NULL, 'Problem 8 [10 points] Minimax and Alpha-Beta Pruning +Solution: +(a) +(i) Answer: +Nodes +Score +A +B +C +D +(ii) Answer: +Edge +Alpha +Beta +CH +DJ +(iii) Record depth information to distinguish paths. +(b) +(i) Answer: +A +(2,4) +B +(0,3) +C +(-1,3) +D +(1,1) +E +(0,-2) +(ii) No. The values that the first and second player are trying to maximize are inde- +pendent. Therefore, the principle for pruning in alpha-beta pruning—that a worse +outcome for one player implies a better outcome for the other—no longer applies. +For instance, in the case where UA(s) = UB(s) for all nodes, the problem reduces to +searching for the max-valued leaf, which could appear anywhere in the tree. +Scheme: +(a) +(i) 0.25 point for giving each correct numeric value. 1 point in total. +(ii) 0.5 point for giving each correct edge/numeric value. 3 points in total. +(iii) 1 point for giving a way to find the shortest path to victory. +(b) +(i) 0.5 point for giving each pair of values. 2.5 points in total. +(ii) 0.5 point for stating “No”. 1 point for explaining why and 1 point for giving an +example. 2.5 points in total.', ARRAY['Search and Games']::TEXT[], 'Search and Games', 'Search and Games', ARRAY['Search and Games']::TEXT[], ARRAY['tree_search', 'pruning', 'manual_tracing']::TEXT[], 'medium', '', '', ''), + ('COMP2211-2024-spring-final', '9', NULL, 9, 'long_question', 'short_answer', 'Problem 9 [3 points] Ethics of Artificial Intelligence +From what areas we should ensure the AI models in production in their organizations func- +tion ethically? +-------------------- END OF PAPER +-------------------- +/* Rough work */ +/* Rough work */ +/* Rough work */ +/* Rough work */ +/* Rough work */', 3, 19, NULL::jsonb, NULL, NULL, 'Problem 9 [3 points] Ethics of Artificial Intelligence +Solution: +ˆ Data Ethics +ˆ Fair AI model (or avoiding AI model bias) +ˆ AI model monitoring and maintenance. +Scheme: +ˆ 1 point for giving each area correctly. 3 points in total. +-------------------- END OF PAPER +--------------------', ARRAY['Ethics of AI']::TEXT[], 'Ethics of AI', 'Ethics of AI', ARRAY['Ethics of AI']::TEXT[], ARRAY['concept_explanation', 'argumentation', 'comparison']::TEXT[], 'easy', '', '', '') +) AS seed( + source_exam_key, + question_number, + parent_question, + display_order, + question_type, + question_format, + question_text, + score, + page_number, + options, + correct_option, + correct_answer, + raw_answer_text, + topics, + topic_primary, + analytics_topic, + topic_tags, + skill_tags, + difficulty, + knowledge_reminder, + ai_hint, + solution +) +JOIN papers AS p + ON p.source_exam_key = seed.source_exam_key + AND p.source_kind = 'course_library' +WHERE NOT EXISTS ( + SELECT 1 FROM paper_questions q + WHERE q.paper_id = p.id + AND q.question_number = seed.question_number +); diff --git a/supabase/seeds/comp2211_problem_taxonomy_backfill.sql b/supabase/seeds/comp2211_problem_taxonomy_backfill.sql new file mode 100644 index 0000000..df53e43 --- /dev/null +++ b/supabase/seeds/comp2211_problem_taxonomy_backfill.sql @@ -0,0 +1,109 @@ +-- ============================================ +-- PastPaper Master — COMP2211 problem-level taxonomy backfill +-- Seed Date: 2026-03-24 +-- ============================================ +-- +-- Purpose: +-- 1. Backfill coarse taxonomy for COMP2211 question rows after the paper has been +-- processed into `paper_questions`. +-- 2. Use the audited cover-page problem mapping as the initial analytics baseline. +-- 3. Only fill empty taxonomy fields, so later fine-grained per-question curation +-- can safely overwrite these defaults. + +WITH mapping AS ( + SELECT * + FROM ( + VALUES + ('COMP2211-2022-fall-midterm', '1', 'True/False Questions', 'True/False', 'True/False', ARRAY['True/False']::TEXT[], ARRAY['concept_check', 'rapid_reasoning']::TEXT[], 'true_false'), + ('COMP2211-2022-fall-midterm', '2', 'Python Fundamentals', 'Python Fundamentals', 'Python Fundamentals', ARRAY['Python Fundamentals']::TEXT[], ARRAY['code_tracing', 'implementation', 'debugging']::TEXT[], 'coding'), + ('COMP2211-2022-fall-midterm', '3', 'Conditional Probability and Bayes Classifier', 'Probabilistic Models', 'Probabilistic Models', ARRAY['Probabilistic Models']::TEXT[], ARRAY['manual_computation', 'probability_reasoning', 'classification_decision']::TEXT[], 'long_question'), + ('COMP2211-2022-fall-midterm', '4', 'K-Nearest Neighbors', 'KNN and Clustering', 'KNN and Clustering', ARRAY['KNN and Clustering']::TEXT[], ARRAY['manual_computation', 'distance_calculation', 'algorithm_tracing']::TEXT[], 'long_question'), + ('COMP2211-2022-fall-midterm', '5', 'K-Means Clustering', 'KNN and Clustering', 'KNN and Clustering', ARRAY['KNN and Clustering']::TEXT[], ARRAY['manual_computation', 'cluster_update', 'algorithm_tracing']::TEXT[], 'long_question'), + ('COMP2211-2022-fall-midterm', '6', 'Perceptron', 'Perceptron and MLP', 'Perceptron and MLP', ARRAY['Perceptron and MLP']::TEXT[], ARRAY['manual_computation', 'weight_update', 'formula_application']::TEXT[], 'long_question'), + ('COMP2211-2022-fall-midterm', '7', 'Multilayer Perceptron', 'Perceptron and MLP', 'Perceptron and MLP', ARRAY['Perceptron and MLP']::TEXT[], ARRAY['forward_pass', 'backpropagation', 'derivation']::TEXT[], 'long_question'), + + ('COMP2211-2022-spring-midterm', '1', 'True/False Questions', 'True/False', 'True/False', ARRAY['True/False']::TEXT[], ARRAY['concept_check', 'rapid_reasoning']::TEXT[], 'true_false'), + ('COMP2211-2022-spring-midterm', '2', 'Python Fundamentals', 'Python Fundamentals', 'Python Fundamentals', ARRAY['Python Fundamentals']::TEXT[], ARRAY['code_tracing', 'implementation', 'debugging']::TEXT[], 'coding'), + ('COMP2211-2022-spring-midterm', '3', 'Conditional Probability and Bayes Classifier', 'Probabilistic Models', 'Probabilistic Models', ARRAY['Probabilistic Models']::TEXT[], ARRAY['manual_computation', 'probability_reasoning', 'classification_decision']::TEXT[], 'long_question'), + ('COMP2211-2022-spring-midterm', '4', 'K-Nearest Neighbors', 'KNN and Clustering', 'KNN and Clustering', ARRAY['KNN and Clustering']::TEXT[], ARRAY['manual_computation', 'distance_calculation', 'algorithm_tracing']::TEXT[], 'long_question'), + ('COMP2211-2022-spring-midterm', '5', 'K-Means Clustering', 'KNN and Clustering', 'KNN and Clustering', ARRAY['KNN and Clustering']::TEXT[], ARRAY['manual_computation', 'cluster_update', 'algorithm_tracing']::TEXT[], 'long_question'), + ('COMP2211-2022-spring-midterm', '6', 'Perceptron', 'Perceptron and MLP', 'Perceptron and MLP', ARRAY['Perceptron and MLP']::TEXT[], ARRAY['manual_computation', 'weight_update', 'formula_application']::TEXT[], 'long_question'), + ('COMP2211-2022-spring-midterm', '7', 'Perceptron and Multilayer Perceptron', 'Perceptron and MLP', 'Perceptron and MLP', ARRAY['Perceptron and MLP']::TEXT[], ARRAY['forward_pass', 'backpropagation', 'weight_update']::TEXT[], 'long_question'), + + ('COMP2211-2022-spring-final-part-a', '1', 'True/False Questions', 'True/False', 'True/False', ARRAY['True/False']::TEXT[], ARRAY['concept_check', 'rapid_reasoning']::TEXT[], 'true_false'), + ('COMP2211-2022-spring-final-part-a', '2', 'Na¨ıve Bayes and K-Nearest Neighbors', NULL, 'Probabilistic Models', ARRAY['Probabilistic Models', 'KNN and Clustering']::TEXT[], ARRAY['manual_computation', 'probability_reasoning', 'distance_calculation']::TEXT[], 'long_question'), + ('COMP2211-2022-spring-final-part-a', '3', 'Multilayer Perceptron (MLP)', 'Perceptron and MLP', 'Perceptron and MLP', ARRAY['Perceptron and MLP']::TEXT[], ARRAY['forward_pass', 'backpropagation', 'derivation']::TEXT[], 'long_question'), + ('COMP2211-2022-spring-final-part-a', '4', 'Digital Image Processing', 'Vision and CNN', 'Vision and CNN', ARRAY['Vision and CNN']::TEXT[], ARRAY['manual_computation', 'filter_computation', 'architecture_reasoning']::TEXT[], 'long_question'), + + ('COMP2211-2022-spring-final-part-b', '1', 'Convolutional Neural Network (CNN)', 'Vision and CNN', 'Vision and CNN', ARRAY['Vision and CNN']::TEXT[], ARRAY['forward_pass', 'architecture_reasoning', 'manual_computation']::TEXT[], 'long_question'), + ('COMP2211-2022-spring-final-part-b', '2', 'Python Programming: Convolutional Neural Network', 'Python Fundamentals', 'Python Fundamentals', ARRAY['Python Fundamentals', 'Vision and CNN']::TEXT[], ARRAY['implementation', 'code_tracing', 'debugging']::TEXT[], 'coding'), + ('COMP2211-2022-spring-final-part-b', '3', 'Minimax and Alpha-Beta Pruning', 'Search and Games', 'Search and Games', ARRAY['Search and Games']::TEXT[], ARRAY['tree_search', 'pruning', 'manual_tracing']::TEXT[], 'long_question'), + ('COMP2211-2022-spring-final-part-b', '4', 'Ethics of Artificial Intelligence', 'Ethics of AI', 'Ethics of AI', ARRAY['Ethics of AI']::TEXT[], ARRAY['concept_explanation', 'argumentation', 'comparison']::TEXT[], 'short_answer'), + + ('COMP2211-2023-spring-midterm', '1', 'True/False Questions', 'True/False', 'True/False', ARRAY['True/False']::TEXT[], ARRAY['concept_check', 'rapid_reasoning']::TEXT[], 'true_false'), + ('COMP2211-2023-spring-midterm', '2', 'Python Fundamentals', 'Python Fundamentals', 'Python Fundamentals', ARRAY['Python Fundamentals']::TEXT[], ARRAY['code_tracing', 'implementation', 'debugging']::TEXT[], 'coding'), + ('COMP2211-2023-spring-midterm', '3', 'Na¨ıve Bayes Classifier', 'Probabilistic Models', 'Probabilistic Models', ARRAY['Probabilistic Models']::TEXT[], ARRAY['manual_computation', 'probability_reasoning', 'classification_decision']::TEXT[], 'long_question'), + ('COMP2211-2023-spring-midterm', '4', 'K-Nearest Neighbors', 'KNN and Clustering', 'KNN and Clustering', ARRAY['KNN and Clustering']::TEXT[], ARRAY['manual_computation', 'distance_calculation', 'algorithm_tracing']::TEXT[], 'long_question'), + ('COMP2211-2023-spring-midterm', '5', 'K-Means Clustering', 'KNN and Clustering', 'KNN and Clustering', ARRAY['KNN and Clustering']::TEXT[], ARRAY['manual_computation', 'cluster_update', 'algorithm_tracing']::TEXT[], 'long_question'), + ('COMP2211-2023-spring-midterm', '6', 'Perceptron', 'Perceptron and MLP', 'Perceptron and MLP', ARRAY['Perceptron and MLP']::TEXT[], ARRAY['manual_computation', 'weight_update', 'formula_application']::TEXT[], 'long_question'), + ('COMP2211-2023-spring-midterm', '7', 'Multilayer Perceptron', 'Perceptron and MLP', 'Perceptron and MLP', ARRAY['Perceptron and MLP']::TEXT[], ARRAY['forward_pass', 'backpropagation', 'derivation']::TEXT[], 'long_question'), + + ('COMP2211-2024-spring-midterm', '1', 'True/False Questions', 'True/False', 'True/False', ARRAY['True/False']::TEXT[], ARRAY['concept_check', 'rapid_reasoning']::TEXT[], 'true_false'), + ('COMP2211-2024-spring-midterm', '2', 'Advanced Python for Artificial Intelligence', 'Python Fundamentals', 'Python Fundamentals', ARRAY['Python Fundamentals']::TEXT[], ARRAY['code_tracing', 'implementation', 'data_manipulation']::TEXT[], 'coding'), + ('COMP2211-2024-spring-midterm', '3', 'Model Evaluation & Advanced Python Programming', 'Evaluation and Validation', 'Evaluation and Validation', ARRAY['Evaluation and Validation', 'Python Fundamentals']::TEXT[], ARRAY['metric_computation', 'experimental_design', 'implementation']::TEXT[], 'coding'), + ('COMP2211-2024-spring-midterm', '4', 'Na¨ıve Bayes Classifier', 'Probabilistic Models', 'Probabilistic Models', ARRAY['Probabilistic Models']::TEXT[], ARRAY['manual_computation', 'probability_reasoning', 'classification_decision']::TEXT[], 'long_question'), + ('COMP2211-2024-spring-midterm', '5', 'K-Nearest Neighbors', 'KNN and Clustering', 'KNN and Clustering', ARRAY['KNN and Clustering']::TEXT[], ARRAY['manual_computation', 'distance_calculation', 'algorithm_tracing']::TEXT[], 'long_question'), + ('COMP2211-2024-spring-midterm', '6', 'Leader Clustering', 'KNN and Clustering', 'KNN and Clustering', ARRAY['KNN and Clustering']::TEXT[], ARRAY['manual_computation', 'cluster_update', 'algorithm_tracing']::TEXT[], 'long_question'), + ('COMP2211-2024-spring-midterm', '7', 'D-fold Cross Validation', 'Evaluation and Validation', 'Evaluation and Validation', ARRAY['Evaluation and Validation']::TEXT[], ARRAY['metric_computation', 'experimental_design', 'reasoning']::TEXT[], 'long_question'), + + ('COMP2211-2024-spring-final', '1', 'True/False Questions', 'True/False', 'True/False', ARRAY['True/False']::TEXT[], ARRAY['concept_check', 'rapid_reasoning']::TEXT[], 'true_false'), + ('COMP2211-2024-spring-final', '2', 'Advanced Python: Image Processing with NumPy', 'Python Fundamentals', 'Python Fundamentals', ARRAY['Python Fundamentals', 'Vision and CNN']::TEXT[], ARRAY['implementation', 'data_manipulation', 'filter_computation']::TEXT[], 'coding'), + ('COMP2211-2024-spring-final', '3', 'Na¨ıve Bayes, K-Nearest Neighbors and Perceptron', NULL, 'Probabilistic Models', ARRAY['Probabilistic Models', 'KNN and Clustering', 'Perceptron and MLP']::TEXT[], ARRAY['manual_computation', 'probability_reasoning', 'distance_calculation', 'weight_update']::TEXT[], 'long_question'), + ('COMP2211-2024-spring-final', '4', 'Multi-layer Perceptron', 'Perceptron and MLP', 'Perceptron and MLP', ARRAY['Perceptron and MLP']::TEXT[], ARRAY['forward_pass', 'backpropagation', 'derivation']::TEXT[], 'long_question'), + ('COMP2211-2024-spring-final', '5', 'Digital Image Processing', 'Vision and CNN', 'Vision and CNN', ARRAY['Vision and CNN']::TEXT[], ARRAY['manual_computation', 'filter_computation', 'architecture_reasoning']::TEXT[], 'long_question'), + ('COMP2211-2024-spring-final', '6', 'Dilated Convolution and Dropout', 'Vision and CNN', 'Vision and CNN', ARRAY['Vision and CNN']::TEXT[], ARRAY['architecture_reasoning', 'forward_pass', 'comparison']::TEXT[], 'long_question'), + ('COMP2211-2024-spring-final', '7', 'Convolutional Neural Network', 'Vision and CNN', 'Vision and CNN', ARRAY['Vision and CNN']::TEXT[], ARRAY['architecture_reasoning', 'forward_pass', 'implementation']::TEXT[], 'long_question'), + ('COMP2211-2024-spring-final', '8', 'Minimax and Alpha-Beta Pruning', 'Search and Games', 'Search and Games', ARRAY['Search and Games']::TEXT[], ARRAY['tree_search', 'pruning', 'manual_tracing']::TEXT[], 'long_question'), + ('COMP2211-2024-spring-final', '9', 'Ethics of Artificial Intelligence', 'Ethics of AI', 'Ethics of AI', ARRAY['Ethics of AI']::TEXT[], ARRAY['concept_explanation', 'argumentation', 'comparison']::TEXT[], 'short_answer') + ) AS t ( + source_exam_key, + problem_number, + raw_topic, + analytics_topic, + topic_primary, + topic_tags, + skill_tags, + default_question_format + ) +) +UPDATE paper_questions AS q +SET analytics_topic = COALESCE(q.analytics_topic, mapping.analytics_topic), + topic_primary = COALESCE(q.topic_primary, mapping.topic_primary), + topic_tags = CASE + WHEN q.topic_tags IS NULL OR cardinality(q.topic_tags) = 0 THEN mapping.topic_tags + ELSE q.topic_tags + END, + skill_tags = CASE + WHEN q.skill_tags IS NULL OR cardinality(q.skill_tags) = 0 THEN mapping.skill_tags + ELSE q.skill_tags + END, + topics = CASE + WHEN q.topics IS NULL OR cardinality(q.topics) = 0 THEN mapping.topic_tags + ELSE q.topics + END, + question_format = CASE + WHEN (q.question_format IS NULL OR q.question_format = '') + AND mapping.default_question_format IS NOT NULL + THEN mapping.default_question_format + ELSE q.question_format + END +FROM papers AS p +JOIN mapping + ON mapping.source_exam_key = p.source_exam_key +WHERE q.paper_id = p.id + AND p.source_kind = 'course_library' + AND p.course_code = 'COMP2211' + AND ( + q.question_number = mapping.problem_number + OR q.question_number ~ ('^' || mapping.problem_number || '([^0-9].*)?$') + ); diff --git a/tech_defense.md b/tech_defense.md new file mode 100644 index 0000000..859436b --- /dev/null +++ b/tech_defense.md @@ -0,0 +1,274 @@ +# KnowIt Technical Defense Q&A + +## Part 1: AI & Product Technical Questions + +### Q: How does your AI analyze past papers? What model do you use? + +We use a multi-model pipeline with clear separation of concerns: + +1. **Vision extraction** (Gemini 2.5 Flash): We render each PDF page to a 96 DPI PNG image using PyMuPDF, encode it as base64, and send it to Gemini's vision API via OpenAI-compatible endpoint. The model extracts every question into structured JSON — question number, type, text, options, score, topics, difficulty. We process in batches of 8 pages to stay within token limits. + +2. **Solution generation** (DeepSeek V3): For each extracted question, we generate three components — a knowledge reminder, a progressive hint, and a complete step-by-step solution. This is done in batches of 3 questions per API call to balance throughput and quality. + +3. **Answer matching** (Gemini Vision): If an answer PDF is provided, we send its pages to the vision model and match answers to corresponding questions by question number. + +The key architectural decision is **splitting vision and text tasks across different models** — Gemini for anything that needs to "see" the document, DeepSeek for pure text reasoning. This cuts cost by ~60% compared to using a single vision model for everything. + +### Q: Why vision mode instead of traditional PDF text extraction? + +We started with pdfplumber-based text extraction and hit critical failures: + +- **Multi-line code blocks** break apart: `C = np.array([[[0,1,2,3],` gets separated from its closing brackets across lines +- **Mathematical notation** is lost or garbled +- **Table structures** collapse into unreadable strings +- **Mixed formatting** (code + text + formulas on the same page) confuses parsers + +Vision mode sends the raw page image — the model sees exactly what a student sees. On our COMP2211 benchmark (Python-heavy, lots of NumPy arrays), vision mode correctly extracts 100% of questions vs ~70% with text extraction. + +### Q: How accurate is the AI on code questions? + +For code output questions (e.g., "What does `print(A[2:-2:3])` output?"), we don't rely on the LLM to calculate. We run **Python exec()** on the actual code: + +```python +ns = {"np": np} +exec(extract_code_lines(question_text), ns) # setup variables +output = exec("print(A[2:-2:3])", ns) # capture stdout +# output = "[ 7 10]" — ground truth, fed to AI as reference +``` + +We maintain a **shared namespace per question group** so variables defined in a parent question (e.g., `A = np.arange(5,15)`) are available to all sub-questions. This gives us 100% accuracy on Python output questions. + +### Q: How does auto-grading work technically? + +Three-step pipeline, all non-blocking (run in thread pool via `asyncio.to_thread`): + +1. **Gemini Vision OCR**: Student's photo → base64 → Gemini with OCR prompt → extracted text with LaTeX formulas preserved (`$\mu = 8.03$`) + +2. **DeepSeek Grading**: We inject the question text, reference answer (from DB), and OCR'd student answer into a structured prompt. The model returns JSON: + ```json + { + "is_correct": false, + "score_given": 2, + "feedback": "Step 1 correct, but in Step 3...", + "error_at_step": 3 + } + ``` + +3. **Persistence**: Result stored in `user_attempts` table with `user_id`, `question_id`, `feedback`, `photo_url`. Wrong answers auto-added to error book. Frontend loads historical results on page load via `GET /api/attempts/by-paper/{paper_id}`. + +Grading runs in a thread pool (`asyncio.to_thread`) so it never blocks the main event loop — other users can browse papers, load questions, etc. while grading runs. + +### Q: How does the similar question retrieval work? + +Multi-signal similarity scoring with caching: + +1. **Topic normalization**: We maintain an alias map (e.g., "Numpy"/"NumPy" → "NumPy", "Naïve Bayes"/"Naive Bayes Classifier" → "Naive Bayes") — about 80 aliases covering common variations. + +2. **Candidate filtering**: Pre-filter by `analytics_topic` in PostgreSQL (cuts candidates from ~250 to ~30 for a given course). + +3. **Scoring** (up to 100 points): + - Topic overlap: up to 40 pts (exact analytics_topic match = 30, shared topic_tags = 10) + - Question type match: 15 pts + - Difficulty match: 10 pts + - PostgreSQL `ts_rank_cd` full-text similarity: up to 20 pts + - Same parent question structure: 5 pts + +4. **Caching**: First computation is stored in `similar_questions` JSONB column on the question row. Subsequent loads are instant. In-memory cache with 5-minute TTL for hot questions. + +5. **Deduplication**: Only the best-matching question per paper is shown (avoid showing 5 questions from the same exam). + +### Q: What's the processing pipeline architecture? How do you handle failures? + +**Checkpoint-based processing with auto-resume:** + +The pipeline has 5 stages, each checkpointed to the database: + +| Stage | What happens | Checkpoint | +|-------|-------------|-----------| +| 1. Render | PDF → PNG images (96 DPI) | In memory only | +| 2. Extract | Vision API → structured questions | Progress bar updated per batch | +| 3. Match answers | Answer PDF → question mapping | Optional, failure skipped | +| 4. Save questions | Write all questions to DB | **Each question persisted immediately** | +| 5. AI trio | Generate solutions per question | **Each solution written individually** | + +If the server crashes at stage 5 (say, 15/35 solutions generated), on restart: +- `lifespan` startup hook detects papers with `status=processing` +- Checks `paper_questions` table — finds 35 questions, 15 with solutions +- Calls `_resume_ai_trio()` which only processes the 20 missing ones +- Marks paper as `ready` when done + +The processing runs in a **daemon thread** with its own event loop (`threading.Thread` + `asyncio.run`), completely isolated from the FastAPI server. + +### Q: What's your RAG pipeline for the AI Tutor? + +We use **LangChain** with a vector database (**SurrealDB**) to index three content types: + +1. **Lecture recordings**: Downloaded from Canvas → FFmpeg extracts audio → Whisper transcribes with timestamps → chunked into ~500 token segments with navigation markers +2. **Courseware PDFs/PPTs**: Extracted and chunked with metadata (course code, topic, page) +3. **Past paper content**: Question text + solutions indexed with topic tags + +Retrieval flow: Student query → embedding → top-K vector search → retrieved chunks + question context → LLM generates grounded answer with source citations. + +### Q: How do you handle API costs? + +**Cost optimization at every layer:** + +| Strategy | Savings | +|----------|---------| +| Model splitting (Gemini vision + DeepSeek text) | ~60% vs single model | +| 96 DPI rendering (down from 120) | ~26% fewer tokens per page | +| 8-page batches for vision | Fewer API calls | +| Answer matching failure = skip, not retry forever | Prevents cost runaway | +| `similar_questions` cached in DB column | One-time compute per question | +| DeepSeek at $0.28/M input vs Gemini at $0.15/M + vision overhead | Text tasks 2-3x cheaper | + +Per-paper cost: **~$1-2 USD** for full processing (extraction + answer matching + 40 solutions). +Per-grading: **~$0.02** (one vision OCR + one text grading call). + +### Q: What's your tech stack? + +| Layer | Technology | Why | +|-------|-----------|-----| +| Frontend | React 18 + Vite + TypeScript | Fast SPA, hot reload | +| Backend | FastAPI (Python 3.12, async) | Native async, OpenAPI docs | +| Database | PostgreSQL via Supabase | Relational + Auth + Storage + RLS | +| Vector DB | SurrealDB | RAG retrieval for AI Tutor | +| Cache | Redis | Session cache, rate limiting | +| Vision AI | Gemini 2.5 Flash (Google official API) | Best vision quality, free tier | +| Text AI | DeepSeek V3 (deepseek-chat) | Cheapest frontier model, no rate limits | +| PDF Rendering | PyMuPDF (fitz) | Fast, accurate page-to-image | +| Code Execution | Python exec() with sandboxed namespace | Ground-truth for code output questions | +| Math Rendering | KaTeX (client-side) | Fast LaTeX rendering, no server round-trip | +| Transcription | Whisper + FFmpeg | Lecture recording → text | +| Deployment | Docker + OpenResty + Let's Encrypt | Single server, HTTPS, reverse proxy | +| Hosting | Tencent Cloud Singapore (2C4G) | Low latency to HK, Gemini API accessible | + +### Q: How do you handle concurrent uploads? + +1. Upload endpoint reads file bytes, creates DB record (`status: processing`), returns paper ID immediately (~200ms response) +2. Processing spawns in a **daemon thread** with its own asyncio event loop — completely isolated from the FastAPI server +3. Frontend polls `GET /api/papers/mine` every 4 seconds, shows real-time progress bar ("Reading pages 1-8...", "Generating solutions 12/35 questions") +4. Multiple papers can process simultaneously (each in its own thread) +5. Server stays responsive for all other requests during processing + +### Q: How do you handle JSON parsing issues from LLM responses? + +LLMs often return invalid JSON, especially with LaTeX content. We handle three categories: + +1. **Markdown code fences**: Strip ` ```json ... ``` ` wrappers +2. **Control characters**: Remove `\x00-\x1f` except `\t\n\r` +3. **Invalid escape sequences**: LaTeX like `\sqrt`, `\sigma` produces invalid JSON escapes. We use a regex that only fixes **odd-count backslash sequences** before non-escape characters: + ```python + re.sub(r'(?