diff --git a/.gitignore b/.gitignore index 83972fa..b7102fc 100644 --- a/.gitignore +++ b/.gitignore @@ -216,3 +216,6 @@ __marimo__/ # Streamlit .streamlit/secrets.toml + +# NICE Data +data/nice/ \ No newline at end of file diff --git a/data/evals/ng28_retrieval.jsonl b/data/evals/ng28_retrieval.jsonl new file mode 100644 index 0000000..14d0f9e --- /dev/null +++ b/data/evals/ng28_retrieval.jsonl @@ -0,0 +1,8 @@ +{"question": "What is the recommended frequency for measuring HbA1c levels in adults with type 2 diabetes once both the HbA1c level and blood glucose lowering therapy are stable?", "question_type": "verbatim", "answer": "6 months", "supporting_spans": ["6 months once the HbA1c level and blood glucose lowering therapy are stable."], "is_answerable": true, "notes": "", "_source_file": "data/nice/ng28/04_Blood-glucose-management.txt", "_eval_type": "retrieval", "_model": "google/gemma-4-31b-it:free", "_generated_at": "2026-06-19T00:04:32.889147+00:00"} +{"question": "What specific standardisation method should be used when measuring HbA1c levels in adults with type 2 diabetes?", "question_type": "verbatim", "answer": "International Federation of Clinical Chemistry (IFCC) standardisation", "supporting_spans": ["Measure HbA1c using methods calibrated according to International Federation of Clinical Chemistry (IFCC) standardisation."], "is_answerable": true, "notes": "", "_source_file": "data/nice/ng28/04_Blood-glucose-management.txt", "_eval_type": "retrieval", "_model": "google/gemma-4-31b-it:free", "_generated_at": "2026-06-19T00:04:32.889147+00:00"} +{"question": "For an adult with type 2 diabetes whose diabetes is managed solely through healthy living and diet, what is the target HbA1c level they should aim for?", "question_type": "paragraph", "answer": "They should aim for an HbA1c level of 48 mmol/mol (6.5%).", "supporting_spans": ["For adults whose type 2 diabetes is managed either by healthy living and diet, or healthy living and diet combined with an initial medication regimen that is not associated with hypoglycaemia (see the section on initial medicines ), support them to aim for an HbA1c level of 48 mmol/mol (6.5%)."], "is_answerable": true, "notes": "", "_source_file": "data/nice/ng28/04_Blood-glucose-management.txt", "_eval_type": "retrieval", "_model": "google/gemma-4-31b-it:free", "_generated_at": "2026-06-19T00:04:32.889147+00:00"} +{"question": "Under what circumstances should a clinician consider short-term self-monitoring of capillary blood glucose levels for an adult with type 2 diabetes?", "question_type": "paragraph", "answer": "Short-term self-monitoring should be considered when the patient is starting treatment with intravenous or oral corticosteroids, or to confirm suspected hypoglycaemia.", "supporting_spans": ["Consider short-term self-monitoring of capillary blood glucose levels in adults with type 2 diabetes, reviewing treatment as necessary:\n- when starting treatment with oral or intravenous corticosteroids or\n- to confirm suspected hypoglycaemia."], "is_answerable": true, "notes": "", "_source_file": "data/nice/ng28/04_Blood-glucose-management.txt", "_eval_type": "retrieval", "_model": "google/gemma-4-31b-it:free", "_generated_at": "2026-06-19T00:04:32.889147+00:00"} +{"question": "For an adult with type 2 diabetes on multiple daily insulin injections, what are the specific criteria for offering intermittently scanned continuous glucose monitoring (isCGM), and why is capillary blood glucose measurement still necessary for these patients?", "question_type": "multi_paragraph", "answer": "isCGM should be offered if the patient has recurrent or severe hypoglycaemia, impaired hypoglycaemia awareness, a condition or disability preventing capillary self-monitoring but allowing isCGM use, or if they would otherwise need to self-measure at least 8 times a day. Capillary measurements remain necessary to check the accuracy of the CGM device and to serve as a back-up (e.g., if the device stops working or blood glucose levels change quickly).", "supporting_spans": ["Offer intermittently scanned continuous glucose monitoring (isCGM, commonly referred to as 'flash') to adults with type 2 diabetes on multiple daily insulin injections if any of the following apply:\n- they have recurrent hypoglycaemia or severe hypoglycaemia\n- they have impaired hypoglycaemia awareness\n- they have a condition or disability (including a learning disability or cognitive impairment) that means they cannot self-monitor their blood glucose by capillary blood glucose monitoring but could use an isCGM device (or have it scanned for them)\n- they would otherwise be advised to self-measure at least 8 times a day.", "Advise adults with type 2 diabetes who are using CGM that they will still need to take capillary blood glucose measurements (although they can do this less often). Explain that is because:\n- they will need to use capillary blood glucose measurements to check the accuracy of their CGM device\n- they will need capillary blood glucose monitoring as a back-up (for example when their blood glucose levels are changing quickly or if the device stops working)."], "is_answerable": true, "notes": "", "_source_file": "data/nice/ng28/04_Blood-glucose-management.txt", "_eval_type": "retrieval", "_model": "google/gemma-4-31b-it:free", "_generated_at": "2026-06-19T00:04:32.889147+00:00"} +{"question": "If an adult with type 2 diabetes has an HbA1c level of 60 mmol/mol, what three actions should be taken according to the guidelines?", "question_type": "multi_paragraph", "answer": "The clinician should: 1) reinforce advice regarding adherence to medicines, healthy living, and diet; 2) support the person to aim for an HbA1c level of 53 mmol/mol (7.0%); and 3) intensify the patient's medicines.", "supporting_spans": ["In adults with type 2 diabetes, if HbA1c levels are not adequately controlled by the initial medication regimen and rise to 58 mmol/mol (7.5%) or higher:\n- reinforce advice about diet, healthy living and adherence to medicines and\n- support the person to aim for an HbA1c level of 53 mmol/mol (7.0%) and\n- intensify medicines."], "is_answerable": true, "notes": "", "_source_file": "data/nice/ng28/04_Blood-glucose-management.txt", "_eval_type": "retrieval", "_model": "google/gemma-4-31b-it:free", "_generated_at": "2026-06-19T00:04:32.889147+00:00"} +{"question": "Which validated scoring systems should be used in primary care to assess impaired hypoglycaemic awareness in adults with type 2 diabetes?", "question_type": "adversarial", "answer": "", "supporting_spans": [], "is_answerable": false, "notes": "The text mentions GOLD and Clarke scores but explicitly states that validated methods are 'not always available in primary care' and the committee did NOT recommend specific methods for assessment.", "_source_file": "data/nice/ng28/04_Blood-glucose-management.txt", "_eval_type": "retrieval", "_model": "google/gemma-4-31b-it:free", "_generated_at": "2026-06-19T00:04:32.889147+00:00"} +{"question": "What is the recommended annual frequency for the structured assessment of self-monitoring skills for patients on insulin?", "question_type": "adversarial", "answer": "", "supporting_spans": [], "is_answerable": false, "notes": "The document states a structured assessment should be carried out 'at least annually' for those self-monitoring, but it does not specify a different or specific frequency exclusively for those on insulin.", "_source_file": "data/nice/ng28/04_Blood-glucose-management.txt", "_eval_type": "retrieval", "_model": "google/gemma-4-31b-it:free", "_generated_at": "2026-06-19T00:04:32.889147+00:00"} diff --git a/datasets/amfv_datasets/eval_prompts/__init__.py b/datasets/amfv_datasets/eval_prompts/__init__.py new file mode 100644 index 0000000..63301ea --- /dev/null +++ b/datasets/amfv_datasets/eval_prompts/__init__.py @@ -0,0 +1,23 @@ +from amfv_datasets.eval_prompts.decomposition import ( + DecompositionItem, + parse_response as parse_decomposition_response, + user_prompt as decomposition_user_prompt, + SYSTEM_PROMPT as DECOMPOSITION_SYSTEM_PROMPT, +) +from amfv_datasets.eval_prompts.retrieval import ( + RetrievalItem, + parse_response as parse_retrieval_response, + user_prompt as retrieval_user_prompt, + SYSTEM_PROMPT as RETRIEVAL_SYSTEM_PROMPT, +) + +__all__ = [ + "DECOMPOSITION_SYSTEM_PROMPT", + "RETRIEVAL_SYSTEM_PROMPT", + "DecompositionItem", + "RetrievalItem", + "decomposition_user_prompt", + "parse_decomposition_response", + "parse_retrieval_response", + "retrieval_user_prompt", +] \ No newline at end of file diff --git a/datasets/amfv_datasets/eval_prompts/decomposition.py b/datasets/amfv_datasets/eval_prompts/decomposition.py new file mode 100644 index 0000000..6b0132a --- /dev/null +++ b/datasets/amfv_datasets/eval_prompts/decomposition.py @@ -0,0 +1,204 @@ +from __future__ import annotations + +import json +import re +from dataclasses import dataclass, field +from typing import Literal + +ClaimType = Literal["factual", "hedged", "negation", "numeric", "procedural"] +SourceKind = Literal["model_output", "reasoning_trace", "document_passage", "multiple_choice_rationale"] + + +@dataclass +class DecompositionItem: + claim: str + claim_type: ClaimType + source_span: str + requires_coreference: bool = False + original_pronoun: str | None = None + resolved_referent: str | None = None + is_distractor: bool = False + distractor_reason: str | None = None + notes: str = "" + + +SYSTEM_PROMPT = """\ +You are an expert medical claim decomposer building a reference evaluation \ +dataset for a medical fact verification system. + +Your task is to decompose a piece of medical text into a list of atomic, \ +independently-verifiable claims, following the three Baichuan-M3 rules and the \ +hard-case handling rules below. + +CORE DECOMPOSITION RULES (Baichuan-M3) +======================================= +Rule 1 — Atomic claims with full coreference resolution + Each claim must be self-contained: anyone reading the claim alone, without \ +the source text, must be able to look it up and verify it. Resolve all \ +pronouns and anaphors before writing the claim. + Example: "She was started on lisinopril 10 mg daily." → + "The patient (45-year-old female with hypertension) was started on \ +lisinopril 10 mg daily." + +Rule 2 — Distractor filtering + When the source is a multiple-choice rationale, the author frequently recites \ +incorrect options before refuting them. Do NOT decompose these distractor spans \ +into claims. Mark them with is_distractor: true so human reviewers can verify \ +the filtering. Example: "Option B, which states that amoxicillin is the first-\ +line treatment for MRSA infections, is incorrect." → mark is_distractor: true \ +with distractor_reason: "recitation of incorrect MC option". + +Rule 3 — Deduplication with order preservation + If two spans assert the same fact (even with different phrasing), produce ONE \ +claim and cite both source spans. Preserve the logical order of the original text. + +HARD-CASE HANDLING RULES +========================= +Numeric precision + Never round, truncate, or paraphrase numbers, units, or lab values. Preserve \ +them exactly. "eGFR 45 mL/min/1.73m²" must appear verbatim in the claim, not \ +as "low eGFR" or "reduced kidney function". + +Hedged claims + Preserve uncertainty markers. "may suggest", "is consistent with", "no \ +definitive evidence" must appear in the claim text. Label these hedged. + +Negated findings + "No evidence of pneumonia" is a verifiable claim. Do not drop negations or \ +rephrase them as positive statements. Label these negation. + +Long pronoun chains + When a pronoun references a subject defined more than two sentences earlier, \ +set requires_coreference: true, record the pronoun in original_pronoun, and \ +write the full referent in resolved_referent. + +CLAIM TYPES +=========== +factual — direct assertion of a medical fact. +hedged — source qualifies with uncertainty language. +negation — asserts absence or non-occurrence. +numeric — truth depends on a specific number, dose, lab value, or unit. +procedural — a step or ordered clinical action. + +OUTPUT FORMAT +============= +Respond with a JSON array and nothing else—no preamble, no markdown fences, \ +no trailing commentary. Each element must be an object with: + + claim (string) Self-contained, coreference-resolved claim. + claim_type (string) One of: factual, hedged, negation, numeric, procedural + source_span (string) Exact substring(s) from the source; for multi-span, + join with " [...] ". + requires_coreference (boolean) true if pronoun resolution was needed. + original_pronoun (string|null) + resolved_referent (string|null) + is_distractor (boolean) true if this span should be filtered out. + distractor_reason (string|null) + notes (string) "" if none. + +QUALITY RULES +============= +- Every non-distractor claim must be independently verifiable without the source. +- Do not merge distinct facts into one claim; split them if needed. +- Do not split a single atomic fact across multiple claims. +- Keep claims in the order they appear in the source text. +- A claim about a drug-dose-indication triple must include all three elements \ + (e.g. "metformin 500 mg twice daily for type 2 diabetes mellitus"). +""" + + +def user_prompt( + text: str, + *, + source_kind: SourceKind = "model_output", + document_title: str = "", + document_source: str = "", + extra_context: str = "", +) -> str: + kind_label = { + "model_output": "model output (final answer)", + "reasoning_trace": "model reasoning trace (chain-of-thought)", + "document_passage": "medical document passage", + "multiple_choice_rationale": "multiple-choice answer rationale", + }[source_kind] + + header_parts = [f"Source kind: {kind_label}"] + if document_title: + header_parts.append(f"Title / question stem: {document_title}") + if document_source: + header_parts.append(f"Source: {document_source}") + if extra_context: + header_parts.append(f"Additional context: {extra_context}") + header = "\n".join(header_parts) + + distractor_reminder = ( + "\nNOTE: This text contains multiple-choice distractors. Apply Rule 2 " + "carefully—mark recitations of wrong options as is_distractor: true.\n" + if source_kind == "multiple_choice_rationale" + else "" + ) + + return f"""\ +Decompose the following medical text into atomic, independently-verifiable claims. + +{header} +{distractor_reminder} +TEXT +==== +{text} +""" + + +_JSON_BLOCK_RE = re.compile(r"```(?:json)?\s*(.*?)\s*```", re.DOTALL) + + +def parse_response(raw: str) -> list[DecompositionItem]: + text = raw.strip() + match = _JSON_BLOCK_RE.search(text) + if match: + text = match.group(1) + + try: + data = json.loads(text) + except json.JSONDecodeError as exc: + raise ValueError(f"Response is not valid JSON: {exc}\n\nRaw:\n{raw[:500]}") from exc + + if not isinstance(data, list): + raise ValueError(f"Expected a JSON array at the top level, got {type(data).__name__}") + + items: list[DecompositionItem] = [] + for i, obj in enumerate(data): + try: + items.append( + DecompositionItem( + claim=obj["claim"], + claim_type=obj["claim_type"], + source_span=obj["source_span"], + requires_coreference=obj.get("requires_coreference", False), + original_pronoun=obj.get("original_pronoun"), + resolved_referent=obj.get("resolved_referent"), + is_distractor=obj.get("is_distractor", False), + distractor_reason=obj.get("distractor_reason"), + notes=obj.get("notes", ""), + ) + ) + except (KeyError, TypeError) as exc: + raise ValueError(f"Item {i} is missing required field: {exc}\n\nItem: {obj}") from exc + + return items + + +def active_claims(items: list[DecompositionItem]) -> list[DecompositionItem]: + return [c for c in items if not c.is_distractor] + + +def distractor_claims(items: list[DecompositionItem]) -> list[DecompositionItem]: + return [c for c in items if c.is_distractor] + + +def claims_by_type(items: list[DecompositionItem], claim_type: ClaimType) -> list[DecompositionItem]: + return [c for c in items if c.claim_type == claim_type] + + +decomposition_user_prompt = user_prompt +parse_decomposition_response = parse_response \ No newline at end of file diff --git a/datasets/amfv_datasets/eval_prompts/retrieval.py b/datasets/amfv_datasets/eval_prompts/retrieval.py new file mode 100644 index 0000000..c1076bf --- /dev/null +++ b/datasets/amfv_datasets/eval_prompts/retrieval.py @@ -0,0 +1,231 @@ +from __future__ import annotations + +import json +import re +from dataclasses import dataclass, field +from typing import Literal + +QuestionType = Literal["verbatim", "paragraph", "multi_paragraph", "adversarial"] + + +@dataclass +class RetrievalItem: + question: str + question_type: QuestionType + answer: str + supporting_spans: list[str] = field(default_factory=list) + is_answerable: bool = True + notes: str = "" + + _BULLET_LINE_RE = re.compile(r"[\r\n]*- [^\r\n]*") + + def validate_spans(self, document: str) -> list[str]: + return [s for s in self.supporting_spans if s not in document] + + @staticmethod + def _ends_at_bullet_boundary(span: str, following: str) -> bool: + if span.endswith(("\n", "\r")): + return following.startswith("- ") + return bool(re.match(r"[\r\n]+- ", following)) + + def find_truncated_spans(self, document: str) -> list[str]: + flagged = [] + for span in self.supporting_spans: + idx = document.find(span) + if idx == -1: + continue + end = idx + len(span) + following = document[end:end + 8] + if self._ends_at_bullet_boundary(span, following): + flagged.append(span) + return flagged + + def repair_truncated_spans(self, document: str) -> int: + repaired_count = 0 + new_spans = [] + for span in self.supporting_spans: + idx = document.find(span) + if idx == -1: + new_spans.append(span) + continue + + cursor = idx + len(span) + extended = False + first_iteration = True + + while True: + following = document[cursor:cursor + 4] + if first_iteration: + boundary_ok = self._ends_at_bullet_boundary(span, following) + else: + boundary_ok = following.startswith(("\n", "\r")) + if not boundary_ok: + break + + m = self._BULLET_LINE_RE.match(document, cursor) + if not m or m.end() == cursor: + break + cursor = m.end() + extended = True + first_iteration = False + + if extended: + new_spans.append(document[idx:cursor]) + repaired_count += 1 + else: + new_spans.append(span) + + self.supporting_spans = new_spans + return repaired_count + + +SYSTEM_PROMPT = """\ +You are an expert medical information retrieval evaluator helping build an \ +evaluation dataset for a medical fact verification system. + +Your task is to generate question-and-answer pairs from a provided medical \ +document or passage. Each question targets one of four difficulty categories: + +CATEGORY DEFINITIONS +==================== +1. verbatim + The answer appears word-for-word in the text. A retrieval system that \ +returns the right passage trivially has the answer. Include at least one \ +highly specific clinical detail (dosage, lab value, drug name, procedure \ +code, etc.) so the question cannot be answered from general knowledge alone. + +2. paragraph + The answer requires synthesising information from a SINGLE paragraph—it \ +cannot be lifted verbatim but does not require crossing paragraph boundaries. \ +Questions should involve reasoning such as inferring a clinical implication, \ +combining two sentences, or paraphrasing a recommendation with altered \ +framing. + +3. multi_paragraph + The answer requires combining information from TWO OR MORE discontinuous \ +passages in the document. The passages should not be adjacent. This tests \ +whether a retrieval system can find and assemble non-contiguous evidence. + +4. adversarial + The question LOOKS like it should be answerable from the document based on \ +its topic and terminology, but the specific answer is NOT present. The ideal \ +adversarial question is one where a hallucinating model might confidently give \ +a plausible-sounding wrong answer. Leave ``answer`` as an empty string and \ +``supporting_spans`` as an empty array for these items. + +OUTPUT FORMAT +============= +Respond with a JSON array and nothing else—no preamble, no markdown fences, \ +no trailing commentary. Each element must be an object with these keys: + + question (string) The question text. + question_type (string) One of: verbatim, paragraph, multi_paragraph, adversarial + answer (string) Gold answer; empty string for adversarial items. + supporting_spans (array) Exact substrings (copy-pasted) from the document + that support the answer; empty array for adversarial. + is_answerable (boolean) false only for adversarial items. + notes (string) Optional annotation; use "" if none. + +QUALITY RULES +============= +- Every answerable question must be answerable ONLY from the provided document, \ +not from general medical knowledge. +- supporting_spans must be verbatim substrings of the source—do not paraphrase. +- Do not create questions whose answers span the entire document; evidence spans \ +should be locatable passages. +- Adversarial questions must concern a topic the document discusses but must \ +ask for a specific detail the document omits (e.g., a dosage range for a drug \ +mentioned only by name, a contraindication not listed, a guideline not cited). +- Vary clinical domains across items where the source permits (dosing, diagnosis \ +criteria, population restrictions, procedure steps, prognosis, etc.). +- Aim for clinical specificity: prefer "What is the recommended dose of \ +metformin for adults with eGFR 30–44?" over "What drug is recommended?". +- When a sentence introduces a bulleted/numbered list (e.g. ends in ":" or \ +"if:" or "any of the following"), and the list items are needed to answer the \ +question, the supporting_span MUST include the full list, not just the \ +introductory sentence. A span that stops right before the bullets it is \ +introducing is incomplete and unusable as evidence—copy the entire passage \ +including every relevant bullet line, exactly as it appears (including the \ +"- " prefix and line breaks). +""" + +_DEFAULT_COUNTS: dict[QuestionType, int] = { + "verbatim": 2, + "paragraph": 2, + "multi_paragraph": 2, + "adversarial": 2, +} + + +def user_prompt( + document: str, + *, + counts: dict[QuestionType, int] | None = None, + document_title: str = "", + document_source: str = "", +) -> str: + counts = {**_DEFAULT_COUNTS, **(counts or {})} + + header_parts = [] + if document_title: + header_parts.append(f"Title: {document_title}") + if document_source: + header_parts.append(f"Source: {document_source}") + header = "\n".join(header_parts) + + count_instructions = "\n".join( + f" - {n} {qt} item{'s' if n != 1 else ''}" + for qt, n in counts.items() + ) + + return f"""\ +Generate retrieval evaluation items from the medical document below. + +{header + chr(10) if header else ""}\ +Produce exactly: +{count_instructions} + +DOCUMENT +======== +{document} +""" + + +_JSON_BLOCK_RE = re.compile(r"```(?:json)?\s*(.*?)\s*```", re.DOTALL) + + +def parse_response(raw: str) -> list[RetrievalItem]: + text = raw.strip() + match = _JSON_BLOCK_RE.search(text) + if match: + text = match.group(1) + + try: + data = json.loads(text) + except json.JSONDecodeError as exc: + raise ValueError(f"Response is not valid JSON: {exc}\n\nRaw:\n{raw[:500]}") from exc + + if not isinstance(data, list): + raise ValueError(f"Expected a JSON array at the top level, got {type(data).__name__}") + + items: list[RetrievalItem] = [] + for i, obj in enumerate(data): + try: + items.append( + RetrievalItem( + question=obj["question"], + question_type=obj["question_type"], + answer=obj.get("answer", ""), + supporting_spans=obj.get("supporting_spans", []), + is_answerable=obj.get("is_answerable", True), + notes=obj.get("notes", ""), + ) + ) + except (KeyError, TypeError) as exc: + raise ValueError(f"Item {i} is missing required field: {exc}\n\nItem: {obj}") from exc + + return items + + +retrieval_user_prompt = user_prompt +parse_retrieval_response = parse_response \ No newline at end of file diff --git a/datasets/amfv_datasets/eval_prompts/scripts/generate_eval.py b/datasets/amfv_datasets/eval_prompts/scripts/generate_eval.py new file mode 100644 index 0000000..e3152a1 --- /dev/null +++ b/datasets/amfv_datasets/eval_prompts/scripts/generate_eval.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +import argparse +import dataclasses +import json +import os +import sys +from datetime import datetime, timezone +from pathlib import Path + +import requests + +# insert the datasets package into the path when run directly. +_repo_root = Path(__file__).resolve().parents[4] +sys.path.insert(0, str(_repo_root / "datasets")) + +from amfv_datasets.eval_prompts.decomposition import ( + SYSTEM_PROMPT as DECOMP_SYSTEM, + DecompositionItem, + parse_response as parse_decomp, + user_prompt as decomp_user_prompt, +) +from amfv_datasets.eval_prompts.retrieval import ( + SYSTEM_PROMPT as RET_SYSTEM, + RetrievalItem, + parse_response as parse_ret, + user_prompt as ret_user_prompt, +) + +MODEL = "google/gemma-4-31b-it:free" +OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions" +MAX_TOKENS = 4096 + + +def _call_openrouter( + api_key: str, + system: str, + user: str, +) -> str: + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + "X-Title": "AMFV Eval Generator", + } + body = { + "model": MODEL, + "max_tokens": MAX_TOKENS, + "messages": [ + {"role": "system", "content": system}, + {"role": "user", "content": user}, + ], + } + resp = requests.post(OPENROUTER_URL, headers=headers, data=json.dumps(body), timeout=120) + resp.raise_for_status() + data = resp.json() + + if "error" in data: + raise RuntimeError(f"OpenRouter error: {data['error']}") + + return data["choices"][0]["message"]["content"] + + +def _to_jsonl_record(item: RetrievalItem | DecompositionItem, **meta: str) -> str: + d = dataclasses.asdict(item) + d.update(meta) + return json.dumps(d, ensure_ascii=False) + + +def generate_retrieval( + document: str, + *, + api_key: str, + counts: dict[str, int] | None = None, + title: str = "", + source: str = "", +) -> list[RetrievalItem]: + raw = _call_openrouter( + api_key, + system=RET_SYSTEM, + user=ret_user_prompt(document, counts=counts, document_title=title, document_source=source), + ) + items = parse_ret(raw) + + total_repaired = 0 + for item in items: + total_repaired += item.repair_truncated_spans(document) + if total_repaired: + print(f" (auto-repaired {total_repaired} truncated span(s))", file=sys.stderr) + + return items + + +def generate_decomposition( + text: str, + *, + api_key: str, + source_kind: str = "model_output", + title: str = "", + source: str = "", +) -> list[DecompositionItem]: + raw = _call_openrouter( + api_key, + system=DECOMP_SYSTEM, + user=decomp_user_prompt(text, source_kind=source_kind, document_title=title, document_source=source), # type: ignore[arg-type] + ) + return parse_decomp(raw) + + +def parse_counts(raw: str) -> dict[str, int]: + result = {} + for part in raw.split(","): + key, _, val = part.partition("=") + result[key.strip()] = int(val.strip()) + return result + + +def main() -> None: + parser = argparse.ArgumentParser(description="Generate AMFV eval items via OpenRouter.") + parser.add_argument("eval_type", choices=["retrieval", "decomposition"]) + parser.add_argument("--input", required=True, help="Path to the source document text file.") + parser.add_argument("--output", default="-", help="Output JSONL path (default: stdout).") + parser.add_argument("--title", default="", help="Optional document title.") + parser.add_argument("--source", default="", help="Optional source URL / identifier.") + parser.add_argument( + "--counts", + default="", + help="Retrieval only: comma-separated key=value overrides, e.g. verbatim=3,adversarial=1.", + ) + parser.add_argument( + "--source-kind", + default="model_output", + choices=["model_output", "reasoning_trace", "document_passage", "multiple_choice_rationale"], + help="Decomposition only: what kind of text is being decomposed.", + ) + args = parser.parse_args() + + api_key = os.environ.get("OPENROUTER_API_KEY") + if not api_key: + sys.exit("OPENROUTER_API_KEY is not set") + + document = Path(args.input).read_text(encoding="utf-8") + + meta = { + "_source_file": args.input, + "_eval_type": args.eval_type, + "_model": MODEL, + "_generated_at": datetime.now(tz=timezone.utc).isoformat(), + } + + print(f"Using model: {MODEL}", file=sys.stderr) + + if args.eval_type == "retrieval": + counts = parse_counts(args.counts) if args.counts else None + items = generate_retrieval( + document, + api_key=api_key, + counts=counts, + title=args.title, + source=args.source, + ) + else: + items = generate_decomposition( + document, + api_key=api_key, + source_kind=args.source_kind, + title=args.title, + source=args.source, + ) + + lines = [_to_jsonl_record(item, **meta) for item in items] + output = "\n".join(lines) + "\n" + + if args.output == "-": + sys.stdout.write(output) + else: + out_path = Path(args.output) + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(output, encoding="utf-8") + print(f"Wrote {len(items)} items → {args.output}", file=sys.stderr) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/datasets/amfv_datasets/eval_prompts/test/test_eval_prompts.py b/datasets/amfv_datasets/eval_prompts/test/test_eval_prompts.py new file mode 100644 index 0000000..47b0696 --- /dev/null +++ b/datasets/amfv_datasets/eval_prompts/test/test_eval_prompts.py @@ -0,0 +1,449 @@ +from __future__ import annotations + +import json + +import pytest + +from amfv_datasets.eval_prompts.decomposition import ( + DecompositionItem, + active_claims, + claims_by_type, + decomposition_user_prompt, + distractor_claims, + parse_decomposition_response, +) +from amfv_datasets.eval_prompts.retrieval import ( + RetrievalItem, + parse_retrieval_response, + retrieval_user_prompt, +) + +SAMPLE_DOC = ( + "Metformin is the first-line pharmacotherapy for type 2 diabetes mellitus in adults " + "with eGFR ≥ 30 mL/min/1.73 m². " + "It should be dose-reduced when eGFR falls to 30–44 mL/min/1.73 m² and discontinued " + "below eGFR 30 mL/min/1.73 m² due to increased risk of lactic acidosis. " + "Insulin secretagogues such as glipizide may be added if glycaemic targets are not met. " + "There is no evidence that dual therapy with metformin and a DPP-4 inhibitor reduces " + "cardiovascular events compared with metformin monotherapy in low-risk patients." +) + +GOOD_RETRIEVAL_JSON = json.dumps( + [ + { + "question": "What is the minimum eGFR threshold for initiating metformin?", + "question_type": "verbatim", + "answer": "eGFR ≥ 30 mL/min/1.73 m²", + "supporting_spans": ["eGFR ≥ 30 mL/min/1.73 m²"], + "is_answerable": True, + "notes": "", + }, + { + "question": "When should metformin be discontinued?", + "question_type": "paragraph", + "answer": "When eGFR falls below 30 mL/min/1.73 m² due to increased lactic acidosis risk.", + "supporting_spans": [ + "discontinued below eGFR 30 mL/min/1.73 m² due to increased risk of lactic acidosis" + ], + "is_answerable": True, + "notes": "", + }, + { + "question": "What is the cardiovascular event rate reduction from adding sitagliptin to metformin?", + "question_type": "adversarial", + "answer": "", + "supporting_spans": [], + "is_answerable": False, + "notes": "Document mentions DPP-4 inhibitors but gives no specific event rate figure.", + }, + ] +) + +GOOD_DECOMP_JSON = json.dumps( + [ + { + "claim": "Metformin is the first-line pharmacotherapy for type 2 diabetes mellitus in adults with eGFR ≥ 30 mL/min/1.73 m².", + "claim_type": "factual", + "source_span": "Metformin is the first-line pharmacotherapy for type 2 diabetes mellitus in adults with eGFR ≥ 30 mL/min/1.73 m².", + "requires_coreference": False, + "original_pronoun": None, + "resolved_referent": None, + "is_distractor": False, + "distractor_reason": None, + "notes": "", + }, + { + "claim": "Metformin should be dose-reduced when eGFR falls to 30–44 mL/min/1.73 m².", + "claim_type": "numeric", + "source_span": "dose-reduced when eGFR falls to 30–44 mL/min/1.73 m²", + "requires_coreference": False, + "original_pronoun": None, + "resolved_referent": None, + "is_distractor": False, + "distractor_reason": None, + "notes": "", + }, + { + "claim": "There is no evidence that dual therapy with metformin and a DPP-4 inhibitor reduces cardiovascular events compared with metformin monotherapy in low-risk patients.", + "claim_type": "negation", + "source_span": "There is no evidence that dual therapy with metformin and a DPP-4 inhibitor reduces cardiovascular events", + "requires_coreference": False, + "original_pronoun": None, + "resolved_referent": None, + "is_distractor": False, + "distractor_reason": None, + "notes": "", + }, + { + "claim": "Option A states that metformin causes hypoglycaemia as a primary side effect.", + "claim_type": "factual", + "source_span": "Option A: metformin causes hypoglycaemia", + "requires_coreference": False, + "original_pronoun": None, + "resolved_referent": None, + "is_distractor": True, + "distractor_reason": "recitation of incorrect MC option", + "notes": "", + }, + ] +) + +class TestRetrievalUserPrompt: + def test_contains_document(self): + prompt = retrieval_user_prompt(SAMPLE_DOC) + assert SAMPLE_DOC in prompt + + def test_default_counts_mentioned(self): + prompt = retrieval_user_prompt(SAMPLE_DOC) + for kind in ("verbatim", "paragraph", "multi_paragraph", "adversarial"): + assert kind in prompt + + def test_title_and_source_included(self): + prompt = retrieval_user_prompt(SAMPLE_DOC, document_title="Diabetes Guidelines 2024", document_source="https://nice.org.uk/guidance/ng28") + assert "Diabetes Guidelines 2024" in prompt + assert "https://nice.org.uk/guidance/ng28" in prompt + + def test_count_override(self): + prompt = retrieval_user_prompt(SAMPLE_DOC, counts={"verbatim": 5, "adversarial": 0}) + assert "5 verbatim" in prompt + + +class TestFindTruncatedSpans: + + def test_flags_span_truncated_right_before_bullets(self): + doc = ( + "Consider relaxing the target HbA1c level on a case-by-case basis if:\n" + "- they have a reduced life expectancy\n" + "- intensive management would not be appropriate.\n" + ) + truncated = "Consider relaxing the target HbA1c level on a case-by-case basis if:\n" + item = RetrievalItem( + question="q", question_type="paragraph", answer="a", + supporting_spans=[truncated], is_answerable=True, + ) + assert item.find_truncated_spans(doc) == [truncated] + + def test_does_not_flag_complete_span_including_bullets(self): + doc = ( + "Consider relaxing the target HbA1c level on a case-by-case basis if:\n" + "- they have a reduced life expectancy\n" + "- intensive management would not be appropriate.\n" + ) + complete = doc.strip() + item = RetrievalItem( + question="q", question_type="paragraph", answer="a", + supporting_spans=[complete], is_answerable=True, + ) + assert item.find_truncated_spans(doc) == [] + + def test_does_not_flag_span_with_no_following_bullets(self): + doc = "Metformin is first-line therapy. It is well tolerated." + span = "Metformin is first-line therapy." + item = RetrievalItem( + question="q", question_type="verbatim", answer="a", + supporting_spans=[span], is_answerable=True, + ) + assert item.find_truncated_spans(doc) == [] + + def test_ignores_spans_not_found_in_document(self): + doc = "Some unrelated document text." + item = RetrievalItem( + question="q", question_type="paragraph", answer="a", + supporting_spans=["This text does not appear anywhere."], + is_answerable=True, + ) + assert item.find_truncated_spans(doc) == [] + + def test_handles_multiple_spans_mixed(self): + doc = ( + "Offer isCGM if any of the following apply:\n" + "- recurrent hypoglycaemia\n" + "- impaired awareness\n" + ) + good = "Offer isCGM if any of the following apply:\n- recurrent hypoglycaemia\n- impaired awareness" + bad = "Offer isCGM if any of the following apply:\n" + item = RetrievalItem( + question="q", question_type="paragraph", answer="a", + supporting_spans=[good, bad], is_answerable=True, + ) + flagged = item.find_truncated_spans(doc) + assert bad in flagged + assert good not in flagged + + def test_handles_crlf_line_endings(self): + doc = ( + "Consider relaxing the target if:\r\n" + "- they have a reduced life expectancy\r\n" + "- intensive management would not be appropriate.\r\n" + ) + truncated = "Consider relaxing the target if:\r\n- they have a reduced life expectancy\r\n" + item = RetrievalItem( + question="q", question_type="paragraph", answer="a", + supporting_spans=[truncated], is_answerable=True, + ) + assert item.find_truncated_spans(doc) == [truncated] + + def test_does_not_flag_literal_hyphen_in_prose(self): + doc = "The treatment was well-tolerated by most patients in the trial." + span = "The treatment was well" + item = RetrievalItem( + question="q", question_type="verbatim", answer="a", + supporting_spans=[span], is_answerable=True, + ) + assert item.find_truncated_spans(doc) == [] + + +class TestRepairTruncatedSpans: + + def test_repairs_real_world_failure_case(self): + doc = ( + "Consider relaxing the target HbA1c level on a case-by-case basis, " + "with particular consideration for people who are older or frailer, if:\n" + "- they are unlikely to achieve longer-term risk-reduction benefits, " + "for example, people with a reduced life expectancy\n" + "- tight blood glucose control would put them at high risk if they " + "developed hypoglycaemia\n" + "- intensive management would not be appropriate, for example if " + "they have significant comorbidities.\n" + ) + truncated = ( + "Consider relaxing the target HbA1c level on a case-by-case basis, " + "with particular consideration for people who are older or frailer, if:\n" + "- they are unlikely to achieve longer-term risk-reduction benefits, " + "for example, people with a reduced life expectancy\n" + ) + item = RetrievalItem( + question="q", question_type="paragraph", answer="a", + supporting_spans=[truncated], is_answerable=True, + ) + assert item.find_truncated_spans(doc) == [truncated] + + n = item.repair_truncated_spans(doc) + + assert n == 1 + assert item.find_truncated_spans(doc) == [] + assert item.validate_spans(doc) == [] + assert "significant comorbidities" in item.supporting_spans[0] + assert "developed hypoglycaemia" in item.supporting_spans[0] + + def test_repairs_with_crlf_line_endings(self): + doc = ( + "Consider relaxing the target if:\r\n" + "- they have a reduced life expectancy\r\n" + "- intensive management would not be appropriate.\r\n" + ) + truncated = "Consider relaxing the target if:\r\n- they have a reduced life expectancy\r\n" + item = RetrievalItem( + question="q", question_type="paragraph", answer="a", + supporting_spans=[truncated], is_answerable=True, + ) + n = item.repair_truncated_spans(doc) + assert n == 1 + assert item.find_truncated_spans(doc) == [] + assert "intensive management would not be appropriate" in item.supporting_spans[0] + + def test_does_not_repair_when_no_bullets_follow(self): + doc = "Metformin is first-line therapy. It is well tolerated." + span = "Metformin is first-line therapy." + item = RetrievalItem( + question="q", question_type="verbatim", answer="a", + supporting_spans=[span], is_answerable=True, + ) + n = item.repair_truncated_spans(doc) + assert n == 0 + assert item.supporting_spans == [span] + + def test_does_not_repair_literal_hyphen_in_prose(self): + doc = "The treatment was well-tolerated by most patients in the trial." + span = "The treatment was well" + item = RetrievalItem( + question="q", question_type="verbatim", answer="a", + supporting_spans=[span], is_answerable=True, + ) + n = item.repair_truncated_spans(doc) + assert n == 0 + assert item.supporting_spans == [span] + + def test_repairs_span_without_trailing_newline(self): + doc = "Offer X if any of the following apply:\n- criterion A\n- criterion B\n- criterion C\n" + span = "Offer X if any of the following apply:" + item = RetrievalItem( + question="q", question_type="multi_paragraph", answer="a", + supporting_spans=[span], is_answerable=True, + ) + n = item.repair_truncated_spans(doc) + assert n == 1 + for bullet in ("criterion A", "criterion B", "criterion C"): + assert bullet in item.supporting_spans[0] + + def test_repairs_only_truncated_spans_in_mixed_list(self): + doc = "Sentence one is fine.\nOffer X if any of the following apply:\n- criterion A\n- criterion B\n" + good = "Sentence one is fine." + bad = "Offer X if any of the following apply:\n" + item = RetrievalItem( + question="q", question_type="multi_paragraph", answer="a", + supporting_spans=[good, bad], is_answerable=True, + ) + n = item.repair_truncated_spans(doc) + assert n == 1 + assert item.supporting_spans[0] == good + assert "criterion B" in item.supporting_spans[1] + + def test_leaves_span_unmodified_when_not_found_in_document(self): + doc = "Completely unrelated text." + span = "This text does not appear anywhere in the document." + item = RetrievalItem( + question="q", question_type="paragraph", answer="a", + supporting_spans=[span], is_answerable=True, + ) + n = item.repair_truncated_spans(doc) + assert n == 0 + assert item.supporting_spans == [span] + + +class TestParseRetrievalResponse: + def test_parse_bare_json(self): + items = parse_retrieval_response(GOOD_RETRIEVAL_JSON) + assert len(items) == 3 + assert all(isinstance(i, RetrievalItem) for i in items) + + def test_parse_fenced_json(self): + fenced = f"```json\n{GOOD_RETRIEVAL_JSON}\n```" + items = parse_retrieval_response(fenced) + assert len(items) == 3 + + def test_adversarial_item_is_not_answerable(self): + items = parse_retrieval_response(GOOD_RETRIEVAL_JSON) + adversarial = [i for i in items if i.question_type == "adversarial"] + assert len(adversarial) == 1 + assert not adversarial[0].is_answerable + assert adversarial[0].supporting_spans == [] + assert adversarial[0].answer == "" + + def test_verbatim_item_has_span(self): + items = parse_retrieval_response(GOOD_RETRIEVAL_JSON) + verbatim = [i for i in items if i.question_type == "verbatim"] + assert verbatim[0].supporting_spans != [] + + def test_span_validation(self): + items = parse_retrieval_response(GOOD_RETRIEVAL_JSON) + verbatim = [i for i in items if i.question_type == "verbatim"][0] + bad = verbatim.validate_spans("completely unrelated text") + assert len(bad) == len(verbatim.supporting_spans) + + good = verbatim.validate_spans(SAMPLE_DOC) + assert good == [] + + def test_invalid_json_raises(self): + with pytest.raises(ValueError, match="not valid JSON"): + parse_retrieval_response("not json at all {{{") + + def test_non_array_raises(self): + with pytest.raises(ValueError, match="JSON array"): + parse_retrieval_response('{"question": "What?"}') + + def test_missing_required_field_raises(self): + bad = json.dumps([{"question_type": "verbatim"}]) + with pytest.raises(ValueError, match="missing required field"): + parse_retrieval_response(bad) + + +class TestDecompositionUserPrompt: + def test_contains_text(self): + prompt = decomposition_user_prompt(SAMPLE_DOC) + assert SAMPLE_DOC in prompt + + def test_default_source_kind(self): + prompt = decomposition_user_prompt(SAMPLE_DOC) + assert "model output" in prompt + + def test_reasoning_trace_kind(self): + prompt = decomposition_user_prompt(SAMPLE_DOC, source_kind="reasoning_trace") + assert "reasoning trace" in prompt + + def test_mc_distractor_reminder_shown(self): + prompt = decomposition_user_prompt(SAMPLE_DOC, source_kind="multiple_choice_rationale") + assert "Rule 2" in prompt + + def test_mc_distractor_reminder_absent_for_other_kinds(self): + for kind in ("model_output", "reasoning_trace", "document_passage"): + prompt = decomposition_user_prompt(SAMPLE_DOC, source_kind=kind) + assert "Rule 2" not in prompt + + def test_title_and_source_included(self): + prompt = decomposition_user_prompt( + SAMPLE_DOC, + document_title="NICE NG28", + document_source="https://nice.org.uk", + ) + assert "NICE NG28" in prompt + assert "https://nice.org.uk" in prompt + + def test_extra_context_included(self): + prompt = decomposition_user_prompt(SAMPLE_DOC, extra_context="Focus on renal dosing.") + assert "Focus on renal dosing." in prompt + + +class TestParseDecompositionResponse: + def test_parse_bare_json(self): + items = parse_decomposition_response(GOOD_DECOMP_JSON) + assert len(items) == 4 + + def test_parse_fenced_json(self): + fenced = f"```json\n{GOOD_DECOMP_JSON}\n```" + items = parse_decomposition_response(fenced) + assert len(items) == 4 + + def test_all_items_are_dataclass(self): + items = parse_decomposition_response(GOOD_DECOMP_JSON) + assert all(isinstance(i, DecompositionItem) for i in items) + + def test_distractor_flagged(self): + items = parse_decomposition_response(GOOD_DECOMP_JSON) + distractors = distractor_claims(items) + assert len(distractors) == 1 + assert distractors[0].distractor_reason == "recitation of incorrect MC option" + + def test_active_claims_excludes_distractors(self): + items = parse_decomposition_response(GOOD_DECOMP_JSON) + active = active_claims(items) + assert len(active) == 3 + assert all(not c.is_distractor for c in active) + + def test_filter_by_claim_type(self): + items = parse_decomposition_response(GOOD_DECOMP_JSON) + numeric = claims_by_type(items, "numeric") + assert len(numeric) == 1 + assert "30–44" in numeric[0].claim + + negations = claims_by_type(items, "negation") + assert len(negations) == 1 + + def test_invalid_json_raises(self): + with pytest.raises(ValueError, match="not valid JSON"): + parse_decomposition_response("}{bad json") + + def test_missing_required_field_raises(self): + bad = json.dumps([{"claim_type": "factual", "source_span": "x"}]) + with pytest.raises(ValueError, match="missing required field"): + parse_decomposition_response(bad) \ No newline at end of file diff --git a/datasets/amfv_datasets/nice/fetcher.py b/datasets/amfv_datasets/nice/fetcher.py new file mode 100644 index 0000000..9bca13a --- /dev/null +++ b/datasets/amfv_datasets/nice/fetcher.py @@ -0,0 +1,237 @@ +from __future__ import annotations + +import re +import time +from dataclasses import dataclass, field +from pathlib import Path +from urllib.parse import urljoin, urlparse + +import requests +from bs4 import BeautifulSoup, Tag + +NICE_BASE = "https://www.nice.org.uk" + +_SKIP_SLUG_PATTERNS = [ + r"update.information", + r"finding.more.information", + r"committee.details", + r"about.this.guidance", + r"using.this.guideline", +] + +_REMOVE_SELECTORS = [ + "header", "footer", "nav", ".breadcrumb", ".pagination", + ".page-header__tags", "#cookie-banner", ".nhsuk-back-link", + ".nhsuk-contents-list", "script", "style", "noscript", + ".side-panel", ".js-filters", ".action-banner", + ".label--tag", ".panel--inverse", + ".nhsuk-inpage-navigation", +] + + +@dataclass +class NiceChapter: + title: str + url: str + slug: str + text: str = "" + + +@dataclass +class NiceGuideline: + code: str + delay: float = 1.5 + session: requests.Session = field(default_factory=requests.Session) + chapters: list[NiceChapter] = field(default_factory=list) + title: str = "" + failed_chapters: list[tuple[str, str]] = field(default_factory=list) + + def __post_init__(self) -> None: + self.code = self.code.lower().strip() + self.session.headers.update({ + "User-Agent": ( + "Mozilla/5.0 (compatible; AMFV-research-bot/0.1; " + "+https://github.com/MedARC-AI/amfv)" + ), + "Accept": "text/html,application/xhtml+xml", + }) + + @property + def overview_url(self) -> str: + return f"{NICE_BASE}/guidance/{self.code}" + + def fetch_all(self, *, verbose: bool = True, max_retries: int = 3) -> None: + if verbose: + print(f"Fetching overview: {self.overview_url}") + overview_html = self._get(self.overview_url, max_retries=max_retries) + self.title, chapter_links = self._parse_overview(overview_html) + + if verbose: + print(f"Guideline: {self.title}") + print(f"Found {len(chapter_links)} chapters to fetch") + + for url, slug, chapter_title in chapter_links: + if self._should_skip(slug): + if verbose: + print(f" skip {slug}") + continue + + time.sleep(self.delay) + try: + html = self._get(url, max_retries=max_retries) + except requests.exceptions.RequestException as exc: + self.failed_chapters.append((slug, str(exc))) + if verbose: + print(f" FAIL {slug} ({exc})") + continue + + text = self._extract_text(html) + self.chapters.append(NiceChapter(title=chapter_title, url=url, slug=slug, text=text)) + if verbose: + print(f" fetch {slug}") + + if verbose: + print(f"Done. Fetched {len(self.chapters)} chapters.") + if self.failed_chapters: + print(f"Failed ({len(self.failed_chapters)}): " + ", ".join(s for s, _ in self.failed_chapters)) + + def combined_text(self) -> str: + parts = [f"# {self.title}\n\nSource: {self.overview_url}\n\n"] + for ch in self.chapters: + parts.append(f"## {ch.title}\n\n{ch.text}\n\n") + return "\n".join(parts) + + def save(self, out_dir: str | Path, *, verbose: bool = True) -> None: + out = Path(out_dir) + out.mkdir(parents=True, exist_ok=True) + + for i, ch in enumerate(self.chapters, 1): + fname = out / f"{i:02d}_{ch.slug}.txt" + fname.write_text(ch.text, encoding="utf-8") + if verbose: + print(f" wrote {fname} ({len(ch.text):,} chars)") + + combined = out / f"{self.code}_combined.txt" + combined.write_text(self.combined_text(), encoding="utf-8") + if verbose: + print(f" wrote {combined} ({len(self.combined_text()):,} chars)") + + def _get(self, url: str, *, max_retries: int = 3) -> str: + last_exc: Exception | None = None + for attempt in range(1, max_retries + 1): + try: + resp = self.session.get(url, timeout=30) + resp.raise_for_status() + return resp.text + except requests.exceptions.HTTPError as exc: + status = exc.response.status_code if exc.response is not None else None + if status is not None and 400 <= status < 500: + raise + last_exc = exc + except (requests.exceptions.ConnectionError, requests.exceptions.Timeout) as exc: + last_exc = exc + + if attempt < max_retries: + time.sleep(self.delay * (2 ** (attempt - 1))) + + assert last_exc is not None + raise last_exc + + def retry_failed(self, *, verbose: bool = True, max_retries: int = 3) -> None: + still_failed: list[tuple[str, str]] = [] + for slug, _ in self.failed_chapters: + url = f"{NICE_BASE}/guidance/{self.code}/chapter/{slug}" + time.sleep(self.delay) + try: + html = self._get(url, max_retries=max_retries) + except requests.exceptions.RequestException as exc: + still_failed.append((slug, str(exc))) + if verbose: + print(f" still failing: {slug} ({exc})") + continue + text = self._extract_text(html) + self.chapters.append(NiceChapter(title=slug, url=url, slug=slug, text=text)) + if verbose: + print(f" recovered: {slug}") + self.failed_chapters = still_failed + + def _parse_overview(self, html: str) -> tuple[str, list[tuple[str, str, str]]]: + soup = BeautifulSoup(html, "html.parser") + + h1 = soup.find("h1") + guideline_title = h1.get_text(strip=True) if h1 else self.code.upper() + + chapter_pattern = re.compile(rf"/guidance/{re.escape(self.code)}/chapter/", re.IGNORECASE) + seen: set[str] = set() + chapters: list[tuple[str, str, str]] = [] + + for a in soup.find_all("a", href=chapter_pattern): + href: str = a["href"] + full_url = urljoin(NICE_BASE, href) + slug = urlparse(full_url).path.rstrip("/").split("/")[-1] + if slug in seen: + continue + seen.add(slug) + chapter_title = a.get_text(strip=True) or slug + chapters.append((full_url, slug, chapter_title)) + + return guideline_title, chapters + + @staticmethod + def _should_skip(slug: str) -> bool: + slug_lower = slug.lower() + return any(re.search(p, slug_lower) for p in _SKIP_SLUG_PATTERNS) + + @staticmethod + def _clean_text(text: str) -> str: + text = re.sub(r"[\u00A0\u2000-\u200A\u202F\u205F\u3000]", " ", text) + text = text.replace("\u200B", "").replace("\u200C", "").replace("\u200D", "") + text = text.replace("\u00AD", " ") + text = re.sub(r" {2,}", " ", text) + return text.strip() + + @staticmethod + def _extract_text(html: str) -> str: + soup = BeautifulSoup(html, "html.parser") + + for selector in _REMOVE_SELECTORS: + for el in soup.select(selector): + el.decompose() + + main = ( + soup.find("main") + or soup.find("div", {"id": "content"}) + or soup.find("div", class_=re.compile(r"content")) + or soup.body + ) + if not main or not isinstance(main, Tag): + return NiceGuideline._clean_text(soup.get_text(separator="\n", strip=True)) + + lines: list[str] = [] + for el in main.descendants: + if not isinstance(el, Tag): + continue + + tag = el.name.lower() + + if tag in ("h1", "h2", "h3", "h4", "h5", "h6"): + text = NiceGuideline._clean_text(el.get_text(separator=" ", strip=True)) + if text: + prefix = "#" * int(tag[1]) + lines.append(f"\n{prefix} {text}\n") + + elif tag == "p": + if el.find_parent("li") is not None: + continue + text = NiceGuideline._clean_text(el.get_text(separator=" ", strip=True)) + if text: + lines.append(text) + + elif tag == "li": + text = NiceGuideline._clean_text(el.get_text(separator=" ", strip=True)) + if text: + lines.append(f"- {text}") + + text = "\n".join(lines) + text = re.sub(r"\n{3,}", "\n\n", text) + return text.strip() \ No newline at end of file diff --git a/datasets/amfv_datasets/nice/scripts/fetch_nice.py b/datasets/amfv_datasets/nice/scripts/fetch_nice.py new file mode 100644 index 0000000..b486d3b --- /dev/null +++ b/datasets/amfv_datasets/nice/scripts/fetch_nice.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[4] / "datasets")) + +from amfv_datasets.nice.fetcher import NiceGuideline + +# guidelines covering the high-stake cases mentioned in notion such as dosage, pregnancy, renal dosing, etc. +RECOMMENDED_GUIDELINES: dict[str, str] = { + "ng28": "Type 2 diabetes in adults: management", + "ng17": "Type 1 diabetes in adults: diagnosis and management", + "ng45": "Chronic kidney disease in adults: assessment and management", + "ng106": "Hypertension in adults: diagnosis and management", + "ng89": "Sepsis: recognition, diagnosis and early management", + "ng51": "Sepsis (update)", + "cg181": "Cardiovascular disease: risk assessment and reduction", + "ng185": "COVID-19 rapid guideline: managing COVID-19", + "ng58": "Multimorbidity: clinical assessment and management", + "ng5": "Medicines optimisation: the safe and effective use of medicines", +} + + +def list_chapters(guideline: NiceGuideline) -> None: + overview_html = guideline._get(guideline.overview_url) + title, chapters = guideline._parse_overview(overview_html) + print(title) + for url, slug, _ in chapters: + marker = " [skip]" if guideline._should_skip(slug) else "" + print(f" {slug:<50} {url}{marker}") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--guideline", "-g", help="Guideline code, e.g. ng28.") + parser.add_argument("--out-dir", "-o", type=Path, help="Output directory. Defaults to data/nice/.") + parser.add_argument("--delay", type=float, default=1.5, help="Seconds between requests.") + parser.add_argument("--max-retries", type=int, default=3, help="Retries per chapter on transient errors.") + parser.add_argument("--list-only", action="store_true", help="List chapter URLs without fetching content.") + parser.add_argument("--show-recommended", action="store_true", help="List recommended guideline codes.") + args = parser.parse_args() + + if args.show_recommended: + for code, title in RECOMMENDED_GUIDELINES.items(): + print(f"{code:<8} {title}") + return + + if not args.guideline: + parser.error("--guideline is required") + + code = args.guideline.lower().strip() + out_dir = args.out_dir or Path("data") / "nice" / code + guideline = NiceGuideline(code, delay=args.delay) + + if args.list_only: + list_chapters(guideline) + return + + guideline.fetch_all(verbose=True, max_retries=args.max_retries) + if guideline.failed_chapters: + guideline.retry_failed(verbose=True, max_retries=args.max_retries) + guideline.save(out_dir, verbose=True) + + if guideline.failed_chapters: + for slug, error in guideline.failed_chapters: + print(f"failed: {slug}: {error}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/datasets/pyproject.toml b/datasets/pyproject.toml index ad356ac..81a538d 100644 --- a/datasets/pyproject.toml +++ b/datasets/pyproject.toml @@ -3,7 +3,10 @@ name = "amfv-datasets" version = "0.0.1" description = "Dataset ingestion, processing, and construction for AMFV" requires-python = ">=3.13" -dependencies = [] +dependencies = [ + "requests>=2.31", + "beautifulsoup4>=4.12", +] [build-system] requires = ["uv_build>=0.11,<0.12"] @@ -16,4 +19,4 @@ module-root = "" extend = "../ruff.toml" [tool.pytest.ini_options] -testpaths = ["test"] +testpaths = ["test"] \ No newline at end of file diff --git a/datasets/test/fixtures/nice_chapter.html b/datasets/test/fixtures/nice_chapter.html new file mode 100644 index 0000000..5ac3661 --- /dev/null +++ b/datasets/test/fixtures/nice_chapter.html @@ -0,0 +1,31 @@ + + +Blood glucose management | Type 2 diabetes in adults: management | Guidance | NICE + +
+ +
+ + +

Blood glucose management

+

HbA1c measurement and targets

+

Measure HbA1c levels in adults with type 2 diabetes every 3 to 6 months.

+

Support adults to aim for an HbA1c level of 48 mmol/mol (6.5%) or lower.

+

Metformin dosing

+

Metformin should be dose-reduced when eGFR falls to 30-44 mL/min/1.73 m2.

+ +

Support adults with type 2 diabetes to monitor their condition.

+
Related guidance links - sidebar junk
+
Download this guidance as PDF - banner junk
+
+ + + + \ No newline at end of file diff --git a/datasets/test/fixtures/nice_overview.html b/datasets/test/fixtures/nice_overview.html new file mode 100644 index 0000000..1fedb25 --- /dev/null +++ b/datasets/test/fixtures/nice_overview.html @@ -0,0 +1,36 @@ + + +Overview | Type 2 diabetes in adults: management | Guidance | NICE + +
+ +
+ +

Type 2 diabetes in adults: management

+ + +

Overview

+

This guideline covers care and management for adults (aged 18 and over) with type 2 diabetes.

+
+ + + \ No newline at end of file diff --git a/datasets/test/test_nice_fetcher.py b/datasets/test/test_nice_fetcher.py new file mode 100644 index 0000000..588c918 --- /dev/null +++ b/datasets/test/test_nice_fetcher.py @@ -0,0 +1,355 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest +import requests + +from amfv_datasets.nice.fetcher import NiceGuideline + +FIXTURES = Path(__file__).parent / "fixtures" + + +@pytest.fixture +def overview_html() -> str: + return (FIXTURES / "nice_overview.html").read_text(encoding="utf-8") + + +@pytest.fixture +def chapter_html() -> str: + return (FIXTURES / "nice_chapter.html").read_text(encoding="utf-8") + + +@pytest.fixture +def guideline() -> NiceGuideline: + return NiceGuideline("ng28") + + +class TestGuidelineInit: + def test_code_is_lowercased(self): + g = NiceGuideline("NG28") + assert g.code == "ng28" + + def test_overview_url(self, guideline): + assert guideline.overview_url == "https://www.nice.org.uk/guidance/ng28" + + def test_user_agent_set(self, guideline): + assert "User-Agent" in guideline.session.headers + + +class TestParseOverview: + def test_extracts_title(self, guideline, overview_html): + title, _ = guideline._parse_overview(overview_html) + assert title == "Type 2 diabetes in adults: management" + + def test_finds_all_chapter_links(self, guideline, overview_html): + _, chapters = guideline._parse_overview(overview_html) + slugs = {slug for _, slug, _ in chapters} + assert "Individualised-care" in slugs + assert "Blood-glucose-management" in slugs + assert "Initial-medicines" in slugs + + def test_overview_link_itself_excluded(self, guideline, overview_html): + _, chapters = guideline._parse_overview(overview_html) + urls = {url for url, _, _ in chapters} + assert "https://www.nice.org.uk/guidance/ng28" not in urls + + def test_no_duplicate_slugs(self, guideline, overview_html): + _, chapters = guideline._parse_overview(overview_html) + slugs = [slug for _, slug, _ in chapters] + assert len(slugs) == len(set(slugs)) + + def test_chapter_titles_populated(self, guideline, overview_html): + _, chapters = guideline._parse_overview(overview_html) + title_by_slug = {slug: title for _, slug, title in chapters} + assert title_by_slug["Blood-glucose-management"] == "Blood glucose management" + + def test_urls_are_absolute(self, guideline, overview_html): + _, chapters = guideline._parse_overview(overview_html) + for url, _, _ in chapters: + assert url.startswith("https://www.nice.org.uk/") + + +class TestShouldSkip: + @pytest.mark.parametrize("slug", [ + "Update-information", + "Finding-more-information-and-committee-details", + "Using-this-guideline", + ]) + def test_administrative_chapters_skipped(self, slug): + assert NiceGuideline._should_skip(slug) is True + + @pytest.mark.parametrize("slug", [ + "Blood-glucose-management", + "Individualised-care", + "Recommendations-for-research", + "Dietary-advice-and-interventions", + ]) + def test_clinical_chapters_kept(self, slug): + assert NiceGuideline._should_skip(slug) is False + + +class TestExtractText: + def test_removes_nav_chrome(self, chapter_html): + text = NiceGuideline._extract_text(chapter_html) + assert "site nav junk" not in text + + def test_removes_cookie_banner(self, chapter_html): + text = NiceGuideline._extract_text(chapter_html) + assert "Accept cookies" not in text + + def test_removes_sidebar_and_action_banner(self, chapter_html): + text = NiceGuideline._extract_text(chapter_html) + assert "sidebar junk" not in text + assert "banner junk" not in text + + def test_removes_footer_and_scripts(self, chapter_html): + text = NiceGuideline._extract_text(chapter_html) + assert "footer junk" not in text + assert "tracking script" not in text + + def test_removes_inpage_navigation(self, chapter_html): + text = NiceGuideline._extract_text(chapter_html) + assert "On this page" not in text + + def test_preserves_numeric_clinical_values(self, chapter_html): + text = NiceGuideline._extract_text(chapter_html) + assert "48 mmol/mol (6.5%)" in text + assert "30-44 mL/min/1.73 m2" in text + assert "eGFR 30 mL/min/1.73 m2" in text + + def test_preserves_headings(self, chapter_html): + text = NiceGuideline._extract_text(chapter_html) + assert "HbA1c measurement and targets" in text + assert "Metformin dosing" in text + + def test_preserves_list_items(self, chapter_html): + text = NiceGuideline._extract_text(chapter_html) + assert "Discontinue metformin below eGFR 30 mL/min/1.73 m2." in text + assert "Review renal function annually." in text + + def test_no_triple_blank_lines(self, chapter_html): + text = NiceGuideline._extract_text(chapter_html) + assert "\n\n\n" not in text + + def test_nbsp_normalized_to_space(self, chapter_html): + text = NiceGuideline._extract_text(chapter_html) + assert "type 2 diabetes to monitor" in text + assert "\u00a0" not in text + + def test_soft_hyphen_normalized_to_space(self, chapter_html): + text = NiceGuideline._extract_text(chapter_html) + assert "their device" in text + assert "theirdevice" not in text + assert "\u00ad" not in text + + def test_li_wrapping_p_does_not_duplicate(self): + html = ( + "
" + "" + "
" + ) + text = NiceGuideline._extract_text(html) + assert text.count("reinforce advice about diet") == 1 + assert text.count("intensify medicines") == 1 + assert "- reinforce advice about diet" in text + assert "- intensify medicines" in text + + def test_li_without_nested_p_still_works(self): + html = "
" + text = NiceGuideline._extract_text(html) + assert text == "- option A\n- option B" + + def test_standalone_p_after_list_unaffected(self): + html = ( + "
" + "" + "

standalone paragraph, not a bullet

" + "
" + ) + text = NiceGuideline._extract_text(html) + assert "- bullet one" in text + assert "standalone paragraph, not a bullet" in text + assert "- standalone paragraph" not in text + + def test_real_nice_markup_reproduces_correctly(self): + html = ( + "
" + '
' + "
1.5.9
" + "
" + "

Consider relaxing the target HbA1c level on a case-by-case basis if:

" + '
    ' + '
  • they are unlikely to achieve longer-term risk-reduction benefits, for example, people with a reduced life expectancy

  • ' + '
  • tight blood glucose control would put them at high risk if they developed hypoglycaemia

  • ' + '
  • intensive management would not be appropriate, for example if they have significant comorbidities. [2015, amended 2022]

  • ' + "
" + "
" + "
" + "
" + ) + text = NiceGuideline._extract_text(html) + assert text.count("reduced life expectancy") == 1 + assert text.count("developed hypoglycaemia") == 1 + assert text.count("significant comorbidities") == 1 + assert "- they are unlikely to achieve longer-term risk-reduction benefits, for example, people with a reduced life expectancy" in text + assert "- tight blood glucose control would put them at high risk if they developed hypoglycaemia" in text + assert "- intensive management would not be appropriate, for example if they have significant comorbidities. [2015, amended 2022]" in text + + +class TestCleanText: + def test_nbsp_to_space(self): + assert NiceGuideline._clean_text("type\u00a02 diabetes") == "type 2 diabetes" + + def test_soft_hyphen_to_space_not_deletion(self): + # Deleting (not spacing) would fuse "their" + "device" -> "theirdevice" + result = NiceGuideline._clean_text("their\u00addevice") + assert result == "their device" + assert "theirdevice" not in result + + def test_zero_width_chars_removed(self): + assert NiceGuideline._clean_text("foo\u200bbar") == "foobar" + + def test_multiple_spaces_collapsed(self): + assert NiceGuideline._clean_text("foo bar") == "foo bar" + + def test_normal_clinical_text_unchanged(self): + text = "Metformin 500 mg twice daily for type 2 diabetes mellitus" + assert NiceGuideline._clean_text(text) == text + + +class TestCombinedText: + def test_combined_text_includes_all_chapters(self, guideline): + from amfv_datasets.nice.fetcher import NiceChapter + + guideline.title = "Type 2 diabetes in adults: management" + guideline.chapters = [ + NiceChapter(title="Chapter A", url="https://x/a", slug="a", text="Text A content."), + NiceChapter(title="Chapter B", url="https://x/b", slug="b", text="Text B content."), + ] + combined = guideline.combined_text() + assert "Chapter A" in combined + assert "Text A content." in combined + assert "Chapter B" in combined + assert "Text B content." in combined + assert guideline.overview_url in combined + + +class TestSaveToDisk: + def test_save_writes_one_file_per_chapter_plus_combined(self, guideline, tmp_path): + from amfv_datasets.nice.fetcher import NiceChapter + + guideline.title = "Type 2 diabetes in adults: management" + guideline.chapters = [ + NiceChapter(title="Chapter A", url="https://x/a", slug="chapter-a", text="Content A"), + NiceChapter(title="Chapter B", url="https://x/b", slug="chapter-b", text="Content B"), + ] + guideline.save(tmp_path, verbose=False) + + written = sorted(p.name for p in tmp_path.glob("*.txt")) + assert written == ["01_chapter-a.txt", "02_chapter-b.txt", "ng28_combined.txt"] + + assert (tmp_path / "01_chapter-a.txt").read_text() == "Content A" + assert (tmp_path / "02_chapter-b.txt").read_text() == "Content B" + + def test_save_creates_out_dir_if_missing(self, guideline, tmp_path): + nested = tmp_path / "does" / "not" / "exist" + guideline.title = "Test" + guideline.chapters = [] + guideline.save(nested, verbose=False) + assert nested.exists() + + +class TestRetryAndResilience: + def test_get_retries_on_5xx_then_succeeds(self, guideline): + from unittest.mock import MagicMock, patch + + call_count = {"n": 0} + + def fake_get(url, timeout=30): + call_count["n"] += 1 + resp = MagicMock() + if call_count["n"] < 3: + resp.status_code = 502 + resp.raise_for_status.side_effect = requests.exceptions.HTTPError(response=resp) + else: + resp.status_code = 200 + resp.text = "ok" + resp.raise_for_status.side_effect = None + return resp + + guideline.delay = 0.01 + with patch.object(guideline.session, "get", side_effect=fake_get): + result = guideline._get("https://x/y", max_retries=3) + + assert result == "ok" + assert call_count["n"] == 3 + + def test_get_does_not_retry_4xx(self, guideline): + from unittest.mock import MagicMock, patch + + call_count = {"n": 0} + + def fake_get_404(url, timeout=30): + call_count["n"] += 1 + resp = MagicMock() + resp.status_code = 404 + resp.raise_for_status.side_effect = requests.exceptions.HTTPError(response=resp) + return resp + + guideline.delay = 0.01 + with patch.object(guideline.session, "get", side_effect=fake_get_404): + with pytest.raises(requests.exceptions.HTTPError): + guideline._get("https://x/y", max_retries=3) + + assert call_count["n"] == 1 + + def test_fetch_all_continues_past_failed_chapter(self, guideline, overview_html): + from unittest.mock import MagicMock, patch + + guideline.delay = 0.01 + + def fake_get(url, timeout=30): + resp = MagicMock() + if url == guideline.overview_url: + resp.status_code = 200 + resp.text = overview_html + resp.raise_for_status.side_effect = None + elif "Blood-glucose-management" in url: + resp.status_code = 502 + resp.raise_for_status.side_effect = requests.exceptions.HTTPError(response=resp) + else: + resp.status_code = 200 + resp.text = "

text

" + resp.raise_for_status.side_effect = None + return resp + + with patch.object(guideline.session, "get", side_effect=fake_get): + guideline.fetch_all(verbose=False, max_retries=2) + + assert len(guideline.failed_chapters) == 1 + assert guideline.failed_chapters[0][0] == "Blood-glucose-management" + assert len(guideline.chapters) > 0 + + def test_retry_failed_recovers_chapter(self, guideline): + from unittest.mock import MagicMock, patch + + guideline.delay = 0.01 + guideline.failed_chapters = [("Blood-glucose-management", "previous error")] + + def fake_get(url, timeout=30): + resp = MagicMock() + resp.status_code = 200 + resp.text = "

recovered text

" + resp.raise_for_status.side_effect = None + return resp + + with patch.object(guideline.session, "get", side_effect=fake_get): + guideline.retry_failed(verbose=False) + + assert guideline.failed_chapters == [] + assert len(guideline.chapters) == 1 + assert "recovered text" in guideline.chapters[0].text \ No newline at end of file diff --git a/scripts/qc/validate_spans.py b/scripts/qc/validate_spans.py new file mode 100644 index 0000000..6dbf4a3 --- /dev/null +++ b/scripts/qc/validate_spans.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import argparse +import difflib +import json +import re +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "datasets")) +from amfv_datasets.eval_prompts.retrieval import RetrievalItem + + +def normalize(text: str) -> str: + return re.sub(r"\s+", " ", text).strip() + + +def describe_mismatch(span: str, document: str, norm_document: str) -> str: + norm_span = normalize(span) + if norm_span in norm_document: + return f"whitespace-only difference: {span[:80]!r}" + + anchor = span[:40] + idx = document.find(anchor) + if idx == -1: + norm_anchor = normalize(anchor) + if normalize(anchor) in norm_document: + return f"anchor only matches after normalization: {span[:80]!r}" + return f"not found at all: {span[:80]!r}" + + doc_excerpt = document[idx: idx + len(span) + 40] + matcher = difflib.SequenceMatcher(None, span, doc_excerpt) + match = matcher.find_longest_match(0, len(span), 0, len(doc_excerpt)) + divergence = match.a + match.size + before = span[max(0, divergence - 15):divergence] + after = span[divergence:divergence + 20] + return f"diverges at char {divergence}: ...{before!r} vs document {after!r}" + + +def validate_file(document: str, items_path: Path, repair: bool) -> int: + norm_document = normalize(document) + records = [json.loads(line) for line in items_path.read_text(encoding="utf-8").splitlines() if line.strip()] + + total_repaired = 0 + total_unresolved = 0 + + for record in records: + item = RetrievalItem( + question=record["question"], + question_type=record["question_type"], + answer=record["answer"], + supporting_spans=record["supporting_spans"], + is_answerable=record["is_answerable"], + notes=record.get("notes", ""), + ) + + if repair: + total_repaired += item.repair_truncated_spans(document) + record["supporting_spans"] = item.supporting_spans + + unresolved = item.validate_spans(document) + item.find_truncated_spans(document) + status = "OK" if not unresolved else f"MISMATCH ({len(unresolved)}/{len(item.supporting_spans)})" + print(f"[{item.question_type:<15}] {status} {item.question[:60]}") + + for span in item.find_truncated_spans(document): + print(f" truncated before bullet list: {span[:90]!r}") + + for span in item.validate_spans(document): + print(f" {describe_mismatch(span, document, norm_document)}") + + total_unresolved += len(unresolved) + + if repair: + out_path = items_path.with_suffix(".repaired.jsonl") + out_path.write_text( + "\n".join(json.dumps(r, ensure_ascii=False) for r in records) + "\n", + encoding="utf-8", + ) + print(f"repaired {total_repaired} span(s), wrote {out_path}") + + return total_unresolved + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("source_doc", type=Path) + parser.add_argument("items_jsonl", type=Path) + parser.add_argument("--repair", action="store_true", help="Extend truncated spans in place and write a .repaired.jsonl copy.") + args = parser.parse_args() + + document = args.source_doc.read_text(encoding="utf-8") + unresolved = validate_file(document, args.items_jsonl, args.repair) + sys.exit(1 if unresolved else 0) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/uv.lock b/uv.lock index 43b9647..97a4adf 100644 --- a/uv.lock +++ b/uv.lock @@ -42,6 +42,16 @@ dev = [ name = "amfv-datasets" version = "0.0.1" source = { editable = "datasets" } +dependencies = [ + { name = "beautifulsoup4" }, + { name = "requests" }, +] + +[package.metadata] +requires-dist = [ + { name = "beautifulsoup4", specifier = ">=4.12" }, + { name = "requests", specifier = ">=2.31" }, +] [[package]] name = "amfv-decomposer" @@ -58,6 +68,85 @@ name = "amfv-verifier" version = "0.0.1" source = { editable = "verifier" } +[[package]] +name = "beautifulsoup4" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571, upload-time = "2026-06-07T16:44:20.453Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" }, +] + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -67,6 +156,15 @@ 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 = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -119,6 +217,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, ] +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + [[package]] name = "ruff" version = "0.15.16" @@ -143,3 +256,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8b/9e/02e845ef151b1dee585e55c4739f8e1734ae1d9f1221dff65761c162208b/ruff-0.15.16-py3-none-win_amd64.whl", hash = "sha256:408256017284eddf98fff77b29aa4fb30f586042d535b2d9befc6512f400aaec", size = 11843403, upload-time = "2026-06-04T16:32:39.76Z" }, { url = "https://files.pythonhosted.org/packages/15/19/016553f86f207450aebebc2b2b5088d086b901cc8186c02ac4284db3bd88/ruff-0.15.16-py3-none-win_arm64.whl", hash = "sha256:8cd61783afb39638a7133ef0d2dfb1e91277593962f81b5a8423eb0b888a6121", size = 11134555, upload-time = "2026-06-04T16:33:00.136Z" }, ] + +[[package]] +name = "soupsieve" +version = "2.8.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" }, +] + +[[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 = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +]