From e5e0e5f10b1bbb09d762dd539869b67ca99b0ea4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yi=C4=9Fitcan=20=C3=96zt=C3=BCrk?= <318303091+yigitcan-ozturk@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:51:55 +0300 Subject: [PATCH 1/5] feat: add configurable scoring weights --- main.py | 91 +++++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 79 insertions(+), 12 deletions(-) diff --git a/main.py b/main.py index a0358c5..9b552b1 100644 --- a/main.py +++ b/main.py @@ -1,6 +1,7 @@ import argparse import csv import json +import math from pathlib import Path from typing import Any @@ -170,7 +171,56 @@ def validate_currencies(quotes: list[dict[str, Any]]) -> str: return next(iter(currencies)) -def score_quotes(quotes: list[dict[str, Any]]) -> list[dict[str, Any]]: +def validate_weights(weights: dict[str, Any]) -> dict[str, float]: + expected = set(WEIGHTS) + supplied = set(weights) + + missing = sorted(expected - supplied) + if missing: + raise ValueError(f"weights: missing required key(s): {', '.join(missing)}") + + unsupported = sorted(supplied - expected) + if unsupported: + raise ValueError(f"weights: unsupported key(s): {', '.join(unsupported)}") + + validated: dict[str, float] = {} + for key in WEIGHTS: + value = weights[key] + if isinstance(value, bool): + raise ValueError(f"weights: {key} must be numeric") + try: + number = float(value) + except (TypeError, ValueError) as error: + raise ValueError(f"weights: {key} must be numeric") from error + if number < 0 or number > 1: + raise ValueError(f"weights: {key} must be between 0 and 1") + validated[key] = number + + total = sum(validated.values()) + if not math.isclose(total, 1.0, rel_tol=0.0, abs_tol=1e-9): + raise ValueError(f"weights: values must sum to 1.0; got {total:g}") + + return validated + + +def load_weights(path: Path) -> dict[str, float]: + with path.open("r", encoding="utf-8") as file: + weights = json.load(file) + + if not isinstance(weights, dict): + raise ValueError(f"{path}: weights JSON must contain one object") + + try: + return validate_weights(weights) + except ValueError as error: + raise ValueError(f"{path}: {error}") from error + + +def score_quotes( + quotes: list[dict[str, Any]], + weights: dict[str, Any] | None = None, +) -> list[dict[str, Any]]: + effective_weights = validate_weights(WEIGHTS if weights is None else weights) min_price = min(quote["price"] for quote in quotes) min_lead_time = min(quote["lead_time_weeks"] for quote in quotes) max_payment_days = max(quote["payment_days"] for quote in quotes) @@ -178,17 +228,19 @@ def score_quotes(quotes: list[dict[str, Any]]) -> list[dict[str, Any]]: scored_quotes = [] for quote in quotes: - price_score = (min_price / quote["price"]) * WEIGHTS["price"] * 100 + price_score = ( + min_price / quote["price"] + ) * effective_weights["price"] * 100 lead_time_score = ( min_lead_time / quote["lead_time_weeks"] - ) * WEIGHTS["lead_time"] * 100 + ) * effective_weights["lead_time"] * 100 if max_payment_days == 0: - payment_score = WEIGHTS["payment_terms"] * 100 + payment_score = effective_weights["payment_terms"] * 100 else: payment_score = ( quote["payment_days"] / max_payment_days - ) * WEIGHTS["payment_terms"] * 100 + ) * effective_weights["payment_terms"] * 100 total_score = price_score + lead_time_score + payment_score @@ -205,7 +257,9 @@ def score_quotes(quotes: list[dict[str, Any]]) -> list[dict[str, Any]]: def build_result( scored_quotes: list[dict[str, Any]], currency: str, + weights: dict[str, Any] | None = None, ) -> dict[str, Any]: + effective_weights = validate_weights(WEIGHTS if weights is None else weights) winner = scored_quotes[0] cheapest = min(scored_quotes, key=lambda item: item["price"]) fastest = min(scored_quotes, key=lambda item: item["lead_time_weeks"]) @@ -235,7 +289,7 @@ def build_result( "payment_days": best_terms["payment_days"], }, }, - "weights": WEIGHTS, + "weights": effective_weights, } @@ -243,7 +297,12 @@ def money(value: float, currency: str) -> str: return f"{currency} {value:,.2f}" -def print_report(quotes: list[dict[str, Any]], currency: str) -> None: +def print_report( + quotes: list[dict[str, Any]], + currency: str, + weights: dict[str, Any] | None = None, +) -> None: + effective_weights = validate_weights(WEIGHTS if weights is None else weights) print(f"\nRFQDIFF v{VERSION}") print("=" * 86) print( @@ -264,7 +323,7 @@ def print_report(quotes: list[dict[str, Any]], currency: str) -> None: f"{str(quote['score']) + '/100':>12}" ) - payload = build_result(quotes, currency) + payload = build_result(quotes, currency, effective_weights) summary = payload["decision_summary"] winner = summary["recommended_supplier"] cheapest = summary["lowest_price"] @@ -289,7 +348,9 @@ def print_report(quotes: list[dict[str, Any]], currency: str) -> None: print("\nScoring weights") print( - "Price 50% | Lead time 30% | Payment terms 20%\n" + f"Price {effective_weights['price']:.0%} | " + f"Lead time {effective_weights['lead_time']:.0%} | " + f"Payment terms {effective_weights['payment_terms']:.0%}\n" "Lower price and lead time score higher; longer payment terms score higher." ) @@ -311,6 +372,11 @@ def parse_args() -> argparse.Namespace: type=Path, help="Quotation files (.json, .csv or .xlsx). Tabular files may contain multiple suppliers.", ) + parser.add_argument( + "--weights", + type=Path, + help="JSON file with price, lead_time and payment_terms weights summing to 1.0.", + ) parser.add_argument( "--json", action="store_true", @@ -332,8 +398,9 @@ def main() -> None: if len(quotes) < 2: raise ValueError("rfqdiff needs at least two supplier quotations.") currency = validate_currencies(quotes) - scored_quotes = score_quotes(quotes) - payload = build_result(scored_quotes, currency) + weights = load_weights(args.weights) if args.weights else validate_weights(WEIGHTS) + scored_quotes = score_quotes(quotes, weights) + payload = build_result(scored_quotes, currency, weights) except (OSError, json.JSONDecodeError, ValueError) as error: raise SystemExit(f"Error: {error}") from error @@ -343,7 +410,7 @@ def main() -> None: if args.json: print(json.dumps(payload, indent=2, ensure_ascii=False)) else: - print_report(scored_quotes, currency) + print_report(scored_quotes, currency, weights) if __name__ == "__main__": From d011b1d0e8e9e00ad1099e850cd8d2d8699a3832 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yi=C4=9Fitcan=20=C3=96zt=C3=BCrk?= <318303091+yigitcan-ozturk@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:52:29 +0300 Subject: [PATCH 2/5] test: cover configurable scoring weights --- tests/test_rfqdiff.py | 76 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/tests/test_rfqdiff.py b/tests/test_rfqdiff.py index 6262eea..8e92fe7 100644 --- a/tests/test_rfqdiff.py +++ b/tests/test_rfqdiff.py @@ -131,6 +131,46 @@ def test_validate_currencies_rejects_mixed_currencies(self) -> None: rfqdiff.validate_currencies(quotes) +class WeightValidationTests(unittest.TestCase): + def test_validate_weights_accepts_explicit_profile(self) -> None: + weights = { + "price": 0.60, + "lead_time": 0.25, + "payment_terms": 0.15, + } + self.assertEqual(rfqdiff.validate_weights(weights), weights) + + def test_validate_weights_rejects_incomplete_profile(self) -> None: + with self.assertRaisesRegex(ValueError, "payment_terms"): + rfqdiff.validate_weights({"price": 0.7, "lead_time": 0.3}) + + def test_validate_weights_rejects_unsupported_criteria(self) -> None: + with self.assertRaisesRegex(ValueError, "unsupported"): + rfqdiff.validate_weights( + { + "price": 0.5, + "lead_time": 0.3, + "payment_terms": 0.1, + "relationship": 0.1, + } + ) + + def test_validate_weights_rejects_total_other_than_one(self) -> None: + with self.assertRaisesRegex(ValueError, "sum to 1.0"): + rfqdiff.validate_weights( + {"price": 0.5, "lead_time": 0.3, "payment_terms": 0.3} + ) + + def test_load_weights_reads_json_profile(self) -> None: + weights = {"price": 0.4, "lead_time": 0.4, "payment_terms": 0.2} + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "weights.json" + path.write_text(json.dumps(weights), encoding="utf-8") + loaded = rfqdiff.load_weights(path) + + self.assertEqual(loaded, weights) + + class ScoringTests(unittest.TestCase): def setUp(self) -> None: self.quotes = [ @@ -179,6 +219,34 @@ def test_score_quotes_handles_zero_payment_days_for_all_suppliers(self) -> None: self.assertEqual(scored[0]["name"], "Supplier A") self.assertEqual(scored[0]["score"], 100.0) + def test_custom_weights_can_change_recommendation(self) -> None: + quotes = [ + { + "name": "Supplier A", + "currency": "EUR", + "price": 100, + "lead_time_weeks": 4, + "payment_days": 0, + }, + { + "name": "Supplier B", + "currency": "EUR", + "price": 150, + "lead_time_weeks": 8, + "payment_days": 60, + }, + ] + default_scored = rfqdiff.score_quotes(quotes) + custom_weights = { + "price": 0.10, + "lead_time": 0.10, + "payment_terms": 0.80, + } + custom_scored = rfqdiff.score_quotes(quotes, custom_weights) + + self.assertEqual(default_scored[0]["name"], "Supplier A") + self.assertEqual(custom_scored[0]["name"], "Supplier B") + def test_build_result_is_pipeline_ready(self) -> None: scored = rfqdiff.score_quotes(self.quotes) payload = rfqdiff.build_result(scored, "EUR") @@ -187,8 +255,16 @@ def test_build_result_is_pipeline_ready(self) -> None: self.assertEqual(payload["version"], "0.2") self.assertEqual(payload["recommended_supplier"], "Supplier A") self.assertEqual(payload["suppliers"][0]["score"], 97.1) + self.assertEqual(payload["weights"], rfqdiff.WEIGHTS) json.dumps(payload) + def test_build_result_records_custom_weights(self) -> None: + weights = {"price": 0.4, "lead_time": 0.4, "payment_terms": 0.2} + scored = rfqdiff.score_quotes(self.quotes, weights) + payload = rfqdiff.build_result(scored, "EUR", weights) + + self.assertEqual(payload["weights"], weights) + def test_write_json_round_trip(self) -> None: scored = rfqdiff.score_quotes(self.quotes) payload = rfqdiff.build_result(scored, "EUR") From 61b740ffaaf7d2969f5134ef76135b0e258b5bba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yi=C4=9Fitcan=20=C3=96zt=C3=BCrk?= <318303091+yigitcan-ozturk@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:52:42 +0300 Subject: [PATCH 3/5] api: expose scoring weight helpers --- rfqdiff/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/rfqdiff/__init__.py b/rfqdiff/__init__.py index 3fe26f3..853d53f 100644 --- a/rfqdiff/__init__.py +++ b/rfqdiff/__init__.py @@ -6,9 +6,11 @@ build_result, load_quote, load_quotes, + load_weights, score_quotes, validate_currencies, validate_quote, + validate_weights, ) __version__ = "0.2.0" @@ -20,7 +22,9 @@ "build_result", "load_quote", "load_quotes", + "load_weights", "score_quotes", "validate_currencies", "validate_quote", + "validate_weights", ] From 6dd31d64b21dc155ee03dfe83b47134d46d34c94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yi=C4=9Fitcan=20=C3=96zt=C3=BCrk?= <318303091+yigitcan-ozturk@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:52:47 +0300 Subject: [PATCH 4/5] samples: add configurable scoring weights example --- samples/weights-balanced.json | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 samples/weights-balanced.json diff --git a/samples/weights-balanced.json b/samples/weights-balanced.json new file mode 100644 index 0000000..943702f --- /dev/null +++ b/samples/weights-balanced.json @@ -0,0 +1,5 @@ +{ + "price": 0.40, + "lead_time": 0.40, + "payment_terms": 0.20 +} From f5da030e2eadbfe239f12e68b5afad7a3aaf41d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yi=C4=9Fitcan=20=C3=96zt=C3=BCrk?= <318303091+yigitcan-ozturk@users.noreply.github.com> Date: Fri, 28 Aug 2026 10:53:16 +0300 Subject: [PATCH 5/5] docs: document configurable scoring profiles --- README.md | 68 +++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 53 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index c554261..4a4bf50 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ It does: - compare price, lead time and payment terms; - import quotations from JSON, CSV and Excel (`.xlsx`); +- use explicit default or user-supplied commercial scoring weights; - produce deterministic supplier scores; - return machine-readable JSON for downstream decision systems; - preserve upstream normalization metadata when present in JSON inputs. @@ -34,7 +35,7 @@ It intentionally does **not**: - determine technical compliance; - score operational supplier risk; - make contractual acceptance decisions; -- hide unsupported criteria inside an opaque composite score. +- accept hidden or unsupported scoring criteria. Those responsibilities remain separated across the engineering procurement toolchain. @@ -90,6 +91,39 @@ rfqdiff samples/quotations.csv --output rfq.json JSON, CSV and XLSX inputs can also be combined in one command as long as every quotation uses the same currency. +## Configurable scoring weights + +The default commercial model is: + +| Criterion | Weight | Better score | +| --- | ---: | --- | +| Price | 50% | Lower | +| Lead time | 30% | Lower | +| Payment terms | 20% | Longer | + +For a different procurement strategy, pass a JSON weights profile with `--weights`: + +```json +{ + "price": 0.40, + "lead_time": 0.40, + "payment_terms": 0.20 +} +``` + +```bash +rfqdiff samples/quotations.csv --weights samples/weights-balanced.json +``` + +Weight profiles are deliberately strict: + +- all three supported criteria must be present; +- no additional criteria are accepted; +- each value must be between `0` and `1`; +- values must sum to exactly `1.0` within floating-point tolerance. + +The effective profile is returned in the output payload under `weights`, keeping each recommendation auditable. + The JSON contract contains: ```json @@ -103,7 +137,12 @@ The JSON contract contains: "name": "Supplier A", "score": 97.1 } - ] + ], + "weights": { + "price": 0.5, + "lead_time": 0.3, + "payment_terms": 0.2 + } } ``` @@ -120,13 +159,21 @@ scored = rfqdiff.score_quotes([ ]) ``` -Tabular files can be loaded through the public API as well: +Custom weights can be supplied through the public API: + +```python +weights = {"price": 0.4, "lead_time": 0.4, "payment_terms": 0.2} +scored = rfqdiff.score_quotes(quotes, weights) +``` + +Tabular files and weight profiles can be loaded through the public API as well: ```python from pathlib import Path import rfqdiff quotes = rfqdiff.load_quotes(Path("quotations.xlsx")) +weights = rfqdiff.load_weights(Path("weights.json")) ``` ## Quotation format @@ -163,16 +210,6 @@ Supplier B,EUR,79400,14,0 All quotations must use the same currency. For mixed currencies, normalize them first with `currency-normalizer`, then pass the normalized values to `rfqdiff`. -## Scoring model - -| Criterion | Weight | Better score | -| --- | ---: | --- | -| Price | 50% | Lower | -| Lead time | 30% | Lower | -| Payment terms | 20% | Longer | - -The score is intentionally explicit so the recommendation can be reviewed rather than treated as a black box. - ## Pipeline role ```text @@ -193,6 +230,8 @@ GitHub Actions validates: - unit tests on Python 3.11, 3.12 and 3.13; - JSON, CSV and Excel quotation loading; +- default and configurable scoring profiles; +- rejection of incomplete or unsupported weight profiles; - wheel and source-distribution builds; - package metadata with `twine check`; - installation of the built wheel and runtime dependencies; @@ -219,14 +258,13 @@ GitHub Actions validates: ## Roadmap -- Configurable commercial scoring weights - Exportable comparison reports - Richer quotation provenance - Richer decision explanations ## Status -Early-stage project, currently at **v0.2**. The current line provides a stable JSON integration contract, an installable Python package and console CLI, plus JSON/CSV/XLSX quotation ingestion. +Early-stage project, currently at **v0.2**. The current line provides a stable JSON integration contract, an installable Python package and console CLI, JSON/CSV/XLSX quotation ingestion, and auditable configurable commercial scoring weights. ## License