Task: workarena.servicenow.filter-service-catalog-item-list
Goal: Create a filter for the service catalog item list to extract all entries where:
    - "Short description" is "New windows virtual desktop (WVD)" or 
    - "Category" is "Application and Account Access" or 
    - "Type" is "Bundle" or 
    - "Active" is "true" or 
    - "Name" is "New virtual pc request"
Task configuration: {'table_name': 'sc_cat_item', 'filter_kind': 'OR', 'filter_len': 5, 'filter_columns': ['short_description', 'category', 'type', 'active', 'name'], 'filter_values': ['New windows virtual desktop (WVD)', 'Application and Account Access', 'Bundle', 'true', 'New virtual pc request'], 'filter_operators': ['is', 'is', 'is', 'is', 'is'], 'columns': {'active': {'base_type': 'boolean', 'cangroup': True, 'canmatch': True, 'cansort': True, 'column_type': 'element', 'default': 'false', 'dependent_on_field': '', 'element': 'active', 'filterable': True, 'hint': '', 'internal_type': 'boolean', 'label': 'Active', 'mandatory': False, 'max_length': 40, 'max_unit': 'days', 'name': 'active', 'read_only': False, 'type': 'boolean'}, 'category': {'attributes': 'encode_utf8=false,popover=true,ref_ac_columns=sc_catalog,ref_auto_completer=AJAXTableCompleter', 'base_type': 'GUID', 'cangroup': True, 'canmatch': True, 'cansort': True, 'column_type': 'element', 'default': '', 'dependent_on_field': '', 'element': 'category', 'filterable': True, 'hint': '', 'internal_type': 'reference', 'label': 'Category', 'mandatory': False, 'max_length': 32, 'max_unit': 'days', 'name': 'category', 'read_only': False, 'reference': 'sc_category', 'reference_attributes': {'display_field': 'title', 'ref_ac_columns': ['sc_catalog'], 'ref_ac_columns_search': '', 'ref_ac_display_value': True, 'ref_auto_completer': 'AJAXTableCompleter'}, 'reference_display_field': 'title', 'type': 'reference'}, 'name': {'attributes': 'serializer=com.glide.script.TranslatedTextXMLSerializer,ts_weight=25', 'base_type': 'string', 'cangroup': True, 'canmatch': True, 'cansort': True, 'column_type': 'element', 'default': '', 'dependent_on_field': '', 'element': 'name', 'filterable': True, 'hint': '', 'internal_type': 'translated_text', 'label': 'Name', 'mandatory': False, 'max_length': 100, 'max_unit': 'days', 'name': 'name', 'read_only': False, 'type': 'translated_text'}, 'price': {'attributes': 'omit_sys_original=true,serializer=com.glide.script.PriceXMLSerializer', 'base_type': 'decimal', 'cangroup': True, 'canmatch': True, 'cansort': True, 'column_type': 'element', 'default': '0', 'dependent_on_field': '', 'element': 'price', 'filterable': True, 'hint': '', 'internal_type': 'price', 'label': 'Price', 'mandatory': False, 'max_length': 20, 'max_unit': 'days', 'name': 'price', 'read_only': False, 'type': 'price'}, 'roles': {'attributes': 'no_text_index=true,record_watcher_blacklist=true', 'base_type': 'string', 'canmatch': True, 'column_type': 'element', 'default': '', 'dependent_on_field': '', 'element': 'roles', 'filterable': True, 'hint': 'Who can request this catalog item', 'internal_type': 'user_roles', 'label': 'Roles', 'mandatory': False, 'max_length': 255, 'max_unit': 'days', 'name': 'roles', 'read_only': False, 'type': 'user_roles'}, 'sc_catalogs': {'attributes': 'no_sort=true,slushbucket_ref_no_expand=true,ts_weight=5', 'base_type': 'string', 'canmatch': True, 'column_type': 'element', 'default': '', 'dependent_on_field': '', 'element': 'sc_catalogs', 'filterable': True, 'hint': 'The catalogs this item is associated with', 'internal_type': 'glide_list', 'label': 'Catalogs', 'mandatory': False, 'max_length': 4000, 'max_unit': 'days', 'multitext': True, 'name': 'sc_catalogs', 'read_only': False, 'reference': 'sc_catalog', 'reference_attributes': {'display_field': 'title', 'ref_ac_columns': [], 'ref_ac_columns_search': '', 'ref_ac_display_value': True, 'ref_auto_completer': ''}, 'reference_display_field': 'title', 'type': 'glide_list'}, 'short_description': {'attributes': 'serializer=com.glide.script.TranslatedTextXMLSerializer,ts_weight=15', 'base_type': 'string', 'cangroup': True, 'canmatch': True, 'cansort': True, 'column_type': 'element', 'default': '', 'dependent_on_field': '', 'element': 'short_description', 'filterable': True, 'hint': '', 'internal_type': 'translated_text', 'label': 'Short description', 'mandatory': False, 'max_length': 200, 'max_unit': 'days', 'name': 'short_description', 'read_only': False, 'type': 'translated_text'}, 'sys_updated_on': {'base_type': 'datetime', 'canmatch': True, 'cansort': True, 'column_type': 'element', 'default': '', 'filterable': True, 'hint': '', 'internal_type': 'glide_date_time', 'label': 'Updated', 'mandatory': False, 'max_length': 40, 'max_unit': 'days', 'name': 'sys_updated_on', 'read_only': False, 'type': 'glide_date_time'}, 'type': {'attributes': 'edge_encryption_enabled=true,no_text_index=true', 'base_type': 'string', 'cangroup': True, 'canmatch': True, 'cansort': True, 'choice_type': 1, 'choices': {'': '-- None --', 'bundle': 'Bundle', 'item': 'Item', 'package': 'Package', 'task': 'Task', 'template': 'Template'}, '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