From 5ed70d518593e18c8a411facce5865b8a3c9b871 Mon Sep 17 00:00:00 2001 From: manshusainishab Date: Sat, 11 Jul 2026 16:13:11 +0530 Subject: [PATCH 1/4] feat(module-b): add Stage 2 evaluation harness --- scripts/evaluate_noise_filter.py | 293 +++++++++++++++++++++++++++++++ 1 file changed, 293 insertions(+) create mode 100644 scripts/evaluate_noise_filter.py diff --git a/scripts/evaluate_noise_filter.py b/scripts/evaluate_noise_filter.py new file mode 100644 index 000000000..96d230ac2 --- /dev/null +++ b/scripts/evaluate_noise_filter.py @@ -0,0 +1,293 @@ +"""Module B evaluation harness — Stage 2 prompt fitness function. + +Runs the labeled dataset through the real Module B gate (Stage 1 regex -> +Stage 1.5 sanitize -> Stage 2 LLM classifier) and scores predictions against +the gold labels. This is the primary metric used to iterate the Stage 2 prompt +toward the mid-evaluation target. + +Recall-first framing: the headline numbers are KNOWLEDGE recall and the +KNOWLEDGE->NOISE leakage count (gold-KNOWLEDGE chunks wrongly dropped -- the +"security lost forever" failure). NOISE rows are dropped before Module C, so +those are the costly mistakes. + +This hits the real LLM API. Cost per full 100-record run is well under $0.01 +with the lite model; monitor exact spend on your provider dashboard. + +Usage: + python scripts/evaluate_noise_filter.py # full dataset + python scripts/evaluate_noise_filter.py --limit 10 # smoke test + python scripts/evaluate_noise_filter.py --threshold 0.7 --out results.csv +""" + +from __future__ import annotations + +import argparse +import csv +import dataclasses +import json +import logging +import os +import sys +from collections import Counter +from pathlib import Path +from typing import Optional + +# Allow running directly as `python scripts/evaluate_noise_filter.py`. +_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if _REPO_ROOT not in sys.path: + sys.path.insert(0, _REPO_ROOT) + +try: + from dotenv import load_dotenv + + load_dotenv() +except ImportError: + pass + +from application.utils.noise_filter.config_loader import load_config +from application.utils.noise_filter.llm_classifier import LLMClassifier +from application.utils.noise_filter.regex_filter import RegexFilter +from application.utils.noise_filter.sanitize import sanitize_text +from application.utils.noise_filter.schemas import ChangeRecord + +logger = logging.getLogger(__name__) + +LABELS = ["KNOWLEDGE", "NOISE", "UNCERTAIN"] +DEFAULT_DATASET = "application/tests/noise_filter/fixtures/labeled_data.json" + + +# --- prediction ---------------------------------------------------------- + + +@dataclasses.dataclass +class Prediction: + chunk_id: str + gold: str + predicted: str + confidence: float + stage: str # "regex" | "llm" + reasoning: str + + +def _apply_threshold(label: str, confidence: float, threshold: float) -> str: + """A KNOWLEDGE verdict below the threshold is treated as UNCERTAIN (HITL), + matching the pipeline's enqueue rule. NOISE/UNCERTAIN pass through.""" + if label == "KNOWLEDGE" and confidence < threshold: + return "UNCERTAIN" + return label + + +def _sanitized(record: ChangeRecord) -> ChangeRecord: + """Stage 1.5: return a copy with sanitized text (no-op on clean input).""" + try: + clean = sanitize_text(record.text) + except ValueError: + return record # sanitization emptied the text; keep original for the LLM + return record.model_copy(update={"text": clean}) + + +def run_eval( + records: list[tuple[ChangeRecord, str]], + config, + threshold: float, + use_regex: bool, +) -> tuple[list[Prediction], int]: + """Run the gate over (record, gold) pairs. Returns predictions + LLM call count.""" + regex = RegexFilter() + predictions: list[Prediction] = [] + + survivors: list[ChangeRecord] = [] + survivor_gold: list[str] = [] + for rec, gold in records: + if use_regex: + is_noise, reason = regex.is_noise_record(rec) + if is_noise: + predictions.append( + Prediction(rec.chunk_id, gold, "NOISE", 1.0, "regex", reason) + ) + continue + survivors.append(_sanitized(rec)) + survivor_gold.append(gold) + + classifier = LLMClassifier(config) + results = classifier.classify_batch(survivors) + n_calls = (len(survivors) + config.batch_size - 1) // config.batch_size + + for rec, gold, res in zip(survivors, survivor_gold, results): + predicted = _apply_threshold(res.label, res.confidence, threshold) + predictions.append( + Prediction( + rec.chunk_id, + gold, + predicted, + res.confidence, + "llm", + res.reasoning or "", + ) + ) + return predictions, n_calls + + +# --- metrics ------------------------------------------------------------- + + +def _confusion(preds: list[Prediction]) -> dict[tuple[str, str], int]: + return Counter((p.gold, p.predicted) for p in preds) + + +def _prf(cm: dict[tuple[str, str], int], label: str) -> tuple[float, float, float]: + tp = cm.get((label, label), 0) + fp = sum(cm.get((g, label), 0) for g in LABELS if g != label) + fn = sum(cm.get((label, p), 0) for p in LABELS if p != label) + precision = tp / (tp + fp) if (tp + fp) else 0.0 + recall = tp / (tp + fn) if (tp + fn) else 0.0 + f1 = 2 * precision * recall / (precision + recall) if (precision + recall) else 0.0 + return precision, recall, f1 + + +def _print_report( + preds: list[Prediction], threshold: float, n_calls: int, use_regex: bool +) -> None: + total = len(preds) + correct = sum(1 for p in preds if p.gold == p.predicted) + cm = _confusion(preds) + + print("\n" + "=" * 64) + print("MODULE B — EVALUATION REPORT") + print("=" * 64) + print(f"records: {total}") + print(f"threshold: {threshold} (KNOWLEDGE below this -> UNCERTAIN)") + print(f"regex stage: {'on' if use_regex else 'off'}") + print(f"LLM batches: {n_calls}") + print(f"accuracy: {correct}/{total} = {correct / total:.3f}") + + # Recall-first headline. + k_recall = _prf(cm, "KNOWLEDGE")[1] + leak = cm.get(("KNOWLEDGE", "NOISE"), 0) + gold_k = sum(cm.get(("KNOWLEDGE", p), 0) for p in LABELS) + print("\n--- recall-first headline ---") + print(f"KNOWLEDGE recall: {k_recall:.3f}") + print( + f"KNOWLEDGE -> NOISE leakage: {leak}/{gold_k} " + f"(security chunks wrongly dropped)" + ) + + print("\n--- 3x3 confusion matrix (rows=gold, cols=pred) ---") + header = "gold\\pred".ljust(12) + "".join(lbl[:9].rjust(10) for lbl in LABELS) + print(header) + for g in LABELS: + row = g[:11].ljust(12) + "".join( + str(cm.get((g, p), 0)).rjust(10) for p in LABELS + ) + print(row) + + print("\n--- per-class precision / recall / f1 ---") + for lbl in LABELS: + p, r, f = _prf(cm, lbl) + print(f"{lbl.ljust(11)} P={p:.3f} R={r:.3f} F1={f:.3f}") + + llm_preds = [p for p in preds if p.stage == "llm"] + if llm_preds: + mean_conf = sum(p.confidence for p in llm_preds) / len(llm_preds) + llm_correct = sum(1 for p in llm_preds if p.gold == p.predicted) + print("\n--- Stage 2 (LLM) only ---") + print( + f"reached LLM: {len(llm_preds)} (regex dropped {total - len(llm_preds)})" + ) + print( + f"LLM accuracy: {llm_correct}/{len(llm_preds)} = {llm_correct / len(llm_preds):.3f}" + ) + print(f"mean confidence: {mean_conf:.3f}") + print("=" * 64 + "\n") + + +def _write_csv(preds: list[Prediction], out_path: Path) -> None: + with out_path.open("w", newline="", encoding="utf-8") as fh: + writer = csv.writer(fh) + writer.writerow( + [ + "chunk_id", + "gold", + "predicted", + "confidence", + "stage", + "correct", + "reasoning", + ] + ) + for p in preds: + writer.writerow( + [ + p.chunk_id, + p.gold, + p.predicted, + f"{p.confidence:.3f}", + p.stage, + int(p.gold == p.predicted), + p.reasoning, + ] + ) + print(f"wrote per-record results -> {out_path}") + + +# --- cli ----------------------------------------------------------------- + + +def _load_dataset(path: Path, limit: int) -> list[tuple[ChangeRecord, str]]: + raw = json.loads(path.read_text(encoding="utf-8")) + if limit > 0: + raw = raw[:limit] + out: list[tuple[ChangeRecord, str]] = [] + for row in raw: + gold = row.get("label") + if gold not in LABELS: + logger.warning("skipping record with missing/invalid label: %s", gold) + continue + out.append((ChangeRecord.model_validate(row), gold)) + return out + + +def main(argv: Optional[list[str]] = None) -> None: + parser = argparse.ArgumentParser( + description="Evaluate Module B against labeled data." + ) + parser.add_argument("--dataset", default=DEFAULT_DATASET) + parser.add_argument("--limit", type=int, default=0, help="0 = all records") + parser.add_argument( + "--threshold", + type=float, + default=None, + help="confidence cutoff; default from config", + ) + parser.add_argument("--batch-size", type=int, default=None) + parser.add_argument( + "--model", default=None, help="override CRE_NOISE_FILTER_LLM_MODEL" + ) + parser.add_argument("--no-regex", action="store_true", help="skip Stage 1 regex") + parser.add_argument("--out", default="noise_filter_eval_results.csv") + args = parser.parse_args(argv) + + config = load_config() + overrides = {} + if args.batch_size is not None: + overrides["batch_size"] = args.batch_size + if args.model is not None: + overrides["llm_model"] = args.model + if overrides: + config = dataclasses.replace(config, **overrides) + threshold = ( + args.threshold if args.threshold is not None else config.confidence_threshold + ) + + records = _load_dataset(Path(args.dataset), args.limit) + print(f"loaded {len(records)} labeled records from {args.dataset}") + print(f"model: {config.llm_model} batch_size: {config.batch_size}") + + preds, n_calls = run_eval(records, config, threshold, use_regex=not args.no_regex) + _print_report(preds, threshold, n_calls, use_regex=not args.no_regex) + _write_csv(preds, Path(args.out)) + + +if __name__ == "__main__": + logging.basicConfig(level=logging.WARNING) + main() From 324e36f825f730d44e3c4aaf77f1d2c56fe8a07b Mon Sep 17 00:00:00 2001 From: manshusainishab Date: Sat, 11 Jul 2026 16:13:11 +0530 Subject: [PATCH 2/4] chore(module-b): align gold labels to recall-first (worked examples + heading-only) --- .../noise_filter/fixtures/labeled_data.json | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/application/tests/noise_filter/fixtures/labeled_data.json b/application/tests/noise_filter/fixtures/labeled_data.json index 77f2c3de3..0a8875e12 100644 --- a/application/tests/noise_filter/fixtures/labeled_data.json +++ b/application/tests/noise_filter/fixtures/labeled_data.json @@ -27,8 +27,8 @@ "id": "document/4-Web_Application_Security_Testing/02-Configuration_and_Deployment_Management_Testing/10-Test_for_Subdomain_Takeover.md", "path": "document/4-Web_Application_Security_Testing/02-Configuration_and_Deployment_Management_Testing/10-Test_for_Subdomain_Takeover.md" }, - "label": "KNOWLEDGE", - "label_rationale": "", + "label": "NOISE", + "label_rationale": "Heading/title-only chunk with no methodology body [heading-only tiny-chunk -> NOISE, consistent rule 2026-07-03; was KNOWLEDGE]", "labeled_by": "manshusainishab", "labeled_at": "2026-05-29" }, @@ -96,8 +96,8 @@ "id": "document/4-Web_Application_Security_Testing/02-Configuration_and_Deployment_Management_Testing/10-Test_for_Subdomain_Takeover.md", "path": "document/4-Web_Application_Security_Testing/02-Configuration_and_Deployment_Management_Testing/10-Test_for_Subdomain_Takeover.md" }, - "label": "UNCERTAIN", - "label_rationale": "borderline: structured as an example but is the primary methodology unit for testing GitHub-specific subdomain takeover. Prompt iteration must clarify whether canonical WSTG worked examples count as KNOWLEDGE or as \"additional example\" NOISE.", + "label": "KNOWLEDGE", + "label_rationale": "Worked attack-scenario walkthrough (GitHub subdomain takeover) -- security content [recall-first correction 2026-07-03; was UNCERTAIN]", "labeled_by": "manshusainishab", "labeled_at": "2026-05-29" }, @@ -131,8 +131,8 @@ "id": "document/4-Web_Application_Security_Testing/02-Configuration_and_Deployment_Management_Testing/10-Test_for_Subdomain_Takeover.md", "path": "document/4-Web_Application_Security_Testing/02-Configuration_and_Deployment_Management_Testing/10-Test_for_Subdomain_Takeover.md" }, - "label": "UNCERTAIN", - "label_rationale": "same case as chunk 3 — scenario walkthrough under Summary. Linked for prompt-iteration consistency.", + "label": "KNOWLEDGE", + "label_rationale": "Worked attack-scenario walkthrough (expired-domain subdomain takeover) [recall-first correction 2026-07-03; was UNCERTAIN]", "labeled_by": "manshusainishab", "labeled_at": "2026-05-29" }, @@ -548,8 +548,8 @@ "id": "document/4-Web_Application_Security_Testing/04-Authentication_Testing/11-Testing_Multi-Factor_Authentication.md", "path": "document/4-Web_Application_Security_Testing/04-Authentication_Testing/11-Testing_Multi-Factor_Authentication.md" }, - "label": "KNOWLEDGE", - "label_rationale": "", + "label": "NOISE", + "label_rationale": "Heading/title-only chunk with no methodology body [heading-only tiny-chunk -> NOISE, consistent rule 2026-07-03; was KNOWLEDGE]", "labeled_by": "manshusainishab", "labeled_at": "2026-05-29" }, @@ -1748,8 +1748,8 @@ "id": "cheatsheets/DOM_based_XSS_Prevention_Cheat_Sheet.md", "path": "cheatsheets/DOM_based_XSS_Prevention_Cheat_Sheet.md" }, - "label": "KNOWLEDGE", - "label_rationale": "", + "label": "NOISE", + "label_rationale": "Heading/title-only chunk with no methodology body [heading-only tiny-chunk -> NOISE, consistent rule 2026-07-03; was KNOWLEDGE]", "labeled_by": "manshusainishab", "labeled_at": "2026-05-29" }, @@ -1851,8 +1851,8 @@ "id": "cheatsheets/DOM_based_XSS_Prevention_Cheat_Sheet.md", "path": "cheatsheets/DOM_based_XSS_Prevention_Cheat_Sheet.md" }, - "label": "KNOWLEDGE", - "label_rationale": "", + "label": "NOISE", + "label_rationale": "Heading/title-only chunk with no methodology body [heading-only tiny-chunk -> NOISE, consistent rule 2026-07-03; was KNOWLEDGE]", "labeled_by": "manshusainishab", "labeled_at": "2026-05-29" }, @@ -1887,8 +1887,8 @@ "id": "cheatsheets/DOM_based_XSS_Prevention_Cheat_Sheet.md", "path": "cheatsheets/DOM_based_XSS_Prevention_Cheat_Sheet.md" }, - "label": "UNCERTAIN", - "label_rationale": "DOM sinks (innerHTML/outerHTML) framed as examples but functionally an attack-vector catalog. Same pattern as chunks 3-4; needs prompt clarification on canonical-primitive vs illustrative-example.", + "label": "KNOWLEDGE", + "label_rationale": "Catalog of dangerous DOM sinks (innerHTML/outerHTML) = XSS attack vectors [recall-first correction 2026-07-03; was UNCERTAIN]", "labeled_by": "manshusainishab", "labeled_at": "2026-05-29" }, @@ -1923,8 +1923,8 @@ "id": "cheatsheets/DOM_based_XSS_Prevention_Cheat_Sheet.md", "path": "cheatsheets/DOM_based_XSS_Prevention_Cheat_Sheet.md" }, - "label": "UNCERTAIN", - "label_rationale": "same case as chunk 55 — DOM sinks framed as examples. Linked for prompt-iteration consistency.", + "label": "KNOWLEDGE", + "label_rationale": "Catalog of dangerous DOM methods (document.write/writeln) = XSS attack vectors [recall-first correction 2026-07-03; was UNCERTAIN]", "labeled_by": "manshusainishab", "labeled_at": "2026-05-29" }, @@ -3068,8 +3068,8 @@ "id": "Website/content/en/user-day/owasp-samm-to-the-rescue.md", "path": "Website/content/en/user-day/owasp-samm-to-the-rescue.md" }, - "label": "UNCERTAIN", - "label_rationale": "talk abstract with substantive attack content (AWS CodeBuild StartBuild pipeline poisoning) wrapped in event-page framing. Prompt iteration must clarify whether talk teasers with named attack techniques count as KNOWLEDGE.", + "label": "KNOWLEDGE", + "label_rationale": "Talk abstract naming a concrete technique (CodeBuild pipeline poisoning) [recall-first correction 2026-07-03; was UNCERTAIN]", "labeled_by": "manshusainishab", "labeled_at": "2026-05-29" }, From 548daadab5b596c8d0e9a1f3543dedcca12b478c Mon Sep 17 00:00:00 2001 From: manshusainishab Date: Sat, 11 Jul 2026 16:13:11 +0530 Subject: [PATCH 3/4] feat(module-b): sharpen Stage 2 prompt for organizational meta-content --- .../tests/noise_filter/llm_classifier_test.py | 2 +- application/utils/noise_filter/prompts.py | 39 +++++++++++++++++-- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/application/tests/noise_filter/llm_classifier_test.py b/application/tests/noise_filter/llm_classifier_test.py index f1b7fee45..34b1545a8 100644 --- a/application/tests/noise_filter/llm_classifier_test.py +++ b/application/tests/noise_filter/llm_classifier_test.py @@ -83,7 +83,7 @@ def test_all_few_shot_examples_embedded(self) -> None: def test_few_shot_label_distribution(self) -> None: labels = [e["label"] for e in FEW_SHOT_EXAMPLES] self.assertEqual(labels.count("KNOWLEDGE"), 5) - self.assertEqual(labels.count("NOISE"), 3) + self.assertEqual(labels.count("NOISE"), 5) self.assertEqual(labels.count("UNCERTAIN"), 2) diff --git a/application/utils/noise_filter/prompts.py b/application/utils/noise_filter/prompts.py index a0e9e96c0..a3cf73fc7 100644 --- a/application/utils/noise_filter/prompts.py +++ b/application/utils/noise_filter/prompts.py @@ -27,9 +27,15 @@ configurations, EVEN clarifications, typo fixes, expanded examples, \ restatements of well-known material, added reference links, or restructured \ security sections. When in doubt, choose KNOWLEDGE. -- NOISE: ONLY chunks with no security signal at all -- sponsorship pages, \ -meeting notes, CI/build configuration, release tags, website layout, \ -contributor onboarding, project governance. +- NOISE: ONLY chunks with no security-knowledge content at all -- sponsorship \ +pages, meeting notes, CI/build configuration, release tags, website layout, \ +contributor onboarding, project governance. This includes content *about* a \ +security project rather than security knowledge itself: version/release \ +announcements, licenses, translation notes, citation/reference-format \ +instructions, tables of contents, project titles/banners, and event/talk \ +teasers -- even when they name a security standard or project (e.g. "ASVS", \ +"SAMM"). Naming a security project is not the same as containing security \ +knowledge. - UNCERTAIN: only when genuinely 50/50 even after applying the bias above. \ This label is rare. @@ -133,6 +139,33 @@ "confidence": 0.95, "reasoning": "CI release-tagging workflow; build infrastructure, not security knowledge.", }, + { + "heading_path": [ + "OWASP Application Security Verification Standard", + "Latest Stable Version", + ], + "text": ( + "The latest stable version is 5.0.0 (dated May 2025), available as a " + "PDF and in the GitHub repository. See the release notes for changes " + "since 4.0." + ), + "label": "NOISE", + "confidence": 0.9, + "reasoning": "Version/release announcement about a security standard -- content about the project, not security knowledge.", + }, + { + "heading_path": [ + "OWASP Application Security Verification Standard", + "License", + ], + "text": ( + "The entire project content is under the Creative Commons " + "Attribution-Share Alike v4.0 license." + ), + "label": "NOISE", + "confidence": 0.95, + "reasoning": "Licensing information about a security project -- organizational, no security methodology.", + }, { "heading_path": ["About"], "text": ( From ba34b8bf7d019b4f92cd2df53ec4525be37f1fed Mon Sep 17 00:00:00 2001 From: manshusainishab Date: Sat, 11 Jul 2026 16:32:31 +0530 Subject: [PATCH 4/4] fix(module-b): guard eval report against empty dataset --- scripts/evaluate_noise_filter.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/scripts/evaluate_noise_filter.py b/scripts/evaluate_noise_filter.py index 96d230ac2..2944d0de0 100644 --- a/scripts/evaluate_noise_filter.py +++ b/scripts/evaluate_noise_filter.py @@ -149,12 +149,17 @@ def _print_report( preds: list[Prediction], threshold: float, n_calls: int, use_regex: bool ) -> None: total = len(preds) - correct = sum(1 for p in preds if p.gold == p.predicted) - cm = _confusion(preds) print("\n" + "=" * 64) print("MODULE B — EVALUATION REPORT") print("=" * 64) + if total == 0: + print("no records to score (empty dataset or all labels invalid).") + print("=" * 64 + "\n") + return + + correct = sum(1 for p in preds if p.gold == p.predicted) + cm = _confusion(preds) print(f"records: {total}") print(f"threshold: {threshold} (KNOWLEDGE below this -> UNCERTAIN)") print(f"regex stage: {'on' if use_regex else 'off'}")