quizforge 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- quizforge-0.1.0/.github/workflows/publish.yml +41 -0
- quizforge-0.1.0/.github/workflows/tests.yml +24 -0
- quizforge-0.1.0/.gitignore +11 -0
- quizforge-0.1.0/LICENSE +21 -0
- quizforge-0.1.0/PKG-INFO +104 -0
- quizforge-0.1.0/README.md +80 -0
- quizforge-0.1.0/pyproject.toml +42 -0
- quizforge-0.1.0/src/quizforge/__init__.py +99 -0
- quizforge-0.1.0/src/quizforge/bank.py +89 -0
- quizforge-0.1.0/src/quizforge/certificate.py +84 -0
- quizforge-0.1.0/src/quizforge/cli.py +134 -0
- quizforge-0.1.0/src/quizforge/generate.py +193 -0
- quizforge-0.1.0/src/quizforge/grade.py +119 -0
- quizforge-0.1.0/src/quizforge/integrity.py +104 -0
- quizforge-0.1.0/src/quizforge/llm.py +42 -0
- quizforge-0.1.0/src/quizforge/sample.py +73 -0
- quizforge-0.1.0/src/quizforge/schemas.py +85 -0
- quizforge-0.1.0/src/quizforge/text.py +11 -0
- quizforge-0.1.0/tests/fakes.py +75 -0
- quizforge-0.1.0/tests/test_bank.py +64 -0
- quizforge-0.1.0/tests/test_certificate.py +60 -0
- quizforge-0.1.0/tests/test_cli.py +73 -0
- quizforge-0.1.0/tests/test_generate.py +82 -0
- quizforge-0.1.0/tests/test_grade.py +70 -0
- quizforge-0.1.0/tests/test_integrity.py +53 -0
- quizforge-0.1.0/tests/test_sample.py +66 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
name: publish
|
|
2
|
+
|
|
3
|
+
# Publishes to PyPI via Trusted Publishing (OIDC) — no API token stored.
|
|
4
|
+
# Configure the publisher once at https://pypi.org/manage/account/publishing/
|
|
5
|
+
# (project: quizforge, workflow: publish.yml, environment: pypi), then push a
|
|
6
|
+
# version tag: git tag v0.1.0 && git push origin v0.1.0
|
|
7
|
+
|
|
8
|
+
on:
|
|
9
|
+
push:
|
|
10
|
+
tags: ["v*"]
|
|
11
|
+
|
|
12
|
+
jobs:
|
|
13
|
+
build:
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
steps:
|
|
16
|
+
- uses: actions/checkout@v4
|
|
17
|
+
- uses: actions/setup-python@v5
|
|
18
|
+
with:
|
|
19
|
+
python-version: "3.12"
|
|
20
|
+
- name: Build sdist + wheel
|
|
21
|
+
run: |
|
|
22
|
+
python -m pip install --upgrade pip build
|
|
23
|
+
python -m build
|
|
24
|
+
- uses: actions/upload-artifact@v4
|
|
25
|
+
with:
|
|
26
|
+
name: dist
|
|
27
|
+
path: dist/
|
|
28
|
+
|
|
29
|
+
publish:
|
|
30
|
+
needs: build
|
|
31
|
+
runs-on: ubuntu-latest
|
|
32
|
+
environment: pypi
|
|
33
|
+
permissions:
|
|
34
|
+
id-token: write # required for Trusted Publishing
|
|
35
|
+
steps:
|
|
36
|
+
- uses: actions/download-artifact@v4
|
|
37
|
+
with:
|
|
38
|
+
name: dist
|
|
39
|
+
path: dist/
|
|
40
|
+
- name: Publish to PyPI
|
|
41
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
name: tests
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
strategy:
|
|
12
|
+
matrix:
|
|
13
|
+
python-version: ["3.10", "3.11", "3.12"]
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
- uses: actions/setup-python@v5
|
|
17
|
+
with:
|
|
18
|
+
python-version: ${{ matrix.python-version }}
|
|
19
|
+
- name: Install
|
|
20
|
+
run: |
|
|
21
|
+
python -m pip install --upgrade pip
|
|
22
|
+
pip install -e ".[dev]"
|
|
23
|
+
- name: Run tests
|
|
24
|
+
run: pytest -q
|
quizforge-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vinay Vobbilichetty
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
quizforge-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: quizforge
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Generate a deep, mixed-format question bank from source material and grade it — deterministic where it can, LLM where it must. Bring your own chat model.
|
|
5
|
+
Project-URL: Homepage, https://github.com/vinayvobbili/quizforge
|
|
6
|
+
Project-URL: Source, https://github.com/vinayvobbili/quizforge
|
|
7
|
+
Author: Vinay Vobbilichetty
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: assessment,education,grading,llm,question-bank,quiz,training
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Education :: Testing
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Requires-Dist: pydantic>=2
|
|
18
|
+
Requires-Dist: pyyaml>=6
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: pytest>=7; extra == 'dev'
|
|
21
|
+
Provides-Extra: openai
|
|
22
|
+
Requires-Dist: langchain-openai>=0.1; extra == 'openai'
|
|
23
|
+
Description-Content-Type: text/markdown
|
|
24
|
+
|
|
25
|
+
# quizforge
|
|
26
|
+
|
|
27
|
+
Generate a deep, **mixed-format** question bank from any source material, then grade it — **deterministic where it can, LLM where it must**. Bring your own chat model.
|
|
28
|
+
|
|
29
|
+
quizforge is the engine behind a training/readiness feature: it drafts far more questions than any single test shows (multiple choice, fill-in-the-blank, match-the-following, short answer, and free-response scenarios), samples a fresh shuffled test on each attempt — so two learners rarely see the same one — and grades every format. MC/fill/match are graded instantly with no model call; open-ended answers are scored 0–1 with coaching feedback by an LLM you provide.
|
|
30
|
+
|
|
31
|
+
- **Model-agnostic** — pass any LangChain-style chat model (`with_structured_output`). No SDK is bundled.
|
|
32
|
+
- **Deep bank, anti-sharing sampling** — unseen-first, difficulty-spread draws per a configurable blueprint.
|
|
33
|
+
- **Cheap grading** — only open-ended answers cost a model call; everything else is local and free.
|
|
34
|
+
- **Plain dicts in, plain dicts out** — YAML/JSON-friendly, easy to store and template.
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install quizforge
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Bring a chat model from whichever provider you use, e.g.:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install langchain-openai # or langchain-anthropic, etc.
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Quickstart
|
|
49
|
+
|
|
50
|
+
### Generate a bank
|
|
51
|
+
|
|
52
|
+
```python
|
|
53
|
+
from quizforge import generate_bank
|
|
54
|
+
from langchain_openai import ChatOpenAI
|
|
55
|
+
|
|
56
|
+
llm = ChatOpenAI(model="gpt-4.1", temperature=0.4)
|
|
57
|
+
|
|
58
|
+
material = open("citrix_lesson.md").read()
|
|
59
|
+
new_questions = generate_bank(
|
|
60
|
+
material, llm,
|
|
61
|
+
targets={"mc": 40, "fill_blank": 20, "match": 12, "short": 16, "freetext": 12},
|
|
62
|
+
existing=[], # pass your current bank to top it up
|
|
63
|
+
coverage="At least half should be applied incident-response scenarios.",
|
|
64
|
+
)
|
|
65
|
+
# -> list of dicts with id/type/difficulty + per-format fields. Store as you like.
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`generate_bank` only produces the *shortfall* to reach `targets`, validates each
|
|
69
|
+
question, and never duplicates an existing prompt — safe to re-run to grow a bank.
|
|
70
|
+
|
|
71
|
+
### Sample a test
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from quizforge import sample_test, DEFAULT_BLUEPRINT
|
|
75
|
+
|
|
76
|
+
test = sample_test(bank, blueprint=DEFAULT_BLUEPRINT, seen_ids=already_seen)
|
|
77
|
+
# DEFAULT_BLUEPRINT draws mc8 / fill4 / match2 / short4 / freetext2 = 20, shuffled.
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Grade
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from quizforge import grade_fill_blank, grade_match, grade_open_answer
|
|
84
|
+
|
|
85
|
+
grade_fill_blank(q, "ICA") # {"score": 1.0, "correct": True, ...}
|
|
86
|
+
grade_match(q, {"0": "RDP", "1": "ICA"}) # per-pair partial credit
|
|
87
|
+
grade_open_answer(q, learner_text, llm) # QuizGrade(score, verdict, feedback, ...) or None
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`grade_open_answer` returns `None` if the model was unavailable — exclude that
|
|
91
|
+
question from the attempt's max score rather than penalizing the learner.
|
|
92
|
+
|
|
93
|
+
## Question shapes
|
|
94
|
+
|
|
95
|
+
Each question is a dict with `id`, `type`, `difficulty`, `prompt`, plus:
|
|
96
|
+
|
|
97
|
+
- `mc` — `choices: [str]`, `answer_idx: int`, `explanation: str`
|
|
98
|
+
- `fill_blank` — `accepted_answers: [str]`, `explanation: str`
|
|
99
|
+
- `match` — `pairs: [{left, right}]`, `explanation: str`
|
|
100
|
+
- `short` / `freetext` — `model_answer: str`, `rubric: [str]`
|
|
101
|
+
|
|
102
|
+
## License
|
|
103
|
+
|
|
104
|
+
MIT
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# quizforge
|
|
2
|
+
|
|
3
|
+
Generate a deep, **mixed-format** question bank from any source material, then grade it — **deterministic where it can, LLM where it must**. Bring your own chat model.
|
|
4
|
+
|
|
5
|
+
quizforge is the engine behind a training/readiness feature: it drafts far more questions than any single test shows (multiple choice, fill-in-the-blank, match-the-following, short answer, and free-response scenarios), samples a fresh shuffled test on each attempt — so two learners rarely see the same one — and grades every format. MC/fill/match are graded instantly with no model call; open-ended answers are scored 0–1 with coaching feedback by an LLM you provide.
|
|
6
|
+
|
|
7
|
+
- **Model-agnostic** — pass any LangChain-style chat model (`with_structured_output`). No SDK is bundled.
|
|
8
|
+
- **Deep bank, anti-sharing sampling** — unseen-first, difficulty-spread draws per a configurable blueprint.
|
|
9
|
+
- **Cheap grading** — only open-ended answers cost a model call; everything else is local and free.
|
|
10
|
+
- **Plain dicts in, plain dicts out** — YAML/JSON-friendly, easy to store and template.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install quizforge
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Bring a chat model from whichever provider you use, e.g.:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install langchain-openai # or langchain-anthropic, etc.
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Quickstart
|
|
25
|
+
|
|
26
|
+
### Generate a bank
|
|
27
|
+
|
|
28
|
+
```python
|
|
29
|
+
from quizforge import generate_bank
|
|
30
|
+
from langchain_openai import ChatOpenAI
|
|
31
|
+
|
|
32
|
+
llm = ChatOpenAI(model="gpt-4.1", temperature=0.4)
|
|
33
|
+
|
|
34
|
+
material = open("citrix_lesson.md").read()
|
|
35
|
+
new_questions = generate_bank(
|
|
36
|
+
material, llm,
|
|
37
|
+
targets={"mc": 40, "fill_blank": 20, "match": 12, "short": 16, "freetext": 12},
|
|
38
|
+
existing=[], # pass your current bank to top it up
|
|
39
|
+
coverage="At least half should be applied incident-response scenarios.",
|
|
40
|
+
)
|
|
41
|
+
# -> list of dicts with id/type/difficulty + per-format fields. Store as you like.
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
`generate_bank` only produces the *shortfall* to reach `targets`, validates each
|
|
45
|
+
question, and never duplicates an existing prompt — safe to re-run to grow a bank.
|
|
46
|
+
|
|
47
|
+
### Sample a test
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
from quizforge import sample_test, DEFAULT_BLUEPRINT
|
|
51
|
+
|
|
52
|
+
test = sample_test(bank, blueprint=DEFAULT_BLUEPRINT, seen_ids=already_seen)
|
|
53
|
+
# DEFAULT_BLUEPRINT draws mc8 / fill4 / match2 / short4 / freetext2 = 20, shuffled.
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
### Grade
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from quizforge import grade_fill_blank, grade_match, grade_open_answer
|
|
60
|
+
|
|
61
|
+
grade_fill_blank(q, "ICA") # {"score": 1.0, "correct": True, ...}
|
|
62
|
+
grade_match(q, {"0": "RDP", "1": "ICA"}) # per-pair partial credit
|
|
63
|
+
grade_open_answer(q, learner_text, llm) # QuizGrade(score, verdict, feedback, ...) or None
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
`grade_open_answer` returns `None` if the model was unavailable — exclude that
|
|
67
|
+
question from the attempt's max score rather than penalizing the learner.
|
|
68
|
+
|
|
69
|
+
## Question shapes
|
|
70
|
+
|
|
71
|
+
Each question is a dict with `id`, `type`, `difficulty`, `prompt`, plus:
|
|
72
|
+
|
|
73
|
+
- `mc` — `choices: [str]`, `answer_idx: int`, `explanation: str`
|
|
74
|
+
- `fill_blank` — `accepted_answers: [str]`, `explanation: str`
|
|
75
|
+
- `match` — `pairs: [{left, right}]`, `explanation: str`
|
|
76
|
+
- `short` / `freetext` — `model_answer: str`, `rubric: [str]`
|
|
77
|
+
|
|
78
|
+
## License
|
|
79
|
+
|
|
80
|
+
MIT
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "quizforge"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Generate a deep, mixed-format question bank from source material and grade it — deterministic where it can, LLM where it must. Bring your own chat model."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Vinay Vobbilichetty" }]
|
|
13
|
+
keywords = ["quiz", "assessment", "question-bank", "training", "llm", "grading", "education"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Programming Language :: Python :: 3",
|
|
19
|
+
"Topic :: Education :: Testing",
|
|
20
|
+
]
|
|
21
|
+
dependencies = [
|
|
22
|
+
"pydantic>=2",
|
|
23
|
+
"pyyaml>=6",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.optional-dependencies]
|
|
27
|
+
openai = ["langchain-openai>=0.1"]
|
|
28
|
+
dev = ["pytest>=7"]
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
quizforge = "quizforge.cli:main"
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://github.com/vinayvobbili/quizforge"
|
|
35
|
+
Source = "https://github.com/vinayvobbili/quizforge"
|
|
36
|
+
|
|
37
|
+
[tool.hatch.build.targets.wheel]
|
|
38
|
+
packages = ["src/quizforge"]
|
|
39
|
+
|
|
40
|
+
[tool.pytest.ini_options]
|
|
41
|
+
pythonpath = ["src"]
|
|
42
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""quizforge — generate a deep, mixed-format question bank from source material
|
|
2
|
+
and grade it. Deterministic where it can, LLM where it must. Bring your own
|
|
3
|
+
chat model.
|
|
4
|
+
|
|
5
|
+
Public API
|
|
6
|
+
----------
|
|
7
|
+
Generate:
|
|
8
|
+
generate_bank(material, llm, *, targets=..., existing=...) -> list[dict]
|
|
9
|
+
Sample:
|
|
10
|
+
sample_test(questions, blueprint=..., seen_ids=...) -> list[dict]
|
|
11
|
+
pick_spread(pool, want, seen_ids) -> list[dict]
|
|
12
|
+
DEFAULT_BLUEPRINT
|
|
13
|
+
Grade:
|
|
14
|
+
grade_fill_blank(question, user_answer) -> dict
|
|
15
|
+
grade_match(question, selections) -> dict
|
|
16
|
+
grade_open_answer(question, user_answer, llm) -> QuizGrade | None
|
|
17
|
+
QuizGrade
|
|
18
|
+
Integrity:
|
|
19
|
+
assess_speed(*, elapsed_seconds, question_types, passed) -> IntegrityFlag
|
|
20
|
+
expected_min_seconds(question_types) -> float
|
|
21
|
+
IntegrityFlag
|
|
22
|
+
Certificate:
|
|
23
|
+
make_certificate(*, learner_id, ..., score_pct, awarded_on) -> Certificate
|
|
24
|
+
verification_code(...) -> str | verify(certificate) -> bool
|
|
25
|
+
Certificate
|
|
26
|
+
Utilities:
|
|
27
|
+
normalize(text) -> str
|
|
28
|
+
structured_output(llm, schema) -> chain
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from .bank import Bank
|
|
32
|
+
from .certificate import (
|
|
33
|
+
Certificate,
|
|
34
|
+
is_eligible,
|
|
35
|
+
level_for,
|
|
36
|
+
make_certificate,
|
|
37
|
+
verification_code,
|
|
38
|
+
verify,
|
|
39
|
+
)
|
|
40
|
+
from .generate import (
|
|
41
|
+
DEFAULT_COVERAGE,
|
|
42
|
+
DEFAULT_DIFF_SPLIT,
|
|
43
|
+
DEFAULT_SYSTEM,
|
|
44
|
+
DEFAULT_TARGETS,
|
|
45
|
+
generate_bank,
|
|
46
|
+
)
|
|
47
|
+
from .grade import (
|
|
48
|
+
DEFAULT_GRADE_SYSTEM,
|
|
49
|
+
QuizGrade,
|
|
50
|
+
grade_fill_blank,
|
|
51
|
+
grade_match,
|
|
52
|
+
grade_open_answer,
|
|
53
|
+
)
|
|
54
|
+
from .integrity import (
|
|
55
|
+
DEFAULT_MIN_SECONDS_PER_TYPE,
|
|
56
|
+
IntegrityFlag,
|
|
57
|
+
assess_speed,
|
|
58
|
+
expected_min_seconds,
|
|
59
|
+
)
|
|
60
|
+
from .llm import structured_output
|
|
61
|
+
from .sample import DEFAULT_BLUEPRINT, DIFFICULTY_ORDER, pick_spread, sample_test
|
|
62
|
+
from .schemas import DIFFICULTIES, FORMAT_KEYS, FORMATS
|
|
63
|
+
from .text import normalize
|
|
64
|
+
|
|
65
|
+
__version__ = "0.1.0"
|
|
66
|
+
|
|
67
|
+
__all__ = [
|
|
68
|
+
"Bank",
|
|
69
|
+
"generate_bank",
|
|
70
|
+
"sample_test",
|
|
71
|
+
"pick_spread",
|
|
72
|
+
"grade_fill_blank",
|
|
73
|
+
"grade_match",
|
|
74
|
+
"grade_open_answer",
|
|
75
|
+
"QuizGrade",
|
|
76
|
+
"assess_speed",
|
|
77
|
+
"expected_min_seconds",
|
|
78
|
+
"IntegrityFlag",
|
|
79
|
+
"DEFAULT_MIN_SECONDS_PER_TYPE",
|
|
80
|
+
"Certificate",
|
|
81
|
+
"make_certificate",
|
|
82
|
+
"verification_code",
|
|
83
|
+
"verify",
|
|
84
|
+
"is_eligible",
|
|
85
|
+
"level_for",
|
|
86
|
+
"normalize",
|
|
87
|
+
"structured_output",
|
|
88
|
+
"DEFAULT_BLUEPRINT",
|
|
89
|
+
"DEFAULT_TARGETS",
|
|
90
|
+
"DEFAULT_DIFF_SPLIT",
|
|
91
|
+
"DEFAULT_SYSTEM",
|
|
92
|
+
"DEFAULT_COVERAGE",
|
|
93
|
+
"DEFAULT_GRADE_SYSTEM",
|
|
94
|
+
"DIFFICULTY_ORDER",
|
|
95
|
+
"DIFFICULTIES",
|
|
96
|
+
"FORMAT_KEYS",
|
|
97
|
+
"FORMATS",
|
|
98
|
+
"__version__",
|
|
99
|
+
]
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Bank — a YAML-backed question bank with grow/sample/save convenience.
|
|
2
|
+
|
|
3
|
+
A bank file is YAML with (at least) a ``questions:`` list; any other top-level
|
|
4
|
+
keys are metadata, carried through untouched on save. An optional ``material:``
|
|
5
|
+
key holds the source text used to ground generation (or pass it explicitly).
|
|
6
|
+
|
|
7
|
+
Comments are NOT preserved on save (clean round-trip via PyYAML). If you keep a
|
|
8
|
+
heavily-commented bank file, generate into memory and write where you control
|
|
9
|
+
the formatting instead of calling ``save``.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from collections import Counter
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import List, Optional, Union
|
|
15
|
+
|
|
16
|
+
import yaml
|
|
17
|
+
|
|
18
|
+
from .generate import generate_bank
|
|
19
|
+
from .sample import sample_test
|
|
20
|
+
|
|
21
|
+
PathLike = Union[str, Path]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Bank:
|
|
25
|
+
"""Load, grow, sample, and save a question bank."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, questions: Optional[List[dict]] = None, meta: Optional[dict] = None):
|
|
28
|
+
self.questions: List[dict] = list(questions or [])
|
|
29
|
+
self.meta: dict = dict(meta or {})
|
|
30
|
+
|
|
31
|
+
# ----- construction -----------------------------------------------------
|
|
32
|
+
@classmethod
|
|
33
|
+
def load(cls, path: PathLike) -> "Bank":
|
|
34
|
+
data = yaml.safe_load(Path(path).read_text()) or {}
|
|
35
|
+
if not isinstance(data, dict):
|
|
36
|
+
raise ValueError(f"{path}: expected a YAML mapping, got {type(data).__name__}")
|
|
37
|
+
questions = data.pop("questions", []) or []
|
|
38
|
+
return cls(questions=questions, meta=data)
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def from_dict(cls, data: dict) -> "Bank":
|
|
42
|
+
data = dict(data or {})
|
|
43
|
+
return cls(questions=data.pop("questions", []) or [], meta=data)
|
|
44
|
+
|
|
45
|
+
# ----- inspection -------------------------------------------------------
|
|
46
|
+
@property
|
|
47
|
+
def material(self) -> str:
|
|
48
|
+
return self.meta.get("material", "") or ""
|
|
49
|
+
|
|
50
|
+
def counts_by_type(self) -> dict:
|
|
51
|
+
return dict(Counter(q.get("type", "mc") for q in self.questions))
|
|
52
|
+
|
|
53
|
+
def counts_by_difficulty(self) -> dict:
|
|
54
|
+
return dict(Counter(q.get("difficulty", "medium") for q in self.questions))
|
|
55
|
+
|
|
56
|
+
def __len__(self) -> int:
|
|
57
|
+
return len(self.questions)
|
|
58
|
+
|
|
59
|
+
# ----- mutation ---------------------------------------------------------
|
|
60
|
+
def add(self, new_questions: List[dict]) -> "Bank":
|
|
61
|
+
self.questions.extend(new_questions)
|
|
62
|
+
return self
|
|
63
|
+
|
|
64
|
+
def grow(self, llm, *, targets: Optional[dict] = None,
|
|
65
|
+
material: Optional[str] = None, **kwargs) -> List[dict]:
|
|
66
|
+
"""Generate the shortfall to reach ``targets`` and append it in place.
|
|
67
|
+
|
|
68
|
+
Grounds on ``material`` if given, else the bank's ``material`` metadata.
|
|
69
|
+
Returns just the newly added questions.
|
|
70
|
+
"""
|
|
71
|
+
mat = material if material is not None else self.material
|
|
72
|
+
if not (mat or "").strip():
|
|
73
|
+
raise ValueError("no material to ground generation — pass material=... "
|
|
74
|
+
"or set a 'material:' key in the bank file")
|
|
75
|
+
new = generate_bank(mat, llm, targets=targets, existing=self.questions, **kwargs)
|
|
76
|
+
self.add(new)
|
|
77
|
+
return new
|
|
78
|
+
|
|
79
|
+
def sample(self, blueprint: Optional[dict] = None, seen_ids=(), rng=None) -> List[dict]:
|
|
80
|
+
return sample_test(self.questions, blueprint=blueprint, seen_ids=seen_ids, rng=rng)
|
|
81
|
+
|
|
82
|
+
# ----- persistence ------------------------------------------------------
|
|
83
|
+
def to_dict(self) -> dict:
|
|
84
|
+
return {**self.meta, "questions": self.questions}
|
|
85
|
+
|
|
86
|
+
def save(self, path: PathLike) -> None:
|
|
87
|
+
with open(path, "w") as f:
|
|
88
|
+
yaml.safe_dump(self.to_dict(), f, sort_keys=False, allow_unicode=True,
|
|
89
|
+
width=100, default_flow_style=False)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Completion certificates — the data + a tamper-evident verification code.
|
|
2
|
+
|
|
3
|
+
quizforge owns the *facts* of a certificate (who, which topic, what level, when)
|
|
4
|
+
and a deterministic verification code derived from them, so any consumer can
|
|
5
|
+
re-derive the code to confirm a certificate wasn't altered. Rendering — PDF,
|
|
6
|
+
HTML, image — is deliberately left to the consumer, where the branding lives, so
|
|
7
|
+
this module stays dependency-light (pydantic only) and clockless (the caller
|
|
8
|
+
supplies ``awarded_on``, keeping generation reproducible).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel, Field
|
|
15
|
+
|
|
16
|
+
PASS = "passed"
|
|
17
|
+
DISTINCTION = "distinction"
|
|
18
|
+
_SEP = "\x1f" # unit separator — unlikely to appear in any field
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Certificate(BaseModel):
|
|
22
|
+
"""An earned completion certificate's facts + its verification code."""
|
|
23
|
+
|
|
24
|
+
learner_id: str = Field(description="Stable learner identity (e.g. email).")
|
|
25
|
+
learner_name: str = Field(description="Display name to print on the certificate.")
|
|
26
|
+
topic_id: str = Field(description="Topic/lesson identifier.")
|
|
27
|
+
topic_title: str = Field(description="Human-readable topic title.")
|
|
28
|
+
score_pct: int = Field(description="Best score as a 0-100 percentage.")
|
|
29
|
+
level: str = Field(description="'passed' or 'distinction'.")
|
|
30
|
+
awarded_on: str = Field(description="Award date as the caller's display string.")
|
|
31
|
+
verification_code: str = Field(description="Deterministic, tamper-evident code.")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def verification_code(*, learner_id: str, topic_id: str, awarded_on: str, level: str,
|
|
35
|
+
score_pct: int, secret: str = "", prefix: str = "QF") -> str:
|
|
36
|
+
"""Derive a short, stable verification code from a certificate's fields.
|
|
37
|
+
|
|
38
|
+
Same fields (+ same ``secret``) always yield the same code; changing any
|
|
39
|
+
field changes the code, so a printed certificate can be checked against the
|
|
40
|
+
record. With a non-empty ``secret`` the code is unforgeable without it.
|
|
41
|
+
"""
|
|
42
|
+
raw = _SEP.join([learner_id.lower().strip(), topic_id, awarded_on, level,
|
|
43
|
+
str(score_pct), secret])
|
|
44
|
+
digest = hashlib.sha256(raw.encode("utf-8")).hexdigest().upper()
|
|
45
|
+
return f"{prefix}-{digest[:4]}-{digest[4:8]}"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def is_eligible(score_pct: int, pass_threshold: float = 0.6) -> bool:
|
|
49
|
+
"""Whether a score earns a certificate at all."""
|
|
50
|
+
return score_pct / 100.0 >= pass_threshold
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def level_for(score_pct: int, distinction_threshold: float = 0.8) -> str:
|
|
54
|
+
"""'distinction' at/above the threshold, else 'passed'."""
|
|
55
|
+
return DISTINCTION if score_pct / 100.0 >= distinction_threshold else PASS
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def make_certificate(*, learner_id: str, learner_name: str, topic_id: str, topic_title: str,
|
|
59
|
+
score_pct: int, awarded_on: str, pass_threshold: float = 0.6,
|
|
60
|
+
distinction_threshold: float = 0.8, secret: str = "",
|
|
61
|
+
prefix: str = "QF") -> Certificate:
|
|
62
|
+
"""Build a :class:`Certificate` for an eligible score.
|
|
63
|
+
|
|
64
|
+
Raises ``ValueError`` if ``score_pct`` is below ``pass_threshold`` — there is
|
|
65
|
+
no certificate for a non-pass.
|
|
66
|
+
"""
|
|
67
|
+
if not is_eligible(score_pct, pass_threshold):
|
|
68
|
+
raise ValueError(f"score {score_pct}% is below the pass threshold "
|
|
69
|
+
f"{round(pass_threshold * 100)}% — no certificate")
|
|
70
|
+
level = level_for(score_pct, distinction_threshold)
|
|
71
|
+
code = verification_code(learner_id=learner_id, topic_id=topic_id, awarded_on=awarded_on,
|
|
72
|
+
level=level, score_pct=score_pct, secret=secret, prefix=prefix)
|
|
73
|
+
return Certificate(learner_id=learner_id, learner_name=learner_name, topic_id=topic_id,
|
|
74
|
+
topic_title=topic_title, score_pct=score_pct, level=level,
|
|
75
|
+
awarded_on=awarded_on, verification_code=code)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def verify(certificate: Certificate, secret: str = "", prefix: str = "QF") -> bool:
|
|
79
|
+
"""Re-derive the code from the certificate's fields and confirm it matches."""
|
|
80
|
+
expected = verification_code(
|
|
81
|
+
learner_id=certificate.learner_id, topic_id=certificate.topic_id,
|
|
82
|
+
awarded_on=certificate.awarded_on, level=certificate.level,
|
|
83
|
+
score_pct=certificate.score_pct, secret=secret, prefix=prefix)
|
|
84
|
+
return expected == certificate.verification_code
|