Task: workarena.servicenow.filter-change-request-list
Goal: Create a filter for the change request list to extract all entries where:
    - "Risk" is "Moderate" or 
    - "Assignment group" is "Hardware" or 
    - "Type" is "Emergency"
Task configuration: {'table_name': 'change_request', 'filter_kind': 'OR', 'filter_len': 3, 'filter_columns': ['risk', 'assignment_group', 'type'], 'filter_values': ['Moderate', 'Hardware', 'Emergency'], 'filter_operators': ['is', 'is', 'is'], 'columns': {'assigned_to': {'attributes': 'encode_utf8=false', 'base_type': 'GUID', 'cangroup': True, 'canmatch': True, 'cansort': True, 'column_type': 'element', 'default': '', 'filterable': True, 'hint': 'Person primarily responsible for working this task', 'internal_type': 'reference', 'label': 'Assigned to', 'mandatory': False, 'max_length': 32, 'max_unit': 'days', 'name': 'assigned_to', 'read_only': False, 'reference': 'sys_user', 'reference_attributes': {'display_field': 'name', 'ref_ac_columns': ['email'], 'ref_ac_columns_search': 'true', 'ref_ac_display_value': True, 'ref_auto_completer': 'AJAXTableCompleter'}, 'reference_display_field': 'name', 'type': 'reference'}, 'assignment_group': {'attributes': 'encode_utf8=false,tree_picker=true', 'base_type': 'GUID', 'cangroup': True, 'canmatch': True, 'cansort': True, 'column_type': 'element', 'default': '', 'filterable': True, 'hint': '', 'internal_type': 'reference', 'label': 'Assignment group', 'mandatory': False, 'max_length': 32, 'max_unit': 'days', 'name': 'assignment_group', 'read_only': False, 'reference': 'sys_user_group', 'reference_attributes': {'display_field': 'name', 'ref_ac_columns': [], 'ref_ac_columns_search': '', 'ref_ac_display_value': True, 'ref_auto_completer': ''}, 'reference_display_field': 'name', 'type': 'reference'}, 'chg_model': {'attributes': 'encode_utf8=false', 'base_type': 'GUID', 'cangroup': True, 'canmatch': True, 'cansort': True, 'column_type': 'element', 'default': '', 'dependent_on_field': '', 'element': 'chg_model', 'filterable': True, 'hint': '', 'internal_type': 'reference', 'label': 'Model', 'mandatory': False, 'max_length': 32, 'max_unit': 'days', 'name': 'chg_model', 'read_only': False, 'reference': 'chg_model', 'reference_attributes': {'display_field': 'name', 'ref_ac_columns': [], 'ref_ac_columns_search': '', 'ref_ac_display_value': True, 'ref_auto_completer': ''}, 'reference_display_field': 'name', 'type': 'reference'}, 'end_date': {'attributes': 'edge_encryption_enabled=true', 'base_type': 'datetime', 'canmatch': True, 'cansort': True, 'column_type': 'element', 'default': '', 'dependent_on_field': '', 'element': 'end_date', 'filterable': True, 'hint': '', 'internal_type': 'glide_date_time', 'label': 'Planned end date', 'mandatory': False, 'max_length': 40, 'max_unit': 'days', 'name': 'end_date', 'read_only': False, 'type': 'glide_date_time'}, 'number': {'attributes': 'edge_encryption_enabled=true,ignore_filter_on_new=true,ts_weight=50', 'base_type': 'string', 'cangroup': True, 'canmatch': True, 'cansort': True, 'column_type': 'element', 'default': '', 'filterable': True, 'hint': '', 'internal_type': 'string', 'label': 'Number', 'mandatory': False, 'max_length': 40, 'max_unit': 'days', 'name': 'number', 'read_only': False, 'type': 'string'}, 'requested_by': {'attributes': 'encode_utf8=false', 'base_type': 'GUID', 'cangroup': True, 'canmatch': True, 'cansort': True, 'column_type': 'element', 'default': '', 'dependent_on_field': '', 'element': 'requested_by', 'filterable': True, 'hint': '', 'internal_type': 'reference', 'label': 'Requested by', 'mandatory': False, 'max_length': 32, 'max_unit': 'days', 'name': 'requested_by', 'read_only': False, 'reference': 'sys_user', 'reference_attributes': {'display_field': 'name', 'ref_ac_columns': ['email'], 'ref_ac_columns_search': 'true', 'ref_ac_display_value': True, 'ref_auto_completer': 'AJAXTableCompleter'}, 'reference_display_field': 'name', 'type': 'reference'}, 'risk': {'attributes': 'field_decorations=change_risk_details', 'base_type': 'string', 'cangroup': True, 'canmatch': True, 'cansort': True, 'choice_type': 3, 'choices': {'2': 'High', '3': 'Moderate', '4': 'Low'}, 'column_type': 'element', 'default': '', 'dependent_on_field': '', 'element': 'risk', 'filterable': True, 'hint': '', 'internal_type': 'integer', 'label': 'Risk', 'mandatory': False, 'max_length': 40, 'max_unit': 'days', 'name': 'risk', 'read_only': False, 'type': 'choice'}, 'short_description': {'attributes': 'edge_encryption_enabled=true,knowledge_search=true,no_truncate=true,ts_weight=10', 'base_type': 'string', 'cangroup': True, 'canmatch': True, 'cansort': True, 'column_type': 'element', 'default': '', 'filterable': True, 'hint': '', 'internal_type': 'string', 'label': 'Short description', 'mandatory': False, 'max_length': 160, 'max_unit': 'days', 'name': 'short_description', 'read_only': False, 'type': 'string'}, 'start_date': {'attributes': 'edge_encryption_enabled=true', 'base_type': 'datetime', 'canmatch': True, 'cansort': True, 'column_type': 'element', 'default': '', 'dependent_on_field': '', 'element': 'start_date', 'filterable': True, 'hint': '', 'internal_type': 'glide_date_time', 'label': 'Planned start date', 'mandatory': False, 'max_length': 40, 'max_unit': 'days', 'name': 'start_date', 'read_only': False, 'type': 'glide_date_time'}, 'state': {'attributes': 'close_states=3;4;7,default_close_state=3,default_work_state=-5', 'base_type': 'string', 'cangroup': True, 'canmatch': True, 'cansort': True, 'choice_type': 3, 'choices': {'-1': 'Implement', '-2': 'Scheduled', '-3': 'Authorize', '-4': 'Assess', '-5': 'New', '0': 'Review', '3': 'Closed', '4': 'Canceled'}, 'column_type': 'element', 'default': '', 'filterable': True, 'hint': '', 'internal_type': 'integer', 'label': 'State', 'mandatory': False, 'max_length': 40, 'max_unit': 'days', 'name': 'state', 'read_only': False, 'type': 'choice'}, 'sys_created_on': {'attributes': 'edge_encryption_enabled=true', 'base_type': 'datetime', 'canmatch': True, 'cansort': True, 'column_type': 'element', 'default': '', 'filterable': True, 'hint': '', 'internal_type': 'glide_date_time', 'label': 'Created', 'mandatory': False, 'max_length': 40, 'max_unit': 'days', 'name': 'sys_created_on', 'read_only': False, 'type': 'glide_date_time'}, 'type': {'attributes': 'edge_encryption_enabled=true', 'base_type': 'string', 'cangroup': True, 'canmatch': True, 'cansort': True, 'choice_type': 1, 'choices': {'': '-- None --', 'emergency': 'Emergency', 'normal': 'Normal', 'standard': 'Standard'}, 'column_type': 'element', 'default': '', 'dependent_on_field': '', 'element': 'type', 'filterable': True, 'hint': '', 'internal_type': 'string', 'label': 'Type', 'mandatory': False, 'max_length': 40, 'max_unit': 'days', 'name': 'type', 'read_only': False, 'type': 'choice'}}}

Validation code:
import json
import os
import re
import urllib.parse as parse
from typing import Any, Dict, List, Tuple

import requests


def validate(messages: List[Dict[str, str]], config: Dict[str, Any]) -> int:
    """
    Validate a ServiceNow-style filter encoded in the 'sysparm_query' of the first URL
    found in the content of the LAST message.

    Config schema (example keys; provide what you need):
      {
        # URL check (optional; if present, enforce)
        "expected_url_prefix": "https://instance.service-now.com/now/nav/ui/classic/...",  # optional
        "expected_url_suffix": "/nav_to.do?uri=...",                                        # optional

        # Filter expectations
        "filter_kind": "AND" or "OR",
        "filter_len": 2,
        "filter_columns": ["priority", "assigned_to"],
        "filter_values": ["High", "Jane Doe"],  # display values after expansion

        # Column metadata for value expansion (optional, but needed for refs/choices)
        "columns": {
          "assigned_to": {
            "type": "reference",
            "reference_display_map": {  # sys_id -> display_value
              "6816f79cc0a8016400b98a06818d57c7": "Jane Doe"
            }
          },
          "priority": {
            "type": "choice",
            "choices": {"1": "Critical", "2": "High", "": "-- None --"}
          }
        },

        # Operators (optional; defaults provided)
        "operators": {
          "EQUALS": "=",
          "ISEMPTY": "ISEMPTY",
          "EMPTYSTRING": "EMPTYSTRING",
          "NOT_EQUALS": "!=",
          "STARTSWITH": "STARTSWITH"
        }
      }

    Returns:
      (score, passed, message, meta)
        score: 1 or 0
    """

    # Define constants
    OPERATOR_EQUALS = "="
    OPERATOR_NOT_EQUALS = "!="
    OPERATOR_STARTSWITH = "STARTSWITH"
    OPERATOR_LIKE = "LIKE"
    OPERATOR_CONTAINS = "CONTAINS"
    OPERATOR_ISEMPTY = "ISEMPTY"
    OPERATOR_EMPTYSTRING = "EMPTYSTRING"

    # check whether we have the necessary information to validate
    if (
        not "table_name" in config
        or not "filter_kind" in config
        or not "filter_len" in config
        or not "filter_columns" in config
        or not "filter_values" in config
    ):
        raise ValueError(
            "Target must contain 'table_name', 'filter_kind', 'filter_len', 'filter_columns', and 'filter_values'"
        )

    # check whether we have messages
    if not messages:
        raise ValueError("Messages must not be empty")

    # --- Step 1: Extract last message content ---
    content = messages[-1].get("content", "")

    # --- Step 2: Extract all URLs — markdown links first, then bare URLs ---
    expected_table = config["table_name"]
    urls = []
    for md in re.finditer(r"\[.*?\]\((https?://[^)]+)\)", content):
        urls.append(md.group(1))
    for bare in re.finditer(r"https?://\S+", content):
        candidate = bare.group(0).rstrip(")].,")
        if candidate not in urls:
            urls.append(candidate)
    if not urls:
        return 0

    # --- Step 3: Pick the URL that contains the expected table name, fall back to first ---
    raw_url = urls[0]
    for u in urls:
        if f"{expected_table}_list.do" in parse.unquote(u):
            raw_url = u
            break
    page_url = parse.unquote(raw_url)

    # --- Step 4: Parse query params — handle both direct and nav_to.do?uri=... formats ---
    parsed = parse.urlparse(page_url)
    page_qs = parse.parse_qs(parsed.query)

    # If URL is nav_to.do?uri=..., extract the inner URI and parse it
    if "uri" in page_qs:
        inner_url = parse.unquote(page_qs["uri"][0])
        inner_parsed = parse.urlparse(inner_url if "://" in inner_url else "http://x/" + inner_url)
        page_qs = parse.parse_qs(inner_parsed.query)
        page_url = inner_url  # use inner URL for table name extraction

    if "sysparm_query" not in page_qs:
        return 0
    page_sysparam_query = page_qs["sysparm_query"][0]

    if not page_sysparam_query:
        return 0

    # get table name from url
    table_match = re.search(r"(\w+)_list\.do", page_url)
    if not table_match:
        return 0
    table_name = table_match.group(1)
    if table_name != config["table_name"]:
        return 0

    # Strip trailing ^EQ (query terminator, not an operator)
    page_sysparam_query = page_sysparam_query.removesuffix("^EQ")

    # Normalize empty-field operators to = (e.g. fieldISEMPTY -> field=)
    page_sysparam_query = page_sysparam_query.replace("ISEMPTY", "=")
    page_sysparam_query = page_sysparam_query.replace("EMPTYSTRING", "=")

    # Normalize NQ to OR, EQ to plain AND separator
    page_sysparam_query = page_sysparam_query.replace("^NQ", "^OR")
    page_sysparam_query = page_sysparam_query.replace("^EQ", "^")

    # Determine AND/OR kind
    # LIMITATION: we only support AND and OR, not mixed
    if "^OR" in page_sysparam_query:
        current_kind = "OR"
        current_sep = "^OR"
    else:
        current_kind = "AND"
        current_sep = "^"

    # validate filter kind
    if current_kind != config.get("filter_kind"):
        return 0

    parts = page_sysparam_query.split(current_sep)
    parts = [p for p in parts if p]

    # validate filter length
    expected_len = int(config.get("filter_len"))
    if len(parts) != expected_len:
        return 0

    # Parse column names, operators, and values
    current_columns, current_values, current_operators = [], [], []

    for predicate in parts:
        pred = predicate.strip()

        if OPERATOR_EMPTYSTRING in pred:
            current_columns.append(pred.replace(OPERATOR_EMPTYSTRING, "").strip())
            current_operators.append("=")
            current_values.append("")
        elif OPERATOR_ISEMPTY in pred:
            current_columns.append(pred.replace(OPERATOR_ISEMPTY, "").strip())
            current_operators.append("=")
            current_values.append("")
        elif OPERATOR_NOT_EQUALS in pred or OPERATOR_STARTSWITH in pred:
            # Unsupported operators
            return 0
        elif OPERATOR_CONTAINS in pred:
            col, val = pred.split(OPERATOR_CONTAINS, 1)
            current_columns.append(col.strip())
            current_operators.append("=")
            current_values.append(val.strip())
        elif OPERATOR_LIKE in pred:
            col, val = pred.split(OPERATOR_LIKE, 1)
            current_columns.append(col.strip())
            current_operators.append("=")
            current_values.append(val.strip())
        elif OPERATOR_EQUALS in pred:
            col, val = pred.split(OPERATOR_EQUALS, 1)
            current_columns.append(col.strip())
            current_operators.append("=")
            current_values.append(val.strip())
        else:
            # Unsupported operators
            return 0

    # Validate columns
    expected_columns = config.get("filter_columns", [])
    if set(current_columns) != set(expected_columns):
        return 0

    # Value expansion for reference/choice columns (if metadata provided)
    col_meta = config.get("columns", {})
    # Detect homogeneous multi-row filter on same column
    is_homogeneous = len(set(current_columns)) < len(current_columns)
    if is_homogeneous and len(set(current_columns)) != 1:
        return 0

    for i, (col, val) in enumerate(zip(current_columns, current_values)):
        meta = col_meta.get(col, {})
        col_type = meta.get("type", "string")

        if col_type == "reference" and val != "":
            ref_map = meta.get("reference_display_map", {})
            if ref_map:
                # Use pre-computed map if available
                display = ref_map.get(val, val)
            else:
                # Query the instance to resolve sys_id -> display value
                ref_table = meta.get("reference")
                ref_field = meta.get("reference_attributes", {}).get("display_field")
                if ref_table and ref_field:
                    response = requests.request(
                        method="GET",
                        url=os.getenv("SERVICENOW_INSTANCE_URL", "").rstrip("/") + f"/api/now/table/{ref_table}",
                        headers={
                            "Content-Type": "application/json",
                            "Accept": "application/json",
                            **json.loads(os.getenv("SERVICENOW_EXTRA_HTTP_HEADERS", "{}")),
                        },
                        params={
                            "sysparm_query": f"sys_id={val}",
                            "sysparm_fields": ref_field,
                            "sysparm_display_value": "all",
                        },
                    )
                    response.raise_for_status()
                    result = response.json().get("result", [])
                    if result:
                        field_val = result[0].get(ref_field, {})
                        if isinstance(field_val, dict) and "display_value" in field_val:
                            display = field_val["display_value"]
                        else:
                            display = field_val
                    else:
                        display = val  # fallback to raw if not found
                else:
                    display = val  # fallback to raw if no reference info

            if is_homogeneous:
                current_values[i] = display
            else:
                current_values[current_columns.index(col)] = display

        elif col_type == "choice":
            choices = meta.get("choices", {})
            display = choices.get(val, "-- None --")
            current_values[current_columns.index(col)] = display

        # strings/numbers/booleans: keep as-is

    expected_values = config.get("filter_values", [])
    if set(current_values) != set(expected_values):
        return 0

    return 1