Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/bensah_rfp-intelligence-system.json

1 line
32 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "bensah/rfp-intelligence-system", "files": {"core/llm_judge.py": {"0572692684cd7d7540cc0982b4ce7965326e6e41": {"t": 1785674604.302548, "n": 15207, "h": "ed809885eba57689", "c": "\"\"\"LLM judge — OpenAI-compatible, model-swappable, vendor-neutral.\n\nWhat this is\n------------\nA thin semantic layer that reads a scraped candidate (title + body) plus the\norg's eligibility policy and returns ONE structured verdict: is this an open\nsolicitation, what type, deadline, amount, geographic scope, is the org's\ncountry eligible, is it on-theme. It is the \"perception\" layer of the hybrid\npipeline (regex pre-filter → THIS → ML decision); it never decides Proceed/\nPark/Decline — it extracts + adjudicates eligibility and hands typed fields on.\n\nWhy OpenAI-compatible (no vendor lock-in)\n-----------------------------------------\nIt talks to ANY OpenAI-compatible chat endpoint via the `openai` SDK's\n`base_url` override. Swap providers/models with env vars only — no code change:\n\n # OpenAI\n LLM_JUDGE_BASE_URL=https://api.openai.com/v1\n LLM_JUDGE_API_KEY=sk-...\n LLM_JUDGE_MODEL=gpt-4o-mini\n\n # OpenRouter (cheap hosted open-weight; switch model freely)\n LLM_JUDGE_BASE_URL=https://openrouter.ai/api/v1\n LLM_JUDGE_API_KEY=sk-or-...\n LLM_JUDGE_MODEL=qwen/qwen-2.5-7b-instruct\n\n # Together / Groq / DeepInfra — same shape, different base_url + model\n\n # Local Ollama (free, for dev) — pull a model first: `ollama pull llama3.1`\n LLM_JUDGE_BASE_URL=http://localhost:11434/v1\n LLM_JUDGE_API_KEY=ollama # any non-empty string; Ollama ignores it\n LLM_JUDGE_MODEL=llama3.1\n\nIf the SDK isn't installed or the env vars aren't set, `is_enabled()` is False\nand `judge()` returns None — callers fall back to the deterministic regex gates,\nso the app works with or without it (and degrades gracefully on Streamlit Cloud).\n\nGuardrails: temperature 0, hard timeout, bounded input/output tokens, JSON-only\nresponse (tolerant parse), per-run content-hash cache so re-scans don't re-pay.\nReturns null fields rather than guessing.\n\"\"\"\nfrom __future__ import annotations\n\nimport hashlib\nimport json\nimport logging\nimport os\nimport re\nfrom typing import Any\n\nfrom core import type_detect as _td\n\nlog = logging.getLogger(__name__)\n\n_DEFAULT_MODEL = \"gpt-4o-mini\"\n# Per-call timeout. Hosted models answer in 1-3s; a local CPU model (Ollama)\n# can take 30-90s, so make it tunable via LLM_JUDGE_TIMEOUT (seconds).\n_DEFAULT_TIMEOUT = 60\n_MAX_INPUT_CHARS = 6000 # ~1.5K tokens of body — keeps cost bounded\n# Reasoning models (gpt-oss, deepseek-r1, o-series) spend completion tokens on\n# an internal reasoning pass BEFORE emitting `content`; a tight cap gets eaten by\n# reasoning and the JSON never finishes (finish_reason=\"length\", empty content).\n# 2000 leaves room for reasoning + the ~250-token JSON. Harmless for plain models\n# (gpt-4o-mini stops at \"stop\" ~250 tokens — this is only a ceiling, not a target).\n_MAX_OUTPUT_TOKENS = 2000\n\n# Per-process cache: key = sha1(model + text) -> Judgment. Avoids re-paying for\n# the same page within/across scans in one worker. Cleared on restart.\n_CACHE: dict[str, dict[str, Any]] = {}\n\n# Per-process LLM call cap — bounds extraction time. gpt-oss:120b runs ~7-15s/call,\n# so an uncapped scan with hundreds of gap-candidates can run 20+ minutes on the LLM\n# alone (and brush the subprocess timeout). After the cap, judge() returns None →\n# the caller falls back to regex. Cache hits do NOT count. Tunable via env.\ntry:\n _MAX_CALLS = int(os.environ.get(\"LLM_JUDGE_MAX_CALLS\", \"60\") or 60)\nexcept ValueError:\n _MAX_CALLS = 60\n_calls = 0\n\n_ISO_DATE_RE = re.compile(r\"^\\d{4}-\\d{2}-\\d{2}$\")\n\n\ndef is_enabled() -> bool:\n \"\"\"True iff an endpoint + the openai SDK are available. Cheap probe.\n\n Only the BASE URL is required — a local Ollama endpoint ignores the API key, and\n requiring a non-empty key silently disabled the judge whenever the key var was\n unset. The key is defaulted to a placeholder in the client; a provider that truly\n needs one surfaces a visible auth error instead of going dark.\"\"\"\n if not os.environ.get(\"LLM_JUDGE_BASE_URL\"):\n return False\n try:\n import openai # noqa: F401, WPS433\n except ImportError:\n return False\n return True\n\n\ndef _org_context(policies: dict[str, Any]) -> str:\n \"\"\"Compact, human-readable summary of the org's eligibility policy, injected\n into the prompt so the model adjudicates against the SAME single source of\n truth the deterministic gates use (no logic duplicated in the prompt).\"\"\"\n countries = (policies or {}).get(\"countries\", {}) or {}\n eligible = \", \".join(c for c in (countries.get(\"eligible\") or []) if c) or \"(none set)\"\n broad = \", \".join(b for b in (countries.get(\"broad_terms\") or []) if b) or \"(none)\"\n themes = ((policies or {}).get(\"themes\", {}) or {}).get(\"required_any\") or []\n theme_sample = \", \".join(themes[:18]) + (\"…\" if len(themes) > 18 else \"\")\n return (\n f\"ORG ELIGIBLE COUNTRIES: {eligible}\\n\"\n f\"ORG BROAD REGIONS/TIERS: {broad}\\n\"\n f\"ORG THEME FOCUS (any of): {theme_sample}\\n\"\n )\n\n\ndef _build_messages(candidate: dict[str, Any], policies: dict[str, Any]) -> list[dict]:\n title = (candidate.get(\"opportunity_title\") or \"\")[:300]\n url = candidate.get(\"opportunity_link\") or \"\"\n body = (candidate.get(\"brief_description\") or candidate.get(\"_page_text\") or \"\")\n body = str(body).strip()[:_MAX_INPUT_CHARS]\n system = (\n \"You are a precise grants/RFP screening analyst. You read a web page and \"\n \"the funding org's eligibility policy, then return ONE JSON object — no \"\n \"prose, no markdown fences. When unsure, use null / false rather than \"\n \"guessing. Judge GEOGRAPHY by whether the org's country qualifies: a call \"\n \"open to a region or development tier that CONTAINS the org's country is \"\n \"eligible; a call scoped to a different region/country is not.\"\n )\n user = (\n f\"{_org_context(policies)}\\n\"\n f\"PAGE TITLE: {title}\\n\"\n f\"PAGE URL: {url}\\n\"\n f\"PAGE TEXT (truncated):\\n<<<\\n{body}\\n>>>\\n\\n\"\n \"Return a JSON object with EXACTLY these keys:\\n\"\n ' \"is_open_call\": true if this is an OPEN funding solicitation an org can '\n \"apply to (RFP/CFP/EOI/NOFO/grant/tender/procurement notice). false for \"\n \"news, grantee profiles, awarded-grant lists, guidance, donor-investment \"\n \"pages, closed/past calls.\\n\"\n ' \"solicitation_type\": one of NOFO, RFP, RFA, CFP, CFA, CfCN, EOI, LOI, RFI, '\n \"RFQ, Tender, Bid, ITB, Procurement notice, Unsolicited, Challenge, Other — \"\n \"or null. NORMALISE any wording/acronym to the canonical type using these \"\n \"synonyms: \" + _td.synonym_hint(\"solicitation\") + \".\\n\"\n ' \"instrument_type\": one of Grant, Cooperative Agreement, Contract, Loan, '\n \"Equity/Investment, Prize/Award, Fellowship, Scholarship, Seed fund, \"\n \"In-kind/TA, Other — or null. NORMALISE using these synonyms: \"\n + _td.synonym_hint(\"instrument\") + \".\\n\"\n ' \"call_submission_deadline\": final closing date as \"YYYY-MM-DD\", or null if '\n \"none / rolling / clearly past.\\n\"\n ' \"is_closed\": true if the page says the call is closed/concluded/past.\\n'\n ' \"call_award_value\": numeric award amount (no symbols), or null. READ '\n \"amounts written in prose too — e.g. 'up to $200,000', 'grants of EUR 1–3 \"\n \"million', 'maximum award US$2M'. For staged calls use the LARGEST tier.\\n\"\n ' \"currency\": ISO code (USD/EUR/GBP…) or null.\\n'\n ' \"funding_tiers\": array of {\"stage\",\"amount_min\",\"amount_max\",\"currency\"} '\n \"when the call funds in STAGES with different ceilings (e.g. 'Proof of \"\n \"Concept up to $200,000' + 'Transition to Scale up to $2,000,000' -> two \"\n \"objects). [] for a single-amount call. amounts numeric, no symbols.\\n\"\n ' \"call_geographic_scope\": array of the call\\'s ELIGIBLE / TARGET geography '\n \"ONLY. CRITICAL — distinguish eligibility from mere mentions: do NOT list \"\n \"countries that appear only as EXAMPLES, STATISTICS, or BACKGROUND (cue words \"\n \"'such as', 'including', 'e.g.', 'for example', 'classified as', 'ranked', \"\n \"'countries like') — e.g. a sentence naming '8 countries classified by WHO as \"\n \"ML3' is background, NOT the eligible list. When the call's scope is a REGION \"\n \"stated even implicitly (e.g. 'cooperation in SSA', 'across sub-Saharan Africa', \"\n \"'to strengthen systems in the region'), return that REGION (e.g. \\\"Sub-Saharan \"\n \"Africa\\\") rather than guessing a country list. List individual COUNTRIES only \"\n \"when the call actually ENUMERATES them as eligible; include both a region label \"\n \"and its listed eligible countries when both are genuinely given. Prefer the \"\n \"broad region/tier when the scope is regional. Use the term as written; [] if \"\n \"the call states no eligible geography.\\n\"\n ' \"country_eligible\": true if the org\\'s eligible country qualifies under '\n \"the call's geography, else false, or null if the page states NO eligibility \"\n \"geography at all. DECISION RULES, in order: (1) SPECIFIC COUNTRIES WIN OVER A \"\n \"BROAD REGION — if the call names specific eligible country(ies), the org \"\n \"qualifies ONLY when one of the ORG ELIGIBLE COUNTRIES is among them; a broad \"\n \"region label like 'Sub-Saharan Africa' that also appears does NOT make the org \"\n \"eligible (e.g. a call for the DRC that also says 'Sub-Saharan Africa' is NOT \"\n \"eligible for a Cameroon org). (2) Only when NO specific eligible countries are \"\n \"named does a containing region/tier (Sub-Saharan Africa, Africa, LMICs, Global \"\n \"South, worldwide/international) make the org eligible. (3) IGNORE incidental \"\n \"geography — timezones, a country named only as an example/statistic/background, \"\n \"or the funder's own location — when deciding eligibility.\\n\"\n ' \"theme_relevant\": true if the call fits the org THEME FOCUS above '\n \"(consider the whole text, not just the title).\\n\"\n ' \"matched_areas\": array of the org theme terms the call matches; [].\\n'\n ' \"confidence\": \"high\" | \"medium\" | \"low\".\\n'\n ' \"reason\": one short sentence justifying is_open_call / eligibility.\\n'\n )\n return [{\"role\": \"system\", \"content\": system}, {\"role\": \"user\", \"content\": user}]\n\n\ndef judge(candidate: dict[str, Any], policies: dict[str, Any],\n model: str | None = None) -> dict[str, Any] | None:\n \"\"\"Return a structured Judgment dict, or None if disabled / failed (caller\n then falls back to the deterministic gates). Cached per (model, content).\"\"\"\n if not is_enabled():\n return None\n title = candidate.get(\"opportunity_title\") or \"\"\n body = candidate.get(\"brief_description\") or candidate.get(\"_page_text\") or \"\"\n if not (title or body):\n return None\n chosen = model or os.environ.get(\"LLM_JUDGE_MODEL\", _DEFAULT_MODEL)\n ckey = hashlib.sha1(\n (chosen + \"|\" + title + \"|\" + str(body)[:_MAX_INPUT_CHARS]).encode(\"utf-8\")\n ).hexdigest()\n if ckey in _CACHE:\n return _CACHE[ckey]\n\n global _calls\n if _calls >= _MAX_CALLS: # per-process budget exhausted → regex fallback\n return None\n _calls += 1\n\n try:\n from openai import OpenAI # noqa: WPS433 (lazy, optional dep)\n try:\n timeout = float(os.environ.get(\"LLM_JUDGE_TIMEOUT\", _DEFAULT_TIMEOUT))\n except ValueError:\n timeout = _DEFAULT_TIMEOUT\n client = OpenAI(\n base_url=os.environ[\"LLM_JUDGE_BASE_URL\"],\n # Placeholder key for endpoints that ignore it (local Ollama); a provider\n # that requires a real key surfaces a visible auth error, never a silent skip.\n api_key=os.environ.get(\"LLM_JUDGE_API_KEY\") or \"ollama\",\n timeout=timeout,\n max_retries=0, # one shot — we fall back to regex on failure\n )\n resp = client.chat.completions.create(\n model=chosen,\n messages=_build_messages(candidate, policies),\n temperature=0,\n max_tokens=_MAX_OUTPUT_TOKENS,\n )\n raw = (resp.choices[0].message.content or \"\") if resp.choices else \"\"\n except Exception as exc:\n log.warning(\"llm_judge failed (%s): %s: %s\",\n candidate.get(\"opportunity_link\"), type(exc).__name__, exc)\n return None\n\n parsed = _parse_json_block(raw)\n if not parsed:\n log.info(\"llm_judge non-JSON for %s: %r\",\n candidate.get(\"opportunity_link\"), raw[:200])\n return None\n\n out = _normalise(parsed, chosen)\n _CACHE[ckey] = out\n return out\n\n\n# ---------------------------------------------------------------------------\n# Parsing / normalisation\n# ---------------------------------------------------------------------------\ndef _normalise(p: dict[str, Any], model: str) -> dict[str, Any]:\n dl = p.get(\"call_submission_deadline\")\n if not (isinstance(dl, str) and _ISO_DATE_RE.match(dl)):\n dl = None\n val = p.get(\"call_award_value\")\n try:\n val = float(val) if val is not None and str(val) != \"\" else None\n except (TypeError, ValueError):\n val = None\n scope = p.get(\"call_geographic_scope\")\n scope = [str(s) for s in scope if s] if isinstance(scope, list) else []\n areas = p.get(\"matched_areas\")\n areas = [str(s) for s in areas if s] if isinstance(areas, list) else []\n tiers = p.get(\"funding_tiers\")\n tiers = [t for t in tiers if isinstance(t, dict)] if isinstance(tiers, list) else []\n return {\n \"is_open_call\": bool(p.get(\"is_open_call\")),\n \"is_closed\": bool(p.get(\"is_closed\")),\n \"solicitation_type\": _s(p.get(\"solicitation_type\")),\n \"instrument_type\": _s(p.get(\"instrument_type\")),\n \"call_submission_deadline\": dl,\n \"call_award_value\": val,\n \"currency\": _s(p.get(\"currency\")),\n \"funding_tiers\": tiers,\n \"call_geographic_scope\": scope,\n \"country_eligible\": _tri(p.get(\"country_eligible\")),\n \"theme_relevant\": bool(p.get(\"theme_relevant\")),\n \"matched_areas\": areas,\n \"confidence\": (_s(p.get(\"confidence\")) or \"low\").lower(),\n \"reason\": _s(p.get(\"reason\")),\n \"_llm_model\": model,\n }\n\n\ndef _parse_json_block(raw: str) -> dict[str, Any] | None:\n if not raw:\n return None\n s = raw.strip()\n s = re.sub(r\"^```(?:json)?\\s*\", \"\", s)\n s = re.sub(r\"\\s*```$\", \"\", s)\n i, j = s.find(\"{\"), s.rfind(\"}\")\n if i < 0 or j < i:\n return None\n try:\n obj = json.loads(s[i:j + 1])\n return obj if isinstance(obj, dict) else None\n except json.JSONDecodeError:\n return None\n\n\ndef _s(v: Any) -> str | None:\n if v is None:\n return None\n out = str(v).strip()\n return out or None\n\n\ndef _tri(v: Any) -> bool | None:\n \"\"\"Tri-state bool: True/False/None (None = 'page states no geography').\"\"\"\n if v is None:\n return None\n if isinstance(v, bool):\n return v\n t = str(v).strip().lower()\n if t in (\"true\", \"yes\", \"eligible\"):\n return True\n if t in (\"false\", \"no\", \"ineligible\"):\n return False\n return None\n"}, "d6ad22631b11973088baab6cd0af5bfeff5e7d8e": {"t": 1785846899.697383, "n": 15207, "h": "ed809885eba57689", "c": "\"\"\"LLM judge — OpenAI-compatible, model-swappable, vendor-neutral.\n\nWhat this is\n------------\nA thin semantic layer that reads a scraped candidate (title + body) plus the\norg's eligibility policy and returns ONE structured verdict: is this an open\nsolicitation, what type, deadline, amount, geographic scope, is the org's\ncountry eligible, is it on-theme. It is the \"perception\" layer of the hybrid\npipeline (regex pre-filter → THIS → ML decision); it never decides Proceed/\nPark/Decline — it extracts + adjudicates eligibility and hands typed fields on.\n\nWhy OpenAI-compatible (no vendor lock-in)\n-----------------------------------------\nIt talks to ANY OpenAI-compatible chat endpoint via the `openai` SDK's\n`base_url` override. Swap providers/models with env vars only — no code change:\n\n # OpenAI\n LLM_JUDGE_BASE_URL=https://api.openai.com/v1\n LLM_JUDGE_API_KEY=sk-...\n LLM_JUDGE_MODEL=gpt-4o-mini\n\n # OpenRouter (cheap hosted open-weight; switch model freely)\n LLM_JUDGE_BASE_URL=https://openrouter.ai/api/v1\n LLM_JUDGE_API_KEY=sk-or-...\n LLM_JUDGE_MODEL=qwen/qwen-2.5-7b-instruct\n\n # Together / Groq / DeepInfra — same shape, different base_url + model\n\n # Local Ollama (free, for dev) — pull a model first: `ollama pull llama3.1`\n LLM_JUDGE_BASE_URL=http://localhost:11434/v1\n LLM_JUDGE_API_KEY=ollama # any non-empty string; Ollama ignores it\n LLM_JUDGE_MODEL=llama3.1\n\nIf the SDK isn't installed or the env vars aren't set, `is_enabled()` is False\nand `judge()` returns None — callers fall back to the deterministic regex gates,\nso the app works with or without it (and degrades gracefully on Streamlit Cloud).\n\nGuardrails: temperature 0, hard timeout, bounded input/output tokens, JSON-only\nresponse (tolerant parse), per-run content-hash cache so re-scans don't re-pay.\nReturns null fields rather than guessing.\n\"\"\"\nfrom __future__ import annotations\n\nimport hashlib\nimport json\nimport logging\nimport os\nimport re\nfrom typing import Any\n\nfrom core import type_detect as _td\n\nlog = logging.getLogger(__name__)\n\n_DEFAULT_MODEL = \"gpt-4o-mini\"\n# Per-call timeout. Hosted models answer in 1-3s; a local CPU model (Ollama)\n# can take 30-90s, so make it tunable via LLM_JUDGE_TIMEOUT (seconds).\n_DEFAULT_TIMEOUT = 60\n_MAX_INPUT_CHARS = 6000 # ~1.5K tokens of body — keeps cost bounded\n# Reasoning models (gpt-oss, deepseek-r1, o-series) spend completion tokens on\n# an internal reasoning pass BEFORE emitting `content`; a tight cap gets eaten by\n# reasoning and the JSON never finishes (finish_reason=\"length\", empty content).\n# 2000 leaves room for reasoning + the ~250-token JSON. Harmless for plain models\n# (gpt-4o-mini stops at \"stop\" ~250 tokens — this is only a ceiling, not a target).\n_MAX_OUTPUT_TOKENS = 2000\n\n# Per-process cache: key = sha1(model + text) -> Judgment. Avoids re-paying for\n# the same page within/across scans in one worker. Cleared on restart.\n_CACHE: dict[str, dict[str, Any]] = {}\n\n# Per-process LLM call cap — bounds extraction time. gpt-oss:120b runs ~7-15s/call,\n# so an uncapped scan with hundreds of gap-candidates can run 20+ minutes on the LLM\n# alone (and brush the subprocess timeout). After the cap, judge() returns None →\n# the caller falls back to regex. Cache hits do NOT count. Tunable via env.\ntry:\n _MAX_CALLS = int(os.environ.get(\"LLM_JUDGE_MAX_CALLS\", \"60\") or 60)\nexcept ValueError:\n _MAX_CALLS = 60\n_calls = 0\n\n_ISO_DATE_RE = re.compile(r\"^\\d{4}-\\d{2}-\\d{2}$\")\n\n\ndef is_enabled() -> bool:\n \"\"\"True iff an endpoint + the openai SDK are available. Cheap probe.\n\n Only the BASE URL is required — a local Ollama endpoint ignores the API key, and\n requiring a non-empty key silently disabled the judge whenever the key var was\n unset. The key is defaulted to a placeholder in the client; a provider that truly\n needs one surfaces a visible auth error instead of going dark.\"\"\"\n if not os.environ.get(\"LLM_JUDGE_BASE_URL\"):\n return False\n try:\n import openai # noqa: F401, WPS433\n except ImportError:\n return False\n return True\n\n\ndef _org_context(policies: dict[str, Any]) -> str:\n \"\"\"Compact, human-readable summary of the org's eligibility policy, injected\n into the prompt so the model adjudicates against the SAME single source of\n truth the deterministic gates use (no logic duplicated in the prompt).\"\"\"\n countries = (policies or {}).get(\"countries\", {}) or {}\n eligible = \", \".join(c for c in (countries.get(\"eligible\") or []) if c) or \"(none set)\"\n broad = \", \".join(b for b in (countries.get(\"broad_terms\") or []) if b) or \"(none)\"\n themes = ((policies or {}).get(\"themes\", {}) or {}).get(\"required_any\") or []\n theme_sample = \", \".join(themes[:18]) + (\"…\" if len(themes) > 18 else \"\")\n return (\n f\"ORG ELIGIBLE COUNTRIES: {eligible}\\n\"\n f\"ORG BROAD REGIONS/TIERS: {broad}\\n\"\n f\"ORG THEME FOCUS (any of): {theme_sample}\\n\"\n )\n\n\ndef _build_messages(candidate: dict[str, Any], policies: dict[str, Any]) -> list[dict]:\n title = (candidate.get(\"opportunity_title\") or \"\")[:300]\n url = candidate.get(\"opportunity_link\") or \"\"\n body = (candidate.get(\"brief_description\") or candidate.get(\"_page_text\") or \"\")\n body = str(body).strip()[:_MAX_INPUT_CHARS]\n system = (\n \"You are a precise grants/RFP screening analyst. You read a web page and \"\n \"the funding org's eligibility policy, then return ONE JSON object — no \"\n \"prose, no markdown fences. When unsure, use null / false rather than \"\n \"guessing. Judge GEOGRAPHY by whether the org's country qualifies: a call \"\n \"open to a region or development tier that CONTAINS the org's country is \"\n \"eligible; a call scoped to a different region/country is not.\"\n )\n user = (\n f\"{_org_context(policies)}\\n\"\n f\"PAGE TITLE: {title}\\n\"\n f\"PAGE URL: {url}\\n\"\n f\"PAGE TEXT (truncated):\\n<<<\\n{body}\\n>>>\\n\\n\"\n \"Return a JSON object with EXACTLY these keys:\\n\"\n ' \"is_open_call\": true if this is an OPEN funding solicitation an org can '\n \"apply to (RFP/CFP/EOI/NOFO/grant/tender/procurement notice). false for \"\n \"news, grantee profiles, awarded-grant lists, guidance, donor-investment \"\n \"pages, closed/past calls.\\n\"\n ' \"solicitation_type\": one of NOFO, RFP, RFA, CFP, CFA, CfCN, EOI, LOI, RFI, '\n \"RFQ, Tender, Bid, ITB, Procurement notice, Unsolicited, Challenge, Other — \"\n \"or null. NORMALISE any wording/acronym to the canonical type using these \"\n \"synonyms: \" + _td.synonym_hint(\"solicitation\") + \".\\n\"\n ' \"instrument_type\": one of Grant, Cooperative Agreement, Contract, Loan, '\n \"Equity/Investment, Prize/Award, Fellowship, Scholarship, Seed fund, \"\n \"In-kind/TA, Other — or null. NORMALISE using these synonyms: \"\n + _td.synonym_hint(\"instrument\") + \".\\n\"\n ' \"call_submission_deadline\": final closing date as \"YYYY-MM-DD\", or null if '\n \"none / rolling / clearly past.\\n\"\n ' \"is_closed\": true if the page says the call is closed/concluded/past.\\n'\n ' \"call_award_value\": numeric award amount (no symbols), or null. READ '\n \"amounts written in prose too — e.g. 'up to $200,000', 'grants of EUR 1–3 \"\n \"million', 'maximum award US$2M'. For staged calls use the LARGEST tier.\\n\"\n ' \"currency\": ISO code (USD/EUR/GBP…) or null.\\n'\n ' \"funding_tiers\": array of {\"stage\",\"amount_min\",\"amount_max\",\"currency\"} '\n \"when the call funds in STAGES with different ceilings (e.g. 'Proof of \"\n \"Concept up to $200,000' + 'Transition to Scale up to $2,000,000' -> two \"\n \"objects). [] for a single-amount call. amounts numeric, no symbols.\\n\"\n ' \"call_geographic_scope\": array of the call\\'s ELIGIBLE / TARGET geography '\n \"ONLY. CRITICAL — distinguish eligibility from mere mentions: do NOT list \"\n \"countries that appear only as EXAMPLES, STATISTICS, or BACKGROUND (cue words \"\n \"'such as', 'including', 'e.g.', 'for example', 'classified as', 'ranked', \"\n \"'countries like') — e.g. a sentence naming '8 countries classified by WHO as \"\n \"ML3' is background, NOT the eligible list. When the call's scope is a REGION \"\n \"stated even implicitly (e.g. 'cooperation in SSA', 'across sub-Saharan Africa', \"\n \"'to strengthen systems in the region'), return that REGION (e.g. \\\"Sub-Saharan \"\n \"Africa\\\") rather than guessing a country list. List individual COUNTRIES only \"\n \"when the call actually ENUMERATES them as eligible; include both a region label \"\n \"and its listed eligible countries when both are genuinely given. Prefer the \"\n \"broad region/tier when the scope is regional. Use the term as written; [] if \"\n \"the call states no eligible geography.\\n\"\n ' \"country_eligible\": true if the org\\'s eligible country qualifies under '\n \"the call's geography, else false, or null if the page states NO eligibility \"\n \"geography at all. DECISION RULES, in order: (1) SPECIFIC COUNTRIES WIN OVER A \"\n \"BROAD REGION — if the call names specific eligible country(ies), the org \"\n \"qualifies ONLY when one of the ORG ELIGIBLE COUNTRIES is among them; a broad \"\n \"region label like 'Sub-Saharan Africa' that also appears does NOT make the org \"\n \"eligible (e.g. a call for the DRC that also says 'Sub-Saharan Africa' is NOT \"\n \"eligible for a Cameroon org). (2) Only when NO specific eligible countries are \"\n \"named does a containing region/tier (Sub-Saharan Africa, Africa, LMICs, Global \"\n \"South, worldwide/international) make the org eligible. (3) IGNORE incidental \"\n \"geography — timezones, a country named only as an example/statistic/background, \"\n \"or the funder's own location — when deciding eligibility.\\n\"\n ' \"theme_relevant\": true if the call fits the org THEME FOCUS above '\n \"(consider the whole text, not just the title).\\n\"\n ' \"matched_areas\": array of the org theme terms the call matches; [].\\n'\n ' \"confidence\": \"high\" | \"medium\" | \"low\".\\n'\n ' \"reason\": one short sentence justifying is_open_call / eligibility.\\n'\n )\n return [{\"role\": \"system\", \"content\": system}, {\"role\": \"user\", \"content\": user}]\n\n\ndef judge(candidate: dict[str, Any], policies: dict[str, Any],\n model: str | None = None) -> dict[str, Any] | None:\n \"\"\"Return a structured Judgment dict, or None if disabled / failed (caller\n then falls back to the deterministic gates). Cached per (model, content).\"\"\"\n if not is_enabled():\n return None\n title = candidate.get(\"opportunity_title\") or \"\"\n body = candidate.get(\"brief_description\") or candidate.get(\"_page_text\") or \"\"\n if not (title or body):\n return None\n chosen = model or os.environ.get(\"LLM_JUDGE_MODEL\", _DEFAULT_MODEL)\n ckey = hashlib.sha1(\n (chosen + \"|\" + title + \"|\" + str(body)[:_MAX_INPUT_CHARS]).encode(\"utf-8\")\n ).hexdigest()\n if ckey in _CACHE:\n return _CACHE[ckey]\n\n global _calls\n if _calls >= _MAX_CALLS: # per-process budget exhausted → regex fallback\n return None\n _calls += 1\n\n try:\n from openai import OpenAI # noqa: WPS433 (lazy, optional dep)\n try:\n timeout = float(os.environ.get(\"LLM_JUDGE_TIMEOUT\", _DEFAULT_TIMEOUT))\n except ValueError:\n timeout = _DEFAULT_TIMEOUT\n client = OpenAI(\n base_url=os.environ[\"LLM_JUDGE_BASE_URL\"],\n # Placeholder key for endpoints that ignore it (local Ollama); a provider\n # that requires a real key surfaces a visible auth error, never a silent skip.\n api_key=os.environ.get(\"LLM_JUDGE_API_KEY\") or \"ollama\",\n timeout=timeout,\n max_retries=0, # one shot — we fall back to regex on failure\n )\n resp = client.chat.completions.create(\n model=chosen,\n messages=_build_messages(candidate, policies),\n temperature=0,\n max_tokens=_MAX_OUTPUT_TOKENS,\n )\n raw = (resp.choices[0].message.content or \"\") if resp.choices else \"\"\n except Exception as exc:\n log.warning(\"llm_judge failed (%s): %s: %s\",\n candidate.get(\"opportunity_link\"), type(exc).__name__, exc)\n return None\n\n parsed = _parse_json_block(raw)\n if not parsed:\n log.info(\"llm_judge non-JSON for %s: %r\",\n candidate.get(\"opportunity_link\"), raw[:200])\n return None\n\n out = _normalise(parsed, chosen)\n _CACHE[ckey] = out\n return out\n\n\n# ---------------------------------------------------------------------------\n# Parsing / normalisation\n# ---------------------------------------------------------------------------\ndef _normalise(p: dict[str, Any], model: str) -> dict[str, Any]:\n dl = p.get(\"call_submission_deadline\")\n if not (isinstance(dl, str) and _ISO_DATE_RE.match(dl)):\n dl = None\n val = p.get(\"call_award_value\")\n try:\n val = float(val) if val is not None and str(val) != \"\" else None\n except (TypeError, ValueError):\n val = None\n scope = p.get(\"call_geographic_scope\")\n scope = [str(s) for s in scope if s] if isinstance(scope, list) else []\n areas = p.get(\"matched_areas\")\n areas = [str(s) for s in areas if s] if isinstance(areas, list) else []\n tiers = p.get(\"funding_tiers\")\n tiers = [t for t in tiers if isinstance(t, dict)] if isinstance(tiers, list) else []\n return {\n \"is_open_call\": bool(p.get(\"is_open_call\")),\n \"is_closed\": bool(p.get(\"is_closed\")),\n \"solicitation_type\": _s(p.get(\"solicitation_type\")),\n \"instrument_type\": _s(p.get(\"instrument_type\")),\n \"call_submission_deadline\": dl,\n \"call_award_value\": val,\n \"currency\": _s(p.get(\"currency\")),\n \"funding_tiers\": tiers,\n \"call_geographic_scope\": scope,\n \"country_eligible\": _tri(p.get(\"country_eligible\")),\n \"theme_relevant\": bool(p.get(\"theme_relevant\")),\n \"matched_areas\": areas,\n \"confidence\": (_s(p.get(\"confidence\")) or \"low\").lower(),\n \"reason\": _s(p.get(\"reason\")),\n \"_llm_model\": model,\n }\n\n\ndef _parse_json_block(raw: str) -> dict[str, Any] | None:\n if not raw:\n return None\n s = raw.strip()\n s = re.sub(r\"^```(?:json)?\\s*\", \"\", s)\n s = re.sub(r\"\\s*```$\", \"\", s)\n i, j = s.find(\"{\"), s.rfind(\"}\")\n if i < 0 or j < i:\n return None\n try:\n obj = json.loads(s[i:j + 1])\n return obj if isinstance(obj, dict) else None\n except json.JSONDecodeError:\n return None\n\n\ndef _s(v: Any) -> str | None:\n if v is None:\n return None\n out = str(v).strip()\n return out or None\n\n\ndef _tri(v: Any) -> bool | None:\n \"\"\"Tri-state bool: True/False/None (None = 'page states no geography').\"\"\"\n if v is None:\n return None\n if isinstance(v, bool):\n return v\n t = str(v).strip().lower()\n if t in (\"true\", \"yes\", \"eligible\"):\n return True\n if t in (\"false\", \"no\", \"ineligible\"):\n return False\n return None\n"}}}}