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.
@@ -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
@@ -0,0 +1,11 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .eggs/
5
+ build/
6
+ dist/
7
+ .pytest_cache/
8
+ .venv/
9
+ venv/
10
+ .env
11
+ *.xlsx
@@ -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.
@@ -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