API Reference

rcpond: A tool to partly automate RCP requests

auth

OAuth 2.0 Authorization Code + PKCE authentication for rcpond.

Provides the following public functions:

  • get_bearer_token(config): Return a valid Bearer token string for the ServiceNow API, running the full browser-based flow or a silent token refresh as needed.
  • get_id_token(): Return the id_token JWT from the cached token response, or None if absent (requires openid scope).
  • clear_token_cache(): Delete any cached tokens.
Token lifecycle
  1. If a cached token exists and is not expired, it is returned immediately.
  2. If the access token is expired but a refresh token is present, a silent refresh is attempted. On success the new token is cached and returned.
  3. If no usable cache is present (or refresh fails), the Authorization Code
  4. PKCE flow is launched: the user's browser is opened at the ServiceNow authorisation URL, a local loopback server on localhost:<port> captures the redirect, and the code is exchanged for tokens.
Token cache

Tokens are stored at $XDG_CACHE_HOME/rcpond/tokens.json with mode 0o600 (owner-readable only).

No configuration is required beyond the Config object.

clear_token_cache()

Delete any cached OAuth tokens.

Source code in rcpond/auth.py
91
92
93
94
95
def clear_token_cache() -> None:
    """Delete any cached OAuth tokens."""
    path = _cache_path()
    if path.exists():
        path.unlink()

get_bearer_token(config)

Return a valid Bearer token for the ServiceNow API.

Reads from the token cache, refreshes silently if possible, and falls back to the full browser-based Authorization Code + PKCE flow when needed.

Parameters:
  • config (Config) –

    Must have servicenow_client_id, servicenow_client_secret, servicenow_oauth_scope, and servicenow_oauth_redirect_port set.

Returns:
  • str

    A valid access token string.

Source code in rcpond/auth.py
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
def get_bearer_token(config: Config) -> str:
    """Return a valid Bearer token for the ServiceNow API.

    Reads from the token cache, refreshes silently if possible, and falls
    back to the full browser-based Authorization Code + PKCE flow when needed.

    Parameters
    ----------
    config : Config
        Must have ``servicenow_client_id``, ``servicenow_client_secret``,
        ``servicenow_oauth_scope``, and ``servicenow_oauth_redirect_port`` set.

    Returns
    -------
    str
        A valid access token string.
    """
    cached = _load_cache()

    if cached and not _token_is_expired(cached):
        return cached["access_token"]

    if cached and cached.get("refresh_token"):
        refreshed = _refresh_access_token(config, cached["refresh_token"])
        if refreshed:
            _save_cache(refreshed)
            return refreshed["access_token"]

    ## Full browser flow
    token = _run_authorization_code_flow(config)
    _save_cache(token)
    return token["access_token"]

get_id_token()

Return the id_token from the cached OAuth token response, or None if absent.

Requires the openid scope to have been requested during authentication.

Source code in rcpond/auth.py
82
83
84
85
86
87
88
def get_id_token() -> str | None:
    """Return the id_token from the cached OAuth token response, or None if absent.

    Requires the ``openid`` scope to have been requested during authentication.
    """
    cached = _load_cache()
    return cached.get("id_token") if cached else None

cli

CLI for rcpond.

This module creates the Typer cli object that is the target of the pyproject.scripts directive.

It adds the following subcommands

  • login
  • whoami
  • display-all
  • display-ticket
  • browse-ticket
  • process-next
  • process-ticket
  • process-all
  • evaluate-all

Each delegating to the corresponding function in command.py.

Configuration is supplied via group-level options (e.g. --env-file) that must appear before the subcommand name:

rcpond --env-file .env display-all-tickets

All config options default to None and fall back to RCPOND_* environment variables and/or any .env file supplied.

The Config object is constructed via the _config helpful func, so that config validation will not be called when using the --help option, directly on rcpond or one one of the subcommands.

browse_ticket(ctx, ticket_number)

Opens a ticket in your default the browser (e.g. RES0001234).

Source code in rcpond/cli.py
148
149
150
151
152
153
@cli.command()
def browse_ticket(ctx: typer.Context, ticket_number: str):
    """Opens a ticket in your default the browser (e.g. RES0001234)."""
    url = command.get_ticket_url(ticket_number=ticket_number, config=_config(ctx))
    print(f"Opening ticket: {url}")
    webbrowser.open(url)

display_all(ctx, long_list=False)

Display all unassigned tickets from ServiceNow.

Source code in rcpond/cli.py
136
137
138
139
@cli.command()
def display_all(ctx: typer.Context, long_list: bool = False):
    """Display all unassigned tickets from ServiceNow."""
    command.display_all_tickets(long_list=long_list, config=_config(ctx))

display_ticket(ctx, ticket_number)

Display the details of a specific ticket (e.g. RES0001234).

Source code in rcpond/cli.py
142
143
144
145
@cli.command()
def display_ticket(ctx: typer.Context, ticket_number: str):
    """Display the details of a specific ticket (e.g. RES0001234)."""
    command.display_single_ticket(ticket_number=ticket_number, config=_config(ctx))

evaluate_all(ctx, in_dir, out_dir, num_runs=1)

Evaluate LLM performance against a directory of pre-downloaded HTML tickets.

The output filename is derived from the configured LLM model name and the number of runs, e.g. gpt-4o_3runs.json.

Source code in rcpond/cli.py
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
@cli.command()
def evaluate_all(
    ctx: typer.Context,
    in_dir: Annotated[Path, typer.Argument(help="Directory of pre-downloaded HTML ticket files.")],
    out_dir: Annotated[Path, typer.Argument(help="Directory to write the JSON results file.")],
    num_runs: Annotated[int, typer.Option(help="Number of LLM runs per ticket (for majority-vote analysis).")] = 1,
):
    """Evaluate LLM performance against a directory of pre-downloaded HTML tickets.

    The output filename is derived from the configured LLM model name and the
    number of runs, e.g. ``gpt-4o_3runs.json``.
    """
    if not out_dir.exists():
        msg = f"Output directory does not exist: {out_dir}"
        raise typer.BadParameter(msg, param_hint="out_dir")

    config = _config(ctx)
    ## Sanitise model name for use in a filename (replace / and : which appear in some model IDs)
    safe_model = config.llm_model.replace("/", "-").replace(":", "-")
    out_file = out_dir / f"{safe_model}_{num_runs}runs.json"

    if out_file.exists():
        msg = f"Output file already exists: {out_file}. Delete it or choose a different output directory."
        raise typer.BadParameter(msg, param_hint="out_dir")

    command.batch_evaluate_tickets(in_dir=in_dir, out_file=out_file, num_runs=num_runs, config=config)

login(ctx)

Authorise rcpond with ServiceNow via OAuth (browser-based flow).

Opens a browser, completes the Authorization Code + PKCE flow, and caches the resulting tokens. Subsequent commands will use the cached token automatically without prompting again.

Source code in rcpond/cli.py
104
105
106
107
108
109
110
111
112
113
114
115
@cli.command()
def login(ctx: typer.Context) -> None:
    """Authorise rcpond with ServiceNow via OAuth (browser-based flow).

    Opens a browser, completes the Authorization Code + PKCE flow, and caches
    the resulting tokens. Subsequent commands will use the cached token
    automatically without prompting again.
    """
    from rcpond.auth import get_bearer_token

    get_bearer_token(_config(ctx))
    print("[green]Login successful.[/green] Token cached.")

process_all(ctx, dry_run=False, reply_mode=ReplyMode.default, yes_i_am_sure=False)

Review all unassigned tickets via the LLM.

Source code in rcpond/cli.py
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
@cli.command()
def process_all(
    ctx: typer.Context,
    dry_run: bool = False,
    reply_mode: ReplyMode = ReplyMode.default,
    ## Single flag name (no "--flag/--no-flag" form) suppresses Typer's auto-generated
    ## negative, which would otherwise produce the unreadable `--no-yes-i-am-sure`.
    yes_i_am_sure: Annotated[
        bool, typer.Option("--yes-i-am-sure", help="Confirm processing all unassigned tickets.")
    ] = False,
):
    """Review all unassigned tickets via the LLM."""
    if yes_i_am_sure:
        command.batch_process_tickets(dry_run=dry_run, reply_mode=reply_mode, config=_config(ctx))
    else:
        msg = "The [bold cyan]--yes-i-am-sure[/bold cyan] option MUST be specified when using the [bold]process-all[/bold] subcommand."
        print(msg)
        typer.echo(ctx.get_help())

process_next(ctx, dry_run=False, reply_mode=ReplyMode.default)

Review an arbitrarily selected unassigned ticket via the LLM.

Source code in rcpond/cli.py
164
165
166
167
168
169
170
171
@cli.command()
def process_next(
    ctx: typer.Context,
    dry_run: bool = False,
    reply_mode: Annotated[ReplyMode, typer.Option(help=_REPLY_MODE_HELP)] = ReplyMode.default,
):
    """Review an arbitrarily selected unassigned ticket via the LLM."""
    command.process_next_ticket(dry_run=dry_run, reply_mode=reply_mode, config=_config(ctx))

process_ticket(ctx, ticket_number, dry_run=False, reply_mode=ReplyMode.default)

Review a specific ticket (e.g. RES0001234) via the LLM.

Source code in rcpond/cli.py
174
175
176
177
178
179
180
181
182
183
184
@cli.command()
def process_ticket(
    ctx: typer.Context,
    ticket_number: str,
    dry_run: bool = False,
    reply_mode: Annotated[ReplyMode, typer.Option(help=_REPLY_MODE_HELP)] = ReplyMode.default,
):
    """Review a specific ticket (e.g. RES0001234) via the LLM."""
    command.process_specific_ticket(
        ticket_number=ticket_number, dry_run=dry_run, reply_mode=reply_mode, config=_config(ctx)
    )

whoami(ctx)

Show the identity of the currently authenticated OAuth user.

Source code in rcpond/cli.py
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
@cli.command()
def whoami(ctx: typer.Context) -> None:
    """Show the identity of the currently authenticated OAuth user."""
    from rcpond.servicenow import ServiceNow

    sn = ServiceNow(_config(ctx))
    if not sn._is_oauth:
        print("[yellow]Static token authentication — user identity not available.[/yellow]")
        return
    claims = sn._fetch_current_user_claims()
    if not claims:
        print("[red]Unable to determine user identity.[/red]")
        raise typer.Exit(1)
    print(f"[bold]Name:[/bold]      {claims.get('name', '?')}")
    print(f"[bold]Username:[/bold]  {claims.get('user_name', '?')}")
    print(f"[bold]sys_id:[/bold]    {claims.get('sub', '?')}")

command

High-level commands for rcpond: listing, processing, and batch-processing tickets.

The four main entry points are:

  • display_all_tickets: List unassigned tickets from ServiceNow.
  • process_next_ticket: Review one arbitrarily chosen ticket via the LLM.
  • process_specific_ticket: Review a given ticket via the LLM.
  • batch_process_tickets: Review all unassigned tickets via the LLM.
  • batch_evaluate_tickets: Evaluate LLM performance against pre-downloaded HTML tickets. Requires the html optional dependency group (pip install rcpond[html]).

ReplyMode

Bases: str, Enum

Controls which tickets _process_ticket will skip.

cautious: skip if RCPond has ever posted on the ticket. default: skip only when RCPond's comment or work note is the most recent activity. always: never skip regardless of ticket state.

Source code in rcpond/command.py
25
26
27
28
29
30
31
32
33
34
35
class ReplyMode(str, Enum):
    """Controls which tickets _process_ticket will skip.

    cautious: skip if RCPond has ever posted on the ticket.
    default:  skip only when RCPond's comment or work note is the most recent activity.
    always:   never skip regardless of ticket state.
    """

    cautious = "cautious"
    default = "default"
    always = "always"

batch_evaluate_tickets(in_dir, out_file, num_runs=1, config=None)

Process all Azure tickets in an offline HTML directory, across multiple runs.

Used for evaluating the performance of the LLM in reviewing tickets. Results are written as dict[str, list[LLMResponse]] keyed by ticket number, so that each ticket's responses across all runs are grouped together. Non-Azure tickets are skipped.

Parameters:
  • in_dir (Path) –

    Directory containing pre-downloaded HTML ticket files.

  • out_file (Path) –

    Path to write the JSON results. Must not already exist.

  • num_runs (int, default: 1 ) –

    Number of times to run the LLM over all tickets (for majority-vote analysis).

  • config (Config | None, default: None ) –

    Configuration to use. If None, Config() is constructed from the environment.

Source code in rcpond/command.py
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
def batch_evaluate_tickets(in_dir: Path, out_file: Path, num_runs: int = 1, config: Config | None = None):
    """Process all Azure tickets in an offline HTML directory, across multiple runs.

    Used for evaluating the performance of the LLM in reviewing tickets. Results
    are written as ``dict[str, list[LLMResponse]]`` keyed by ticket number, so
    that each ticket's responses across all runs are grouped together. Non-Azure
    tickets are skipped.

    Parameters
    ----------
    in_dir : Path
        Directory containing pre-downloaded HTML ticket files.
    out_file : Path
        Path to write the JSON results. Must not already exist.
    num_runs : int
        Number of times to run the LLM over all tickets (for majority-vote analysis).
    config : Config | None
        Configuration to use. If None, Config() is constructed from the environment.
    """
    try:
        from rcpond.html_servicenow import HtmlServiceNow
    except ImportError as e:
        msg = "The 'html' optional dependencies are required for this command. Install them with: pip install rcpond[html]"
        raise ImportError(msg) from e

    config = config or Config()
    service_now: HtmlServiceNow = HtmlServiceNow(in_dir)
    llm: LLM = LLM(config)

    ## Pre-filter to Azure tickets only
    all_tickets = service_now.get_tickets(long_list=True)
    azure_tickets = []
    for ticket in all_tickets:
        # TODO: Temporary, an messy way to limit tickets to only those related to Azure
        # Find a better solution
        full_ticket = service_now.get_full_ticket(ticket)

        if full_ticket.which_service != "Azure":
            print(f"skipping non-Azure ticket: {ticket.number}")
        else:
            azure_tickets.append(ticket)

    ## Run the LLM num_runs times, accumulating responses per ticket
    results: dict[str, list[LLMResponse]] = {t.number: [] for t in azure_tickets}
    for run in range(num_runs):
        print(f"\n--- Run {run + 1}/{num_runs} ---")
        for ticket in azure_tickets:
            resp = _process_ticket(
                ticket=ticket,
                dry_run=True,
                config=config,
                service_now=service_now,
                llm=llm,
                reply_mode=ReplyMode.always,
            )
            results[ticket.number].append(resp)
            print()

    with open(out_file, "w") as f:
        json.dump({k: [vars(r) for r in v] for k, v in results.items()}, f, indent=2)

batch_process_tickets(dry_run, reply_mode, config=None)

Process all unassigned ServiceNow tickets via the LLM.

Each ticket is reviewed individually. The LLM response and reasoning are displayed for each. If the LLM recommends actions and dry_run is False, the actions are performed.

Parameters:
  • dry_run (bool) –

    If True, planned tool calls are not executed.

  • reply_mode (ReplyMode) –

    Controls when to skip a ticket based on prior RCPond activity.

  • config (Config | None, default: None ) –

    Configuration to use. If None, Config() is constructed from the environment.

Source code in rcpond/command.py
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
def batch_process_tickets(dry_run: bool, reply_mode: ReplyMode, config: Config | None = None):
    """Process all unassigned ServiceNow tickets via the LLM.

    Each ticket is reviewed individually. The LLM response and reasoning are
    displayed for each. If the LLM recommends actions and ``dry_run`` is False,
    the actions are performed.

    Parameters
    ----------
    dry_run : bool
        If True, planned tool calls are not executed.
    reply_mode : ReplyMode
        Controls when to skip a ticket based on prior RCPond activity.
    config : Config | None
        Configuration to use. If None, Config() is constructed from the environment.
    """
    config = config or Config()
    service_now: ServiceNow = ServiceNow(config)
    llm: LLM = LLM(config)
    all_tickets = service_now.get_tickets()

    if len(all_tickets) > 0:
        for num, ticket in enumerate(all_tickets, start=1):
            print(f"Processing ticket {num} of {len(all_tickets)}")
            resp: LLMResponse = _process_ticket(ticket, dry_run, config, service_now, llm, reply_mode)
            display_short_ticket(ticket)
            display_response(resp)
    else:
        print("No tickets that have not been previously processed by RCPond")

display_all_tickets(long_list, config=None)

Display the list of unassigned tickets from ServiceNow to the user.

Source code in rcpond/command.py
117
118
119
120
121
def display_all_tickets(long_list: bool, config: Config | None = None):
    """Display the list of unassigned tickets from ServiceNow to the user."""
    config = config or Config()
    service_now: ServiceNow = ServiceNow(config)
    display_multi_tickets(service_now.get_tickets(long_list=long_list))

display_single_ticket(ticket_number, config=None)

Display the details of a specific ticket.

Source code in rcpond/command.py
124
125
126
127
128
129
def display_single_ticket(ticket_number: str, config: Config | None = None):
    """Display the details of a specific ticket."""
    config = config or Config()
    service_now: ServiceNow = ServiceNow(config)
    ticket = service_now.get_ticket(ticket_number)
    display_full_ticket(service_now.get_full_ticket(ticket))

get_ticket_url(ticket_number, config=None)

Return the ServiceNow Web UI URL for a specific ticket.

Parameters:
  • ticket_number (str) –

    The ticket number to look up (e.g. "RES0001234").

  • config (Config | None, default: None ) –

    Configuration to use. If None, Config() is constructed from the environment.

Source code in rcpond/command.py
132
133
134
135
136
137
138
139
140
141
142
143
144
145
def get_ticket_url(ticket_number: str, config: Config | None = None) -> str:
    """Return the ServiceNow Web UI URL for a specific ticket.

    Parameters
    ----------
    ticket_number : str
        The ticket number to look up (e.g. ``"RES0001234"``).
    config : Config | None
        Configuration to use. If None, Config() is constructed from the environment.
    """
    config = config or Config()
    service_now: ServiceNow = ServiceNow(config)
    ticket = service_now.get_ticket(ticket_number)
    return service_now.web_url(ticket)

process_next_ticket(dry_run, reply_mode, config=None)

Process an arbitrarily selected ServiceNow ticket via the LLM.

The LLM response and reasoning are displayed to the user. If the LLM recommends an action and dry_run is False, the action is performed.

Parameters:
  • dry_run (bool) –

    If True, planned tool calls are not executed.

  • reply_mode (ReplyMode) –

    Controls when to skip a ticket based on prior RCPond activity.

  • config (Config | None, default: None ) –

    Configuration to use. If None, Config() is constructed from the environment.

Source code in rcpond/command.py
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
def process_next_ticket(dry_run: bool, reply_mode: ReplyMode, config: Config | None = None):
    """Process an arbitrarily selected ServiceNow ticket via the LLM.

    The LLM response and reasoning are displayed to the user. If the LLM
    recommends an action and ``dry_run`` is False, the action is performed.

    Parameters
    ----------
    dry_run : bool
        If True, planned tool calls are not executed.
    reply_mode : ReplyMode
        Controls when to skip a ticket based on prior RCPond activity.
    config : Config | None
        Configuration to use. If None, Config() is constructed from the environment.
    """
    config = config or Config()
    service_now: ServiceNow = ServiceNow(config)
    llm: LLM = LLM(config)
    tickets: list[Ticket] = service_now.get_tickets()
    next_ticket = tickets.pop()
    resp: LLMResponse = _process_ticket(next_ticket, dry_run, config, service_now, llm, reply_mode)
    display_short_ticket(next_ticket)
    display_response(resp)

process_specific_ticket(ticket_number, dry_run, reply_mode, config=None)

Process the given ServiceNow ticket via the LLM.

The LLM response and reasoning are displayed to the user. If the LLM recommends an action and dry_run is False, the action is performed.

Parameters:
  • ticket_number (str) –

    The ticket number (e.g. "RES0001234") to process.

  • dry_run (bool) –

    If True, planned tool calls are not executed.

  • reply_mode (ReplyMode) –

    Controls when to skip a ticket based on prior RCPond activity.

  • config (Config | None, default: None ) –

    Configuration to use. If None, Config() is constructed from the environment.

Source code in rcpond/command.py
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
def process_specific_ticket(ticket_number: str, dry_run: bool, reply_mode: ReplyMode, config: Config | None = None):
    """Process the given ServiceNow ticket via the LLM.

    The LLM response and reasoning are displayed to the user. If the LLM
    recommends an action and ``dry_run`` is False, the action is performed.

    Parameters
    ----------
    ticket_number : str
        The ticket number (e.g. ``"RES0001234"``) to process.
    dry_run : bool
        If True, planned tool calls are not executed.
    reply_mode : ReplyMode
        Controls when to skip a ticket based on prior RCPond activity.
    config : Config | None
        Configuration to use. If None, Config() is constructed from the environment.
    """
    config = config or Config()
    service_now: ServiceNow = ServiceNow(config)
    llm: LLM = LLM(config)
    ticket = service_now.get_ticket(ticket_number)
    resp: LLMResponse = _process_ticket(ticket, dry_run, config, service_now, llm, reply_mode)
    display_short_ticket(ticket)
    display_response(resp)

config

Configuration loading and validation for rcpond.

Provides class Config to read, parse, and make available configuration variables required at runtime. The constructor loads configuration from up to three sources, in order of increasing precedence:

  1. Exactly one config file — either $XDG_CONFIG_HOME/rcpond/default.config (the personal default) or the file given by env_path, but never both. Supplying env_path completely replaces the XDG file; the XDG file is not read at all and any values it contains are ignored.
  2. Environment variables prefixed with RCPOND_ and uppercased (e.g. RCPOND_LLM_MODEL)
  3. Explicit CLI arguments passed as cli_args

Values from a later source override earlier ones. A ValueError is raised if any required field is still missing after all sources are merged, or if a path field does not exist on disk.

The constructor implements some basic validation of parameters, specifically ensuring that the file paths are valid.

File format

The format of the configuration file is:

RCPOND_LLM_CHAT_COMPLETIONS_URL=...
RCPOND_LLM_API_KEY=your-api-key-here
RCPOND_LLM_MODEL=...
RCPOND_SERVICENOW_URL=https://turing-api.azure-api.net/dev-research/api/now/table
RCPOND_SERVICENOW_WEB_URL=https://alanturingdev.service-now.com
RCPOND_RULES_PATH=/path/to/rule/file
RCPOND_SYSTEM_PROMPT_TEMPLATE_PATH=/path/to/prompt/file

# Static token auth (required unless OAuth credentials are set):
RCPOND_SERVICENOW_TOKEN=your-servicenow-token  # pragma: allowlist secret

# OAuth auth (takes precedence over the static token when both are set):
# RCPOND_SERVICENOW_CLIENT_ID=your-client-id
# RCPOND_SERVICENOW_CLIENT_SECRET=your-client-secret
# RCPOND_SERVICENOW_OAUTH_SCOPE=useraccount
# RCPOND_SERVICENOW_OAUTH_REDIRECT_PORT=8765
# RCPOND_SERVICENOW_OAUTH_AUTH_URL=https://alanturingdev.service-now.com/oauth_auth.do
# RCPOND_SERVICENOW_OAUTH_TOKEN_URL=https://alanturingdev.service-now.com/oauth_token.do
Example use

the_config = Config("/home/.config/rcpond/rcpond.txt") the_config.servicenow_token

Config dataclass

Validated runtime configuration for rcpond.

Parameters:
  • env_path (str | None, default: None ) –

    Path to a .env file to load. When supplied, this file is used instead of $XDG_CONFIG_HOME/rcpond/default.config — the XDG file is not read at all. The supplied file must therefore be self-contained (all required fields present); it cannot rely on the XDG file to fill in missing values.

  • cli_args (dict | None, default: None ) –

    Dict of config field names to values from the CLI. None values are ignored.

Attributes:
  • llm_chat_completions_url (str) –

    Base URL of the LLM API endpoint.

  • llm_api_key (str) –

    API key for authenticating with the LLM provider.

  • llm_model (str) –

    Model identifier to use for LLM requests.

  • servicenow_token (str | None) –

    Static subscription key for the ServiceNow API. Required unless OAuth credentials are provided (servicenow_client_id + servicenow_client_secret).

  • servicenow_url (str) –

    Base URL of the ServiceNow REST API endpoint.

  • servicenow_web_url (str) –

    Base URL of the ServiceNow Web UI (e.g. https://alanturingdev.service-now.com). Used to generate direct links to tickets.

  • servicenow_client_id (str | None) –

    OAuth client ID. When set alongside servicenow_client_secret, OAuth is used in preference to servicenow_token.

  • servicenow_client_secret (str | None) –

    OAuth client secret.

  • servicenow_oauth_scope (str | None) –

    OAuth scope requested from ServiceNow. Required when using OAuth; ignored otherwise.

  • servicenow_oauth_redirect_port (int | None) –

    Port for the local OAuth redirect listener. Required when using OAuth; ignored otherwise.

  • servicenow_oauth_auth_url (str | None) –

    ServiceNow OAuth authorisation endpoint URL. Required when using OAuth; ignored otherwise.

  • servicenow_oauth_token_url (str | None) –

    ServiceNow OAuth token endpoint URL. Required when using OAuth; ignored otherwise.

  • rules_path (Path) –

    Path to the RULES.md file used to construct the system prompt.

  • system_prompt_template_path (Path) –

    Path to the Jinja2 template used to render the system prompt.

  • email_templates_path (Path) –

    Path of the directory of Jinja2 templates used to render messages to end users

Source code in rcpond/config.py
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
@dataclass
class Config:
    """Validated runtime configuration for rcpond.

    Parameters
    ----------
    env_path : str | None
        Path to a .env file to load. When supplied, this file is used instead of
        ``$XDG_CONFIG_HOME/rcpond/default.config`` — the XDG file is not read at
        all. The supplied file must therefore be self-contained (all required fields
        present); it cannot rely on the XDG file to fill in missing values.
    cli_args : dict | None
        Dict of config field names to values from the CLI. None values are ignored.

    Attributes
    ----------
    llm_chat_completions_url : str
        Base URL of the LLM API endpoint.
    llm_api_key : str
        API key for authenticating with the LLM provider.
    llm_model : str
        Model identifier to use for LLM requests.
    servicenow_token : str | None
        Static subscription key for the ServiceNow API. Required unless OAuth
        credentials are provided (``servicenow_client_id`` + ``servicenow_client_secret``).
    servicenow_url : str
        Base URL of the ServiceNow REST API endpoint.
    servicenow_web_url : str
        Base URL of the ServiceNow Web UI (e.g. ``https://alanturingdev.service-now.com``).
        Used to generate direct links to tickets.
    servicenow_client_id : str | None
        OAuth client ID. When set alongside ``servicenow_client_secret``, OAuth is
        used in preference to ``servicenow_token``.
    servicenow_client_secret : str | None
        OAuth client secret.
    servicenow_oauth_scope : str | None
        OAuth scope requested from ServiceNow. Required when using OAuth; ignored otherwise.
    servicenow_oauth_redirect_port : int | None
        Port for the local OAuth redirect listener. Required when using OAuth; ignored otherwise.
    servicenow_oauth_auth_url : str | None
        ServiceNow OAuth authorisation endpoint URL. Required when using OAuth; ignored otherwise.
    servicenow_oauth_token_url : str | None
        ServiceNow OAuth token endpoint URL. Required when using OAuth; ignored otherwise.
    rules_path : Path
        Path to the RULES.md file used to construct the system prompt.
    system_prompt_template_path : Path
        Path to the Jinja2 template used to render the system prompt.
    email_templates_path : Path
        Path of the directory of Jinja2 templates used to render messages to end users
    """

    env_path: InitVar[str | None] = None
    cli_args: InitVar[dict | None] = None

    llm_chat_completions_url: str = field(init=False)
    llm_api_key: str = field(init=False)
    llm_model: str = field(init=False)
    servicenow_token: str | None = field(init=False)
    servicenow_url: str = field(init=False)
    servicenow_web_url: str = field(init=False)
    servicenow_client_id: str | None = field(init=False)
    servicenow_client_secret: str | None = field(init=False)
    servicenow_oauth_scope: str | None = field(init=False)
    servicenow_oauth_redirect_port: int | None = field(init=False)
    servicenow_oauth_auth_url: str | None = field(init=False)
    servicenow_oauth_token_url: str | None = field(init=False)
    rules_path: Path = field(init=False)
    system_prompt_template_path: Path = field(init=False)
    email_templates_dir: Path = field(init=False)

    def __post_init__(self, env_path: str | None, cli_args: dict | None) -> None:
        values: dict[str, str] = {}

        # 1. Load from $XDG_CONFIG_HOME/rcpond/default.config (lowest precedence)
        # xdg_default = xdg_config_home() / "rcpond" / "default.config"
        # if xdg_default.exists():
        #     xdg_vars = _parse_dotenv(xdg_default)
        #     for f in fields(self):
        #         env_key = _env_var_name(f.name)
        #         if env_key in xdg_vars:
        #             values[f.name] = xdg_vars[env_key]

        # # 2. Load from .env file
        # if env_path is not None:
        #     dotenv_vars = _parse_dotenv(_confirm_path_exists(env_path))
        #     for f in fields(self):
        #         env_key = _env_var_name(f.name)
        #         if env_key in dotenv_vars:
        #             values[f.name] = dotenv_vars[env_key]

        # Test which config files exist (if any)
        # Only load from one, skip if neither exist
        # 'env_path' takes precedence over XDG_CONFIG_HOME
        # 1. First test $XDG_CONFIG_HOME/rcpond/default.config (lowest precedence)
        #    Silently ignore if this file does not exist
        # 2. Overide with 'env_path' if it exists and is valid
        # .   Error if 'env_path' is specified, but not valid.
        xdg_default = xdg_config_home() / "rcpond" / "default.config"
        config_path = xdg_default if xdg_default.exists() else None

        if env_path is not None:
            config_path = _confirm_path_exists(env_path)

        if config_path:
            config_file_vars = _parse_dotenv(config_path)
            for f in fields(self):
                env_key = _env_var_name(f.name)
                if env_key in config_file_vars:
                    values[f.name] = config_file_vars[env_key]

        # 3. Override with actual environment variables
        for f in fields(self):
            env_key = _env_var_name(f.name)
            if env_key in os.environ:
                values[f.name] = os.environ[env_key]

        # 4. Override with CLI args (highest precedence)
        if cli_args:
            for f in fields(self):
                if f.name in cli_args and cli_args[f.name] is not None:
                    values[f.name] = cli_args[f.name]

        ## Fields that are always optional (may be absent or None)
        _ALWAYS_OPTIONAL = {"servicenow_client_id", "servicenow_client_secret"}

        _OAUTH_ONLY = {
            "servicenow_oauth_scope",
            "servicenow_oauth_redirect_port",
            "servicenow_oauth_auth_url",
            "servicenow_oauth_token_url",
        }

        ## servicenow_token is required unless both OAuth credentials are present;
        ## OAuth-only fields are required only when OAuth credentials are present.
        oauth_present = bool(values.get("servicenow_client_id") and values.get("servicenow_client_secret"))
        conditionally_optional = {"servicenow_token"} if oauth_present else _OAUTH_ONLY

        missing = [
            f.name
            for f in fields(self)
            if f.name not in values and f.name not in _ALWAYS_OPTIONAL and f.name not in conditionally_optional
        ]
        if missing:
            msg = f"Missing required configuration: {', '.join(missing)}"
            raise ValueError(msg)

        if oauth_present and "openid" not in values.get("servicenow_oauth_scope", "").split():
            scope = values.get("servicenow_oauth_scope", "(not set)")
            msg = (
                f"RCPOND_SERVICENOW_OAUTH_SCOPE must include 'openid' when OAuth credentials are set "
                f"(current value: {scope!r}). Add 'openid' to the scope and re-authenticate with 'rcpond login'."
            )
            raise ValueError(msg)

        # Confirm path fields are valid and set attributes
        field_names = {f.name for f in fields(self)}
        hints = {k: v for k, v in typing.get_type_hints(Config).items() if k in field_names}
        for f in fields(self):
            raw = values.get(f.name)
            if raw is None:
                setattr(self, f.name, None)
            elif hints[f.name] is Path:
                setattr(self, f.name, _confirm_path_exists(raw))
            elif int in (typing.get_args(hints[f.name]) or (hints[f.name],)):
                setattr(self, f.name, int(raw))
            else:
                setattr(self, f.name, raw)

        # Validate Jinja2 templates
        _validate_jinja_template(self.system_prompt_template_path)
        _validate_email_templates_dir(self.email_templates_dir)

display

Display functions for rcpond output.

Provides:

  • display_all_tickets: Display a grouped summary table of tickets.
  • display_short_ticket: Display a high-level ticket summary.
  • display_full_ticket: Display the full details of a ticket.
  • display_response: Display an LLM response.

display_full_ticket(ticket, *, console=None)

Display the full details of a ticket using Rich formatting.

Parameters:
  • ticket (FullTicket) –

    The ticket to display.

  • console (Console | None, default: None ) –

    Rich Console to write to. Defaults to the module-level console (stdout).

Source code in rcpond/display.py
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
def display_full_ticket(ticket: FullTicket, *, console: Console | None = None) -> None:
    """Display the full details of a ticket using Rich formatting.

    Parameters
    ----------
    ticket : FullTicket
        The ticket to display.
    console : Console | None
        Rich Console to write to. Defaults to the module-level console (stdout).
    """
    con = console or _console

    con.print(_header_panel(ticket))

    ## Request details
    con.print(
        _section(
            "Request details",
            [
                ("Service", ticket.which_service),
                ("Subscription type", ticket.subscription_type),
                ("New / existing", ticket.new_or_existing_allocation),
                ("Research area", ticket.research_area_programme or ticket.if_other_please_specify),
                ("Data sensitivity", ticket.data_sensitivity),
                ("Start date", ticket.start_date),
                ("End date", ticket.end_date),
            ],
        )
    )

    ## Project
    con.print(
        _section(
            "Project",
            [
                ("Project title", ticket.project_title),
                ("PI / Supervisor", ticket.pi_supervisor_name),
                ("PI email", ticket.pi_supervisor_email),
            ],
        )
    )

    ## Finance
    con.print(
        _section(
            "Finance",
            [
                ("Credits requested", ticket.credits_requested),
                ("Finance code", ticket.which_finance_code),
                ("PMU contact email", ticket.pmu_contact_email),
                ("Subscription / Azure ID", ticket.azure_subscription_id_or_hpc_group_project_id),
                ("Cost breakdown", ticket.cost_compute_time_breakdown),
            ],
        )
    )

    ## Technical requirements
    con.print(
        _section(
            "Technical requirements",
            [
                ("CPU hours", ticket.cpu_hours_required),
                ("GPU hours", ticket.gpu_hours_required),
                ("Facility", ticket.which_facility or ticket.if_other_please_specify_facility),
                ("Computational requirements", ticket.computational_requirements),
                ("Platform justification", ticket.platform_justification),
                ("Research justification", ticket.research_justification),
            ],
        )
    )

    ## Access
    if ticket.users_who_require_access_names_and_emails:
        con.print(
            _section(
                "Users requiring access",
                [
                    ("Users", ticket.users_who_require_access_names_and_emails),
                ],
            )
        )

    ## Work notes and comments, merged chronologically
    notes = ticket.get_combined_notes()
    if notes:
        con.print(_notes_panel(notes))

display_multi_tickets(tickets, *, console=None)

Display a table of ticket summaries.

Each section represents tickets with common values of "Category" and "Description". The Description field is used for the section title.

Each row represents a single ticket. It should have the columns - Number - Opened date/time - Requested by - Status (new / assigned / on-hold / etc)

Source code in rcpond/display.py
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
def display_multi_tickets(tickets: list[Ticket], *, console: Console | None = None) -> None:
    """Display a table of ticket summaries.

    Each section represents tickets with common values of "Category" and "Description". The Description field is used for the section title.

    Each row represents a single ticket. It should have the columns
    - Number
    - Opened date/time
    - Requested by
    - Status (new / assigned / on-hold / etc)
    """
    con = console or _console

    if not tickets:
        con.print("[dim]No tickets found.[/dim]")
        return

    ## Group tickets by (u_category, short_description)
    groups: dict[tuple[str, str], list[Ticket]] = {}
    for ticket in tickets:
        key = (ticket.u_category, ticket.short_description)
        groups.setdefault(key, []).append(ticket)

    for (category, description), group in groups.items():
        table = Table(show_header=True, header_style="bold cyan", box=None, padding=(0, 2))
        table.add_column("Number", no_wrap=True)
        table.add_column("Opened", no_wrap=False)
        table.add_column("Requested by")
        table.add_column("Status")
        table.add_column("Assigned To")

        for ticket in group:
            table.add_row(
                ticket.number,
                ticket.opened_at,
                ticket.requested_for,
                ticket.state,
                ticket.assigned_to if ticket.assigned_to else "[dim]UNASSIGNED[/dim]",
            )

        title = f"[bold]{description} / {category}[/bold]"
        con.print(Panel(table, title=title, title_align="left", border_style="bright_blue"))

display_response(response, *, console=None)

Display an LLM response.

Parameters:
  • response (LLMResponse) –

    The response to display.

  • console (Console | None, default: None ) –

    Rich Console to write to. Defaults to the module-level console (stdout).

Source code in rcpond/display.py
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
def display_response(response: LLMResponse, *, console: Console | None = None) -> None:
    """Display an LLM response.

    Parameters
    ----------
    response : LLMResponse
        The response to display.
    console : Console | None
        Rich Console to write to. Defaults to the module-level console (stdout).
    """
    con = console or _console

    title = "[bold]LLM Response[/bold]"
    if response.ticket_number:
        title += f"  [dim]{response.ticket_number}[/dim]"
    if response.llm_model:
        title += f"  [dim italic]{response.llm_model}[/dim italic]"

    ## Main response text
    response_text = response.response_text if response.response_text else "NO RESPONSE RECEIVED!"
    con.print(
        Panel(
            Text(response_text, overflow="fold"),
            title=title,
            title_align="left",
            border_style="bright_blue",
        )
    )

    ## Reasoning (only present for chain-of-thought models)
    if response.reasoning:
        con.print(
            Panel(
                Text(response.reasoning, overflow="fold"),
                title="[bold]Reasoning[/bold]",
                title_align="left",
                border_style="dim",
            )
        )

    ## Planned tool call
    if response.planned_tool_call:
        tool_name = response.planned_tool_call.get("function", {}).get("name", "unknown")
        args = response.planned_tool_call.get("function", {}).get("arguments", {})
        rows = list(args.items()) if isinstance(args, dict) else [("arguments", str(args))]
        con.print(
            Panel(
                _kv_table(rows),
                title=f"[bold]Tool call:[/bold] [cyan]{tool_name}[/cyan]",
                title_align="left",
                border_style="yellow",
            )
        )

display_short_ticket(ticket, *, console=None)

Display a high-level ticket summary.

Parameters:
  • ticket (Ticket) –

    The ticket to display.

  • console (Console | None, default: None ) –

    Rich Console to write to. Defaults to the module-level console (stdout).

Source code in rcpond/display.py
82
83
84
85
86
87
88
89
90
91
92
93
def display_short_ticket(ticket: Ticket, *, console: Console | None = None) -> None:
    """Display a high-level ticket summary.

    Parameters
    ----------
    ticket : Ticket
        The ticket to display.
    console : Console | None
        Rich Console to write to. Defaults to the module-level console (stdout).
    """
    con = console or _console
    con.print(_header_panel(ticket))

html_servicenow

A read-only ServiceNow interface backed by pre-downloaded HTML files.

Provides HtmlServiceNow, a subclass of ServiceNow that reads ticket data from a directory of HTML export files (produced by the /x_tati_resmgt_research.do?... endpoint) instead of making live API calls.

Public API
  • HtmlServiceNow(html_dir): Construct from a directory of *.html files.
  • All read methods from ServiceNow are supported: get_tickets(), get_ticket(), get_full_ticket(), get_work_notes().
  • Write methods (post_note(), assign_to()) are no-ops.
  • get_assignee() returns an empty assignee dict — assignment state is not stored in the HTML export.
Return types

Same as ServiceNow: list[Ticket], Ticket, FullTicket, list[str].

Configuration

No Config object is needed. Pass the directory path directly.

HtmlServiceNow

Bases: ServiceNow

Read-only ServiceNow interface backed by a directory of HTML export files.

sn = HtmlServiceNow(Path("downloads/")) sn.get_tickets()

Source code in rcpond/html_servicenow.py
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
class HtmlServiceNow(ServiceNow):
    """Read-only ServiceNow interface backed by a directory of HTML export files.

    >>> sn = HtmlServiceNow(Path("downloads/"))
    >>> sn.get_tickets()
    """

    def __init__(self, html_dir: Path) -> None:
        ## Do NOT call super().__init__() — no Config or HTTP session is needed
        html_dir = Path(html_dir)
        if not html_dir.exists():
            msg = f"Input directory does not exist: {html_dir}"
            raise FileNotFoundError(msg)
        if not html_dir.is_dir():
            msg = f"Input path is not a directory: {html_dir}"
            raise NotADirectoryError(msg)
        if not any(html_dir.glob("*.html")):
            msg = f"No .html files found in: {html_dir}"
            raise ValueError(msg)
        self._html_dir = html_dir

    def _find_html_for_ticket(self, tkt: Ticket) -> Path:
        """Locate the HTML file for ``tkt``.

        Parameters
        ----------
        tkt : Ticket
            The ticket to look up.

        Returns
        -------
        Path
            Path to the matching HTML file.

        Raises
        ------
        FileNotFoundError
            If no HTML file for the ticket number is found in ``html_dir``.
        """
        ## Try the conventional filename produced by download_tickets.sh first
        candidate = self._html_dir / f"ticket_{tkt.number}.html"
        if candidate.exists():
            return candidate

        ## Fall back to scanning all HTML files by parsed ticket number
        for f in self._html_dir.glob("*.html"):
            facts = extract_key_facts(f)
            if facts.get("ticket_number") == tkt.number:
                return f

        err_msg = f"No HTML file found for ticket {tkt.number} in {self._html_dir}"
        raise FileNotFoundError(err_msg)

    ## ---- Read methods ----

    def get_tickets(self, long_list: bool = False) -> list[Ticket]:
        """Return a ``FullTicket`` for each HTML file in ``html_dir``.

        Parameters
        ----------
        long_list : bool
            If ``False`` (default), only unassigned tickets are returned.
            If ``True``, all tickets are returned regardless of assignment state.

        Returns
        -------
        list[Ticket]
            Each element is actually a ``FullTicket``.
        """
        tickets: list[Ticket] = []
        for f in sorted(self._html_dir.glob("*.html")):
            facts = extract_key_facts(f)
            is_assigned = bool(facts["assigned_to"]["display_value"])
            if is_assigned and not long_list:
                continue
            tickets.append(parse_ticket_html(f))
        return tickets

    def get_full_ticket(self, tkt: Ticket) -> FullTicket:
        """Return a ``FullTicket`` for ``tkt``.

        If ``tkt`` is already a ``FullTicket`` (as returned by ``get_tickets``),
        it is returned directly. Otherwise the HTML file is parsed.

        Parameters
        ----------
        tkt : Ticket
            The ticket to look up.

        Returns
        -------
        FullTicket
        """
        if isinstance(tkt, FullTicket):
            return tkt
        html_file = self._find_html_for_ticket(tkt)
        return parse_ticket_html(html_file)

    def get_work_notes(self, tkt: Ticket) -> list[NoteEntry]:
        """Return the work notes for ``tkt`` extracted from the HTML activity log.

        Parameters
        ----------
        tkt : Ticket
            The ticket to look up.

        Returns
        -------
        list[NoteEntry]
            One entry per work note, sorted chronologically (oldest first).
        """
        html_file = self._find_html_for_ticket(tkt)
        facts = extract_key_facts(html_file)
        activities = facts["activities"]
        rows = activities[activities["field_name"] == "work_notes"].dropna(subset=["date", "text"])
        entries = [
            NoteEntry(
                datetime_stamp=datetime.strptime(row["date"], "%d/%m/%Y %H:%M:%S"),
                user=row["user"] or "",
                note_type=row["field_label"] or "Work notes",
                content=row["text"],
            )
            for _, row in rows.iterrows()
        ]
        return sorted(entries, key=lambda e: e.datetime_stamp)

    def get_assignee(self, tkt: Ticket) -> dict[str, str]:
        """Return the assignee for ``tkt`` extracted from the HTML.

        Parameters
        ----------
        tkt : Ticket
            The ticket to look up.

        Returns
        -------
        dict[str, str]
            Dict with ``"value"`` (sys_id) and ``"display_value"`` (name).
            Both are empty strings if the ticket is unassigned.
        """
        html_file = self._find_html_for_ticket(tkt)
        facts = extract_key_facts(html_file)
        return facts["assigned_to"]

    ## ---- Write no-ops ----

    def post_note(self, _tkt: Ticket, _note: str) -> None:
        """No-op — HTML source is read-only."""

    def _attempt_assign_to(self, _ticket: Ticket, _assignee: str) -> None:
        """No-op — HTML source is read-only."""

    def assign_to(self, _ticket: Ticket, _assignee: str) -> dict[str, str]:
        """No-op — HTML source is read-only. Returns an empty assignee dict."""
        return {"value": "", "display_value": ""}

assign_to(_ticket, _assignee)

No-op — HTML source is read-only. Returns an empty assignee dict.

Source code in rcpond/html_servicenow.py
186
187
188
def assign_to(self, _ticket: Ticket, _assignee: str) -> dict[str, str]:
    """No-op — HTML source is read-only. Returns an empty assignee dict."""
    return {"value": "", "display_value": ""}

get_assignee(tkt)

Return the assignee for tkt extracted from the HTML.

Parameters:
  • tkt (Ticket) –

    The ticket to look up.

Returns:
  • dict[str, str]

    Dict with "value" (sys_id) and "display_value" (name). Both are empty strings if the ticket is unassigned.

Source code in rcpond/html_servicenow.py
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
def get_assignee(self, tkt: Ticket) -> dict[str, str]:
    """Return the assignee for ``tkt`` extracted from the HTML.

    Parameters
    ----------
    tkt : Ticket
        The ticket to look up.

    Returns
    -------
    dict[str, str]
        Dict with ``"value"`` (sys_id) and ``"display_value"`` (name).
        Both are empty strings if the ticket is unassigned.
    """
    html_file = self._find_html_for_ticket(tkt)
    facts = extract_key_facts(html_file)
    return facts["assigned_to"]

get_full_ticket(tkt)

Return a FullTicket for tkt.

If tkt is already a FullTicket (as returned by get_tickets), it is returned directly. Otherwise the HTML file is parsed.

Parameters:
  • tkt (Ticket) –

    The ticket to look up.

Returns:
Source code in rcpond/html_servicenow.py
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
def get_full_ticket(self, tkt: Ticket) -> FullTicket:
    """Return a ``FullTicket`` for ``tkt``.

    If ``tkt`` is already a ``FullTicket`` (as returned by ``get_tickets``),
    it is returned directly. Otherwise the HTML file is parsed.

    Parameters
    ----------
    tkt : Ticket
        The ticket to look up.

    Returns
    -------
    FullTicket
    """
    if isinstance(tkt, FullTicket):
        return tkt
    html_file = self._find_html_for_ticket(tkt)
    return parse_ticket_html(html_file)

get_tickets(long_list=False)

Return a FullTicket for each HTML file in html_dir.

Parameters:
  • long_list (bool, default: False ) –

    If False (default), only unassigned tickets are returned. If True, all tickets are returned regardless of assignment state.

Returns:
  • list[Ticket]

    Each element is actually a FullTicket.

Source code in rcpond/html_servicenow.py
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
def get_tickets(self, long_list: bool = False) -> list[Ticket]:
    """Return a ``FullTicket`` for each HTML file in ``html_dir``.

    Parameters
    ----------
    long_list : bool
        If ``False`` (default), only unassigned tickets are returned.
        If ``True``, all tickets are returned regardless of assignment state.

    Returns
    -------
    list[Ticket]
        Each element is actually a ``FullTicket``.
    """
    tickets: list[Ticket] = []
    for f in sorted(self._html_dir.glob("*.html")):
        facts = extract_key_facts(f)
        is_assigned = bool(facts["assigned_to"]["display_value"])
        if is_assigned and not long_list:
            continue
        tickets.append(parse_ticket_html(f))
    return tickets

get_work_notes(tkt)

Return the work notes for tkt extracted from the HTML activity log.

Parameters:
  • tkt (Ticket) –

    The ticket to look up.

Returns:
  • list[NoteEntry]

    One entry per work note, sorted chronologically (oldest first).

Source code in rcpond/html_servicenow.py
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
def get_work_notes(self, tkt: Ticket) -> list[NoteEntry]:
    """Return the work notes for ``tkt`` extracted from the HTML activity log.

    Parameters
    ----------
    tkt : Ticket
        The ticket to look up.

    Returns
    -------
    list[NoteEntry]
        One entry per work note, sorted chronologically (oldest first).
    """
    html_file = self._find_html_for_ticket(tkt)
    facts = extract_key_facts(html_file)
    activities = facts["activities"]
    rows = activities[activities["field_name"] == "work_notes"].dropna(subset=["date", "text"])
    entries = [
        NoteEntry(
            datetime_stamp=datetime.strptime(row["date"], "%d/%m/%Y %H:%M:%S"),
            user=row["user"] or "",
            note_type=row["field_label"] or "Work notes",
            content=row["text"],
        )
        for _, row in rows.iterrows()
    ]
    return sorted(entries, key=lambda e: e.datetime_stamp)

post_note(_tkt, _note)

No-op — HTML source is read-only.

Source code in rcpond/html_servicenow.py
180
181
def post_note(self, _tkt: Ticket, _note: str) -> None:
    """No-op — HTML source is read-only."""

llm

An interface to an OpenAI-compatible chat completions API.

Provides a class, LLM, which wraps the chat completions endpoint. The only function is:

  • LLM.generate(): To generate a response given a system prompt and a user prompt, optionally with tool definitions.

Responses are returned as instances of an LLMResponse dataclass containing the response text, optional reasoning content, and any planned tool call.

The chat completions URL and API key are supplied via a Config object.

LLM

Simple wrapper around an OpenAI-compatible chat completions API.

Example:

llm = LLM(config) response = llm.generate("You are helpful.", "Hello!", model="gpt-4")

Source code in rcpond/llm.py
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
class LLM:
    """Simple wrapper around an OpenAI-compatible chat completions API.

    Example:
    >>> llm = LLM(config)
    >>> response = llm.generate("You are helpful.", "Hello!", model="gpt-4")

    """

    def __init__(self, config: Config) -> None:
        """Initialise the LLM class.

        Parameters
        ----------
        config : Config
            Configuration object containing the chat completions URL and API key.
        """
        self.llm_chat_completions_url = config.llm_chat_completions_url
        self.llm_api_key = config.llm_api_key

    def _generate(self, messages: list[dict], model: str, tools: list[dict] | None = None) -> dict[str, Any]:
        """Generate a response from the LLM given a list of messages.

        Parameters
        ----------
        messages : list[dict]
            The messages to generate a response for, in OpenAI format.
        model : str
            The model to use for generation.
        tools : list[dict] | None
            Optional list of tool definitions in OpenAI format.

        Returns
        -------
        dict[str, Any]
            The generated response from the LLM as a parsed dictionary.
        """
        payload = {
            "model": model,
            "messages": messages,
        }
        if tools:
            payload["tools"] = tools
        response = requests.post(
            self.llm_chat_completions_url,
            headers={"Authorization": f"Bearer {self.llm_api_key}"},
            json=payload,
        )
        response.raise_for_status()
        return response.json()

    def _parse_response(self, response: dict) -> LLMResponse:
        """Parse the response from the LLM into the `LLMResponse` dataclass.

        Parameters
        ----------
        response : dict
            The response from the LLM to parse.

        Returns
        -------
        LLMResponse
            The parsed response from the LLM.
        """
        message = response["choices"][0]["message"]
        response_text = message.get("content", "")
        reasoning = message.get("reasoning_content")
        tool_calls = message.get("tool_calls")
        planned_tool_call = None
        if tool_calls:
            ## The API returns function arguments as a JSON string;
            ## parse them into a dict for downstream use.
            tool_call = tool_calls[0]
            planned_tool_call = {
                **tool_call,
                "function": {
                    **tool_call["function"],
                    "arguments": json.loads(tool_call["function"]["arguments"]),
                },
            }
        return LLMResponse(
            response_text=response_text,
            reasoning=reasoning,
            planned_tool_call=planned_tool_call,
        )

    def generate(
        self,
        system_prompt: str,
        user_prompt: str,
        model: str,
        tools: list[Tool] | None = None,
        ticket_number: str | None = None,
    ) -> LLMResponse:
        """Generate an LLM response given a system prompt and a user prompt.
        Formats the system and user prompt into a single prompt and calls the `_generate` method to get the response from the LLM.
        LLM response is parsed into `LLMResponse` dataclass.

        Parameters
        ----------
        system_prompt : str
            The system prompt to provide context for the LLM.
        user_prompt : str
            The user prompt to generate a response for.
        model: str
            The model to use for generation.
        tools : list[Tool] | None
            Optional list of tools to make available to the model.
        ticket_number : str | None
            The ticket number being processed, stored on the response for traceability.

        Returns
        -------
        LLMResponse
            The generated response from the LLM.
        """
        messages = [
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": user_prompt},
        ]
        tool_dicts = [t.to_openai_dict() for t in tools] if tools else None
        response = self._generate(messages, model=model, tools=tool_dicts)
        llm_response = self._parse_response(response)
        llm_response.ticket_number = ticket_number
        llm_response.llm_model = model
        return llm_response

__init__(config)

Initialise the LLM class.

Parameters:
  • config (Config) –

    Configuration object containing the chat completions URL and API key.

Source code in rcpond/llm.py
56
57
58
59
60
61
62
63
64
65
def __init__(self, config: Config) -> None:
    """Initialise the LLM class.

    Parameters
    ----------
    config : Config
        Configuration object containing the chat completions URL and API key.
    """
    self.llm_chat_completions_url = config.llm_chat_completions_url
    self.llm_api_key = config.llm_api_key

generate(system_prompt, user_prompt, model, tools=None, ticket_number=None)

Generate an LLM response given a system prompt and a user prompt. Formats the system and user prompt into a single prompt and calls the _generate method to get the response from the LLM. LLM response is parsed into LLMResponse dataclass.

Parameters:
  • system_prompt (str) –

    The system prompt to provide context for the LLM.

  • user_prompt (str) –

    The user prompt to generate a response for.

  • model (str) –

    The model to use for generation.

  • tools (list[Tool] | None, default: None ) –

    Optional list of tools to make available to the model.

  • ticket_number (str | None, default: None ) –

    The ticket number being processed, stored on the response for traceability.

Returns:
Source code in rcpond/llm.py
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
def generate(
    self,
    system_prompt: str,
    user_prompt: str,
    model: str,
    tools: list[Tool] | None = None,
    ticket_number: str | None = None,
) -> LLMResponse:
    """Generate an LLM response given a system prompt and a user prompt.
    Formats the system and user prompt into a single prompt and calls the `_generate` method to get the response from the LLM.
    LLM response is parsed into `LLMResponse` dataclass.

    Parameters
    ----------
    system_prompt : str
        The system prompt to provide context for the LLM.
    user_prompt : str
        The user prompt to generate a response for.
    model: str
        The model to use for generation.
    tools : list[Tool] | None
        Optional list of tools to make available to the model.
    ticket_number : str | None
        The ticket number being processed, stored on the response for traceability.

    Returns
    -------
    LLMResponse
        The generated response from the LLM.
    """
    messages = [
        {"role": "system", "content": system_prompt},
        {"role": "user", "content": user_prompt},
    ]
    tool_dicts = [t.to_openai_dict() for t in tools] if tools else None
    response = self._generate(messages, model=model, tools=tool_dicts)
    llm_response = self._parse_response(response)
    llm_response.ticket_number = ticket_number
    llm_response.llm_model = model
    return llm_response

LLMResponse dataclass

The parsed response from an LLM chat completion.

Source code in rcpond/llm.py
27
28
29
30
31
32
33
34
35
36
37
38
39
40
@dataclass
class LLMResponse:
    """The parsed response from an LLM chat completion."""

    response_text: str
    """The text content of the response."""
    reasoning: str | None = None
    """Optional reasoning content (e.g. from models that support chain-of-thought)."""
    planned_tool_call: dict | None = None
    """Optional tool call requested by the model, with arguments parsed from JSON."""
    ticket_number: str | None = None
    """The ticket number this response relates to, passed in by the caller."""
    llm_model: str | None = None
    """The model identifier used to generate this response. A value ``None`` indiciates that the response was not LLM generated, but generated deterministically."""

llm_model = None class-attribute instance-attribute

The model identifier used to generate this response. A value None indiciates that the response was not LLM generated, but generated deterministically.

planned_tool_call = None class-attribute instance-attribute

Optional tool call requested by the model, with arguments parsed from JSON.

reasoning = None class-attribute instance-attribute

Optional reasoning content (e.g. from models that support chain-of-thought).

response_text instance-attribute

The text content of the response.

ticket_number = None class-attribute instance-attribute

The ticket number this response relates to, passed in by the caller.

parse_html

Parse ServiceNow ticket HTML export files.

Provides two public functions:

  • extract_key_facts(filename): Parse a ServiceNow ticket HTML file and return a dict of extracted fields.
  • parse_ticket_html(filename): Parse a ServiceNow ticket HTML file and return a FullTicket instance.

The HTML files are generated by the ServiceNow export endpoint::

/x_tati_resmgt_research.do?sys_id={sys_id}&sysparm_view=Export_to_pdf&...
Return types

extract_key_facts returns a dict[str, str | pd.DataFrame | None]. String fields are None when the corresponding element is absent from the HTML. The "activities" key always holds a pd.DataFrame (empty if no activities are found).

parse_ticket_html returns a FullTicket.

No configuration is required.

extract_key_facts(filename)

Extract ticket fields from a ServiceNow HTML export file.

Parameters:
  • filename (Path) –

    Path to the HTML file.

Returns:
  • dict[str, str | DataFrame | None]

    Extracted fields. String values are None when the corresponding HTML element is absent. "activities" is always a pd.DataFrame.

Source code in rcpond/parse_html.py
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
def extract_key_facts(filename: Path) -> dict:
    """Extract ticket fields from a ServiceNow HTML export file.

    Parameters
    ----------
    filename : Path
        Path to the HTML file.

    Returns
    -------
    dict[str, str | pd.DataFrame | None]
        Extracted fields. String values are ``None`` when the corresponding
        HTML element is absent. ``"activities"`` is always a ``pd.DataFrame``.
    """
    soup = BeautifulSoup(filename.read_text(), "html.parser")

    activities = _extract_activities_as_table(soup)

    comments = _extract_comments(activities)
    work_notes = "\n\n".join(comments["text"].dropna())

    def _q(question: str, starting_point: str | None = None) -> str:
        return _extract_question_answer_pair_table(soup, question, starting_point) or ""

    return {
        ## Internal keys (not FullTicket fields)
        "sys_id": _extract_sys_id(soup),
        "ticket_number": _extract_ticket_number(soup),
        "assigned_to": _extract_assigned_to(soup),
        "state": _extract_state(soup),
        "category": _extract_category(soup),
        "sub_category": _extract_sub_category(soup),
        "activity_count": str(len(activities)),
        "comment_count": str(_extract_comment_count(activities)),
        "activities": activities,
        ## FullTicket fields — keys match FullTicket field names exactly
        "work_notes": work_notes,
        "comments": "",
        "which_service": _extract_platform_choice(soup) or "",
        "requested_for": _q("requested for", "Variables"),
        "project_title": _q("project title"),
        "research_area_programme": _extract_research_area_or_programme(soup) or "",
        "pi_supervisor_name": _q("PI/Supervisor name"),
        "pi_supervisor_email": _q("PI/Supervisor email"),
        "subscription_type": _q("Subscription type"),
        "which_finance_code": _q("Which finance code"),
        "pmu_contact_email": _q("PMU Contact email"),
        "credits_requested": _q("Credits requested"),
        "which_facility": _q("Which facility"),
        "if_other_please_specify": "",  ## handled by fallback logic in _extract_research_area_or_programme
        "if_other_please_specify_facility": "",
        "cpu_hours_required": _q("CPU hours required?"),
        "gpu_hours_required": _q("GPU hours required?"),
        "new_or_existing_allocation": _q("New or existing allocation"),
        "azure_subscription_id_or_hpc_group_project_id": _q("Azure subscription ID or HPC Group/Project ID"),
        "start_date": _q("Start date"),
        "end_date": _q("End date"),
        "data_sensitivity": _q("Data sensitivity"),
        "platform_justification": _q("Platform justification"),
        "research_justification": _q("Research justification"),
        "computational_requirements": _q("Computational requirements"),
        "users_who_require_access_names_and_emails": _q("Users who require access (names and emails)"),
        "cost_compute_time_breakdown": _q("Cost/compute time breakdown"),
    }

parse_ticket_html(filename)

Parse a ServiceNow ticket HTML export file and return a FullTicket.

Parameters:
  • filename (Path) –

    Path to the HTML file.

Returns:
Source code in rcpond/parse_html.py
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
def parse_ticket_html(filename: Path) -> FullTicket:
    """Parse a ServiceNow ticket HTML export file and return a ``FullTicket``.

    Parameters
    ----------
    filename : Path
        Path to the HTML file.

    Returns
    -------
    FullTicket
    """
    facts = extract_key_facts(filename)
    activities = facts["activities"]

    ## Use the earliest activity date as a best-effort stand-in for opened_at
    opened_at = ""
    if not activities.empty and activities["date"].notna().any():
        opened_at = activities["date"].dropna().min() or ""

    ticket = Ticket(
        sys_id=facts.get("sys_id") or filename.stem,
        number=facts.get("ticket_number") or "",
        opened_at=opened_at,
        requested_for=facts.get("requested_for") or "",
        u_category=facts.get("category") or "",
        u_sub_category=facts.get("sub_category") or "",
        short_description=_SHORT_DESCRIPTION,
        state="",
        assigned_to="",
        work_notes=facts.get("work_notes") or "",
        comments=facts.get("comments") or "",
    )

    ## Filter facts to only the extra fields expected by FullTicket (i.e. those
    ## not already on Ticket), to avoid passing conflicting keys from extract_key_facts.
    ticket_field_names = {f.name for f in dataclasses.fields(Ticket)}
    full_ticket_field_names = {f.name for f in dataclasses.fields(FullTicket)}
    extra_field_names = full_ticket_field_names - ticket_field_names
    extra_facts = {k: v for k, v in facts.items() if k in extra_field_names}

    return FullTicket.from_Ticket(ticket, **extra_facts)

prompt

Prompt construction for rcpond, including reading RULES.md and formatting ticket data.

Provides a single public function:

  • construct_prompt: Builds the (system_prompt, user_prompt) pair for the LLM given a full ticket and config.

The system prompt is formed by reading the rules file and rendering it into a template using Python's str.format. The user prompt is the full ticket serialised as JSON.

construct_prompt(full_ticket, config)

Construct the system and user prompts for the LLM given a full ticket and config.

Reads the rules file and renders it into the system prompt template. Serialises the full ticket as JSON for the user prompt.

Parameters:
  • full_ticket (FullTicket) –

    The full ServiceNow ticket to construct the prompt for.

  • config (Config) –

    The loaded configuration. config.rules_path and config.system_prompt_template_path must point to existing files (guaranteed by Config.__post_init__).

Returns:
  • tuple[str, str]

    A (system_prompt, user_prompt) tuple.

Source code in rcpond/prompt.py
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
def construct_prompt(full_ticket: FullTicket, config: Config) -> tuple[str, str]:
    """Construct the system and user prompts for the LLM given a full ticket and config.

    Reads the rules file and renders it into the system prompt template. Serialises
    the full ticket as JSON for the user prompt.

    Parameters
    ----------
    full_ticket : FullTicket
        The full ServiceNow ticket to construct the prompt for.
    config : Config
        The loaded configuration. ``config.rules_path`` and
        ``config.system_prompt_template_path`` must point to existing files
        (guaranteed by ``Config.__post_init__``).

    Returns
    -------
    tuple[str, str]
        A (system_prompt, user_prompt) tuple.
    """
    rules_text = config.rules_path.read_text()
    template_text = config.system_prompt_template_path.read_text()
    system_prompt = template_text.format(rules=rules_text)
    user_prompt = json.dumps(dataclasses.asdict(full_ticket), indent=2)
    return system_prompt.strip(), user_prompt.strip()

servicenow

A limited interface to ServiceNow.

Provides a class, ServiceNow, which wraps the ServiceNow API. The only methods are:

  • ServiceNow.get_tickets(): Get a list of tickets. By default only unassigned tickets are returned, but all tickets can be selected;
  • ServiceNow.get_ticket(): Get a single ticket by its ticket number (e.g. "RES0001234");
  • ServiceNow.get_full_ticket(): Get full details of a ticket;
  • assign_to() Assigns a ticket to the named user;
  • get_work_notes() List the work notes for a specific ticket; and
  • post_note() (Not implemented) Post a “work note” to a ticket.

Tickets are returned as instances of a Ticket dataclass which contains a few, high-level details. The subclass FullTicket contains ,in addition, the fields submitted by the requestor on the request form.

The URL of the ServiceNow API is hardcoded, but you will need to supply a user authentication token.

This version filters out all tickets that are not requests for HPC or Azure resource.

Example use

the_sn = ServiceNow("ab...def") the_sn.get_tickets()

FullTicket dataclass

Bases: Ticket

A ticket; includes full details from the original submission.

Source code in rcpond/servicenow.py
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
@dataclass
class FullTicket(Ticket):
    """A ticket; includes full details from the original submission."""

    project_title: str
    research_area_programme: str
    if_other_please_specify: str
    pi_supervisor_name: str
    pi_supervisor_email: str
    which_service: str
    subscription_type: str
    which_finance_code: str
    pmu_contact_email: str
    credits_requested: str
    which_facility: str
    if_other_please_specify_facility: str
    cpu_hours_required: str
    gpu_hours_required: str
    new_or_existing_allocation: str
    azure_subscription_id_or_hpc_group_project_id: str
    start_date: str
    end_date: str
    data_sensitivity: str
    platform_justification: str
    research_justification: str
    computational_requirements: str
    users_who_require_access_names_and_emails: str
    cost_compute_time_breakdown: str

    @classmethod
    def from_Ticket(cls, t: Ticket, **extras):
        """Create a new FullTicket starting from a Ticket and passing
        only the additional fields.
        """
        base = dataclasses.asdict(t)
        base.update(extras)
        return cls(**base)

from_Ticket(t, **extras) classmethod

Create a new FullTicket starting from a Ticket and passing only the additional fields.

Source code in rcpond/servicenow.py
165
166
167
168
169
170
171
172
@classmethod
def from_Ticket(cls, t: Ticket, **extras):
    """Create a new FullTicket starting from a Ticket and passing
    only the additional fields.
    """
    base = dataclasses.asdict(t)
    base.update(extras)
    return cls(**base)

NoteEntry dataclass

A single parsed work note or comment from a ServiceNow ticket.

Source code in rcpond/servicenow.py
122
123
124
125
126
127
128
129
130
131
132
133
@dataclass(frozen=True)
class NoteEntry:
    """A single parsed work note or comment from a ServiceNow ticket."""

    datetime_stamp: datetime
    """Parsed timestamp of the note."""
    user: str
    """Display name of the author."""
    note_type: str
    """Note category as returned by ServiceNow, e.g. ``"Work notes"`` or ``"Comments"``."""
    content: str
    """Body text of the note, with the header line stripped."""

content instance-attribute

Body text of the note, with the header line stripped.

datetime_stamp instance-attribute

Parsed timestamp of the note.

note_type instance-attribute

Note category as returned by ServiceNow, e.g. "Work notes" or "Comments".

user instance-attribute

Display name of the author.

ServiceNow

Simple wrapper around limited parts of the ServiceNow API.

Source code in rcpond/servicenow.py
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
class ServiceNow:
    """Simple wrapper around limited parts of the ServiceNow API."""

    # ServiceNow configuration
    # _base_url = "https://turing-api.azure-api.net/dev-research/api/now/table"
    _TABLE = "x_tati_resmgt_research"

    def __init__(self, config: Config):
        self._base_api_url = config.servicenow_url
        self._web_base_url: str = config.servicenow_web_url
        self._id_token: str | None = None
        self._is_oauth = False
        self.session = requests.Session()
        self.session.headers.update({"Content-Type": "application/json", "Accept": "application/json"})
        if config.servicenow_client_id and config.servicenow_client_secret:
            from rcpond.auth import get_bearer_token, get_id_token

            self.session.headers["Authorization"] = f"Bearer {get_bearer_token(config)}"
            self._id_token = get_id_token()
            self._is_oauth = True
        else:
            self.session.headers["Ocp-Apim-Subscription-Key"] = config.servicenow_token or ""

    def get_tickets(self, long_list: bool = False) -> list[Ticket]:
        """Get tickets that are applications for HPC/Azure credits.

        Parameters
        ----------
        long_list : bool
            If ``False`` (default), return a curated shortlist relevant to the
            current user or bot. If ``True``, return all non-closed/resolved tickets.

        Returns
        -------
        list[Ticket]
            For interactive (OAuth) users — shortlist: unassigned or assigned to the
            current user; longlist: all non-closed/resolved tickets.
            For bot (token) users — shortlist: unassigned tickets where RCPond has not
            posted the most recent note; longlist: all non-closed/resolved tickets where
            RCPond has not posted the most recent note.
        """
        _BASE_QUERY = "short_description=Request access to HPC and cloud computing facilities"
        _CLOSED_STATES = frozenset({"Closed", "Resolved", "Cancelled"})

        ticket_fields = {field.name for field in dataclasses.fields(Ticket)}
        resp = self.session.get(
            f"{self._base_api_url}/{self._TABLE}", params={"sysparm_query": _BASE_QUERY, "sysparm_display_value": "all"}
        )
        resp.raise_for_status()

        tickets = [Ticket(**_extract_ticket_fields(tkt, ticket_fields)) for tkt in resp.json()["result"]]
        ## Always exclude closed/resolved tickets; remaining filters depend on auth mode and long_list
        tickets = [t for t in tickets if t.state not in _CLOSED_STATES]

        if long_list:
            ## Bot longlist: exclude tickets RCPond already handled (OAuth sees everything)
            if not self._is_oauth:
                tickets = [t for t in tickets if not t.is_rcpond_processed()]
        else:
            if self._is_oauth:
                my_name = self._current_user_display_name()
                ## Interactive shortlist: only tickets assigned to me or unassigned
                tickets = [t for t in tickets if t.assigned_to in ("", my_name)]
            else:
                ## Bot shortlist: unassigned tickets RCPond has not already handled
                tickets = [t for t in tickets if t.assigned_to == "" and not t.is_rcpond_processed()]

        return tickets

    def get_ticket(self, ticket_number: str) -> Ticket:
        """Returns the unique ticket matching ``ticket_number``, or raise ValueError if either no match,
        or multiple matches are found.

        The specified ticket may be assigned or unassigned

        Parameters
        ----------
        ticket_number : str
            The ticket number to look up (e.g. ``"RES0001234"``).

        Raises
        ------
        ValueError
            If no ticket matches, or if more than one matches (should not happen
            in practice — ServiceNow enforces uniqueness, but guarded defensively).
        """
        matched = [t for t in self.get_tickets(long_list=True) if t.number == ticket_number]
        if len(matched) == 0:
            err_msg = f"Ticket '{ticket_number}' not found."
            raise ValueError(err_msg)
        if len(matched) > 1:
            ## ServiceNow should prevent duplicate ticket numbers, but guard defensively.
            detail = "\n\n".join(str(t) for t in matched)
            err_msg = f"Multiple tickets match '{ticket_number}':\n{detail}"
            raise ValueError(err_msg)
        return matched[0]

    def web_url(self, tkt: Ticket) -> str:
        """Return the ServiceNow Web UI URL for ``tkt``.

        Parameters
        ----------
        tkt : Ticket
            The ticket to generate a URL for.
        """
        return f"{self._web_base_url.rstrip('/')}/{self._TABLE}.do?sys_id={tkt.sys_id}"

    def get_full_ticket(self, tkt: Ticket) -> FullTicket:
        """Get full ticket details."""

        ## Get details from ServiceNow as JSON
        extra_fields = {field.name for field in dataclasses.fields(FullTicket)} - {
            field.name for field in dataclasses.fields(Ticket)
        }

        ## All extra fields are ServiceNow catalogue variables and must be requested
        ## with the "variables." prefix — they are not top-level record fields.
        requested_fields = {f"variables.{f}" for f in extra_fields}

        resp = self.session.get(
            f"{self._base_api_url}/{self._TABLE}/{tkt.sys_id}",
            params={"sysparm_fields": ",".join(requested_fields), "sysparm_display_value": "all"},
        )

        resp.raise_for_status()

        ## Parse the returned JSON, stripping the "variables." prefix so the keys
        ## match FullTicket field names.
        result = {
            (k[len("variables.") :] if k.startswith("variables.") else k): v for k, v in resp.json()["result"].items()
        }

        return FullTicket.from_Ticket(tkt, **_extract_ticket_fields(result, extra_fields))

    def _fetch_fields(self, sys_id: str, fields: set[str]) -> dict[str, str]:
        """Fetch display values for the specified fields from a single record.

        Parameters
        ----------
        sys_id : str
            The ``sys_id`` of the record to fetch.
        fields : set[str]
            Top-level field names to request.

        Returns
        -------
        dict[str, str]
            Mapping of field name to its display-value string.
        """
        resp = self.session.get(
            f"{self._base_api_url}/{self._TABLE}/{sys_id}",
            params={"sysparm_display_value": "true", "sysparm_fields": ",".join(fields)},
        )
        resp.raise_for_status()
        return _extract_ticket_fields(resp.json()["result"], fields)

    def get_work_notes(self, tkt: Ticket) -> list[NoteEntry]:
        result = self._fetch_fields(tkt.sys_id, {"work_notes"})
        return _parse_comment_display_values(result["work_notes"])

    def get_assignee(self, tkt: Ticket) -> dict[str, str]:
        """
        A convenience method to retrieve the current `assigned_to` field for a Ticket.

        returns:
            A dict with two keys `display_value` and `value`
        """
        resp = self.session.get(
            f"{self._base_api_url}/{self._TABLE}/{tkt.sys_id}",
            params={
                "sysparm_display_value": "all",
                "sysparm_exclude_reference_link": "true",
                "sysparm_fields": "assigned_to",
                "sysparm_query_no_domain": "false",
            },
        )
        resp.raise_for_status()
        return resp.json()["result"]["assigned_to"]

    def post_note(self, tkt: Ticket, note: str) -> None:
        """Post a work note to a ticket.

        Params:
            tkt: The ticket
            note: The note to post
        """
        prefix = _note_prefix()

        ## This will append the `note` param to `work_notes` field
        resp = self.session.patch(
            f"{self._base_api_url}/{self._TABLE}/{tkt.sys_id}",
            json={"work_notes": prefix + note},
        )
        resp.raise_for_status()

    def _fetch_current_user_claims(self) -> dict:
        """Return identity claims for the current OAuth user.

        Tries in order:
        1. Decode the cached ``_id_token`` JWT (no extra network round-trip).
        2. Query the ``sys_user`` table with ``sys_id=javascript:gs.getUserID()``, which
           ServiceNow evaluates server-side so it returns exactly the authenticated user's
           record regardless of their sys_id.

        Returns a dict with at least ``sub`` (sys_id) and ``name`` keys on success.

        Raises
        ------
        RuntimeError
            If user identity cannot be determined.
        """
        if self._id_token:
            try:
                payload_b64 = self._id_token.split(".")[1]
                ## base64url requires padding to a multiple of 4
                return json.loads(base64.urlsafe_b64decode(payload_b64 + "=="))
            except Exception:
                pass  ## Malformed id_token; fall through to sys_user lookup

        try:
            resp = self.session.get(
                f"{self._web_base_url}/api/now/table/sys_user",
                params={
                    "sysparm_query": "sys_id=javascript:gs.getUserID()",
                    "sysparm_fields": "sys_id,name,user_name",
                    "sysparm_display_value": "true",
                },
            )
        except Exception as exc:
            msg = "Network error fetching user identity from ServiceNow. Check your connection and re-authenticate with 'rcpond login'."
            raise RuntimeError(msg) from exc

        if not resp.ok:
            msg = f"ServiceNow returned HTTP {resp.status_code} when fetching user identity. Re-authenticate with 'rcpond login'."
            raise RuntimeError(msg)

        result = resp.json().get("result", [])
        if not result:
            msg = (
                "ServiceNow returned no user record for the authenticated session. Re-authenticate with 'rcpond login'."
            )
            raise RuntimeError(msg)

        record = result[0]
        return {"sub": record["sys_id"], "name": record["name"], "user_name": record.get("user_name", "")}

    def _current_user_sys_id(self) -> str:
        """Return the current user's sys_id from OIDC claims (``sub`` claim).

        Raises
        ------
        RuntimeError
            If claims are unavailable or contain no ``sub`` field.
        """
        claims = self._fetch_current_user_claims()
        if sub := claims.get("sub"):
            return sub
        msg = "User identity claims contain no 'sub' (sys_id). Re-authenticate with 'rcpond login'."
        raise RuntimeError(msg)

    def _current_user_display_name(self) -> str:
        """Return the display name of the currently authenticated OAuth user.

        Tries OIDC claims (``name`` field) first; falls back to a ``sys_user`` table
        lookup using the ``sub`` claim.

        Returns
        -------
        str
            The user's display name.

        Raises
        ------
        RuntimeError
            If identity cannot be established.
        """
        claims = self._fetch_current_user_claims()
        if name := claims.get("name"):
            return name
        ## id_token present but lacks 'name' claim — look up by sub
        if sub := claims.get("sub"):
            resp = self.session.get(
                f"{self._web_base_url}/api/now/table/sys_user/{sub}",
                params={"sysparm_fields": "name", "sysparm_display_value": "true"},
            )
            resp.raise_for_status()
            return resp.json()["result"]["name"]
        msg = "User identity claims contain neither 'name' nor 'sub'. Re-authenticate with 'rcpond login'."
        raise RuntimeError(msg)

    def assign_to_me(self, ticket: Ticket) -> dict[str, str]:
        """Assign ``ticket`` to the currently authenticated OAuth user.

        Parameters
        ----------
        ticket : Ticket
            The ticket to assign.

        Returns
        -------
        dict[str, str]
            The updated assignee dict (``display_value`` and ``value``).

        Raises
        ------
        NotImplementedError
            If the client is using static token authentication, which does not
            carry a per-user identity. OAuth credentials (``servicenow_client_id``
            + ``servicenow_client_secret``) are required to use this feature.
        """
        if not self._is_oauth:
            msg = (
                "assign_to_me() requires OAuth authentication.\n"
                "Static token auth does not carry a per-user identity.\n"
                "Set servicenow_client_id and servicenow_client_secret in your config to enable this feature."
            )
            raise NotImplementedError(msg)
        return self.assign_to(ticket, self._current_user_sys_id())

    def _attempt_assign_to(self, ticket: Ticket, assignee: str) -> None:
        resp = self.session.patch(
            f"{self._base_api_url}/{self._TABLE}/{ticket.sys_id}",
            json={"assigned_to": assignee},
        )
        resp.raise_for_status()

    def assign_to(self, ticket: Ticket, assignee: str) -> dict[str, str]:
        """Assign the current user to a ticket.

        **Post-hoc validation is performed that this assignee exists in the ServiceNow instance. If an invalid value for `assignee` is provided. The method will assign the ticket to the invalid user, detect that it is invalid, and then revert the assignment to the original assignee. This interaction will show in the Activity log for the ticket in the ServiceNow WebUI**

        Example:
        >>> sn.assign_to(my_tkt, "sam@example.com")

        To unassign a ticket, set assignee == "":
        >>> sn.assign_to(my_tkt, "")

        Params:
            ticket: The ticket to be assigned
            assignee: The email address or sys_id (as a str) of the user to assign the ticket to.

        Returns:
            A dict with two keys `display_value` and `value`
        """
        _original_assignee = self.get_assignee(ticket)

        # Attempt assignment
        self._attempt_assign_to(ticket, assignee)

        # Verify success
        new_assignee = self.get_assignee(ticket)

        # The required result is to unassign the ticket (eg assignee is "" or None)
        if not assignee:
            if new_assignee["display_value"] != "" or new_assignee["value"] != "":
                err_msg = f"Expected ticket '{ticket.number}' to be unassigned but got: {new_assignee}"
                raise RuntimeError(err_msg)
            ticket.assigned_to = ""
            return new_assignee

        ## If the assign_to value was not recognised by ServiceNow, then the
        ## display_value will be empty
        if not new_assignee["display_value"]:
            # Reset the assignee to the original value
            # Trust that this works correctly
            self._attempt_assign_to(ticket, _original_assignee["value"])
            err_msg = (
                f"Unable to assign ticket '{ticket.number}' to the user '{assignee}'"
                " The user was not recognised by ServiceNow."
                f" The ticket has been re-assigned back to the original assignee '{_original_assignee['display_value']}/{_original_assignee['value']}'"
            )
            raise ValueError(err_msg)

        ticket.assigned_to = new_assignee["display_value"]
        return new_assignee

assign_to(ticket, assignee)

Assign the current user to a ticket.

Post-hoc validation is performed that this assignee exists in the ServiceNow instance. If an invalid value for assignee is provided. The method will assign the ticket to the invalid user, detect that it is invalid, and then revert the assignment to the original assignee. This interaction will show in the Activity log for the ticket in the ServiceNow WebUI

Example:

sn.assign_to(my_tkt, "sam@example.com")

To unassign a ticket, set assignee == "":

sn.assign_to(my_tkt, "")

Params: ticket: The ticket to be assigned assignee: The email address or sys_id (as a str) of the user to assign the ticket to.

Returns: A dict with two keys display_value and value

Source code in rcpond/servicenow.py
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
def assign_to(self, ticket: Ticket, assignee: str) -> dict[str, str]:
    """Assign the current user to a ticket.

    **Post-hoc validation is performed that this assignee exists in the ServiceNow instance. If an invalid value for `assignee` is provided. The method will assign the ticket to the invalid user, detect that it is invalid, and then revert the assignment to the original assignee. This interaction will show in the Activity log for the ticket in the ServiceNow WebUI**

    Example:
    >>> sn.assign_to(my_tkt, "sam@example.com")

    To unassign a ticket, set assignee == "":
    >>> sn.assign_to(my_tkt, "")

    Params:
        ticket: The ticket to be assigned
        assignee: The email address or sys_id (as a str) of the user to assign the ticket to.

    Returns:
        A dict with two keys `display_value` and `value`
    """
    _original_assignee = self.get_assignee(ticket)

    # Attempt assignment
    self._attempt_assign_to(ticket, assignee)

    # Verify success
    new_assignee = self.get_assignee(ticket)

    # The required result is to unassign the ticket (eg assignee is "" or None)
    if not assignee:
        if new_assignee["display_value"] != "" or new_assignee["value"] != "":
            err_msg = f"Expected ticket '{ticket.number}' to be unassigned but got: {new_assignee}"
            raise RuntimeError(err_msg)
        ticket.assigned_to = ""
        return new_assignee

    ## If the assign_to value was not recognised by ServiceNow, then the
    ## display_value will be empty
    if not new_assignee["display_value"]:
        # Reset the assignee to the original value
        # Trust that this works correctly
        self._attempt_assign_to(ticket, _original_assignee["value"])
        err_msg = (
            f"Unable to assign ticket '{ticket.number}' to the user '{assignee}'"
            " The user was not recognised by ServiceNow."
            f" The ticket has been re-assigned back to the original assignee '{_original_assignee['display_value']}/{_original_assignee['value']}'"
        )
        raise ValueError(err_msg)

    ticket.assigned_to = new_assignee["display_value"]
    return new_assignee

assign_to_me(ticket)

Assign ticket to the currently authenticated OAuth user.

Parameters:
  • ticket (Ticket) –

    The ticket to assign.

Returns:
  • dict[str, str]

    The updated assignee dict (display_value and value).

Raises:
  • NotImplementedError

    If the client is using static token authentication, which does not carry a per-user identity. OAuth credentials (servicenow_client_id + servicenow_client_secret) are required to use this feature.

Source code in rcpond/servicenow.py
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
def assign_to_me(self, ticket: Ticket) -> dict[str, str]:
    """Assign ``ticket`` to the currently authenticated OAuth user.

    Parameters
    ----------
    ticket : Ticket
        The ticket to assign.

    Returns
    -------
    dict[str, str]
        The updated assignee dict (``display_value`` and ``value``).

    Raises
    ------
    NotImplementedError
        If the client is using static token authentication, which does not
        carry a per-user identity. OAuth credentials (``servicenow_client_id``
        + ``servicenow_client_secret``) are required to use this feature.
    """
    if not self._is_oauth:
        msg = (
            "assign_to_me() requires OAuth authentication.\n"
            "Static token auth does not carry a per-user identity.\n"
            "Set servicenow_client_id and servicenow_client_secret in your config to enable this feature."
        )
        raise NotImplementedError(msg)
    return self.assign_to(ticket, self._current_user_sys_id())

get_assignee(tkt)

A convenience method to retrieve the current assigned_to field for a Ticket.

returns: A dict with two keys display_value and value

Source code in rcpond/servicenow.py
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
def get_assignee(self, tkt: Ticket) -> dict[str, str]:
    """
    A convenience method to retrieve the current `assigned_to` field for a Ticket.

    returns:
        A dict with two keys `display_value` and `value`
    """
    resp = self.session.get(
        f"{self._base_api_url}/{self._TABLE}/{tkt.sys_id}",
        params={
            "sysparm_display_value": "all",
            "sysparm_exclude_reference_link": "true",
            "sysparm_fields": "assigned_to",
            "sysparm_query_no_domain": "false",
        },
    )
    resp.raise_for_status()
    return resp.json()["result"]["assigned_to"]

get_full_ticket(tkt)

Get full ticket details.

Source code in rcpond/servicenow.py
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
def get_full_ticket(self, tkt: Ticket) -> FullTicket:
    """Get full ticket details."""

    ## Get details from ServiceNow as JSON
    extra_fields = {field.name for field in dataclasses.fields(FullTicket)} - {
        field.name for field in dataclasses.fields(Ticket)
    }

    ## All extra fields are ServiceNow catalogue variables and must be requested
    ## with the "variables." prefix — they are not top-level record fields.
    requested_fields = {f"variables.{f}" for f in extra_fields}

    resp = self.session.get(
        f"{self._base_api_url}/{self._TABLE}/{tkt.sys_id}",
        params={"sysparm_fields": ",".join(requested_fields), "sysparm_display_value": "all"},
    )

    resp.raise_for_status()

    ## Parse the returned JSON, stripping the "variables." prefix so the keys
    ## match FullTicket field names.
    result = {
        (k[len("variables.") :] if k.startswith("variables.") else k): v for k, v in resp.json()["result"].items()
    }

    return FullTicket.from_Ticket(tkt, **_extract_ticket_fields(result, extra_fields))

get_ticket(ticket_number)

Returns the unique ticket matching ticket_number, or raise ValueError if either no match, or multiple matches are found.

The specified ticket may be assigned or unassigned

Parameters:
  • ticket_number (str) –

    The ticket number to look up (e.g. "RES0001234").

Raises:
  • ValueError

    If no ticket matches, or if more than one matches (should not happen in practice — ServiceNow enforces uniqueness, but guarded defensively).

Source code in rcpond/servicenow.py
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
def get_ticket(self, ticket_number: str) -> Ticket:
    """Returns the unique ticket matching ``ticket_number``, or raise ValueError if either no match,
    or multiple matches are found.

    The specified ticket may be assigned or unassigned

    Parameters
    ----------
    ticket_number : str
        The ticket number to look up (e.g. ``"RES0001234"``).

    Raises
    ------
    ValueError
        If no ticket matches, or if more than one matches (should not happen
        in practice — ServiceNow enforces uniqueness, but guarded defensively).
    """
    matched = [t for t in self.get_tickets(long_list=True) if t.number == ticket_number]
    if len(matched) == 0:
        err_msg = f"Ticket '{ticket_number}' not found."
        raise ValueError(err_msg)
    if len(matched) > 1:
        ## ServiceNow should prevent duplicate ticket numbers, but guard defensively.
        detail = "\n\n".join(str(t) for t in matched)
        err_msg = f"Multiple tickets match '{ticket_number}':\n{detail}"
        raise ValueError(err_msg)
    return matched[0]

get_tickets(long_list=False)

Get tickets that are applications for HPC/Azure credits.

Parameters:
  • long_list (bool, default: False ) –

    If False (default), return a curated shortlist relevant to the current user or bot. If True, return all non-closed/resolved tickets.

Returns:
  • list[Ticket]

    For interactive (OAuth) users — shortlist: unassigned or assigned to the current user; longlist: all non-closed/resolved tickets. For bot (token) users — shortlist: unassigned tickets where RCPond has not posted the most recent note; longlist: all non-closed/resolved tickets where RCPond has not posted the most recent note.

Source code in rcpond/servicenow.py
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
def get_tickets(self, long_list: bool = False) -> list[Ticket]:
    """Get tickets that are applications for HPC/Azure credits.

    Parameters
    ----------
    long_list : bool
        If ``False`` (default), return a curated shortlist relevant to the
        current user or bot. If ``True``, return all non-closed/resolved tickets.

    Returns
    -------
    list[Ticket]
        For interactive (OAuth) users — shortlist: unassigned or assigned to the
        current user; longlist: all non-closed/resolved tickets.
        For bot (token) users — shortlist: unassigned tickets where RCPond has not
        posted the most recent note; longlist: all non-closed/resolved tickets where
        RCPond has not posted the most recent note.
    """
    _BASE_QUERY = "short_description=Request access to HPC and cloud computing facilities"
    _CLOSED_STATES = frozenset({"Closed", "Resolved", "Cancelled"})

    ticket_fields = {field.name for field in dataclasses.fields(Ticket)}
    resp = self.session.get(
        f"{self._base_api_url}/{self._TABLE}", params={"sysparm_query": _BASE_QUERY, "sysparm_display_value": "all"}
    )
    resp.raise_for_status()

    tickets = [Ticket(**_extract_ticket_fields(tkt, ticket_fields)) for tkt in resp.json()["result"]]
    ## Always exclude closed/resolved tickets; remaining filters depend on auth mode and long_list
    tickets = [t for t in tickets if t.state not in _CLOSED_STATES]

    if long_list:
        ## Bot longlist: exclude tickets RCPond already handled (OAuth sees everything)
        if not self._is_oauth:
            tickets = [t for t in tickets if not t.is_rcpond_processed()]
    else:
        if self._is_oauth:
            my_name = self._current_user_display_name()
            ## Interactive shortlist: only tickets assigned to me or unassigned
            tickets = [t for t in tickets if t.assigned_to in ("", my_name)]
        else:
            ## Bot shortlist: unassigned tickets RCPond has not already handled
            tickets = [t for t in tickets if t.assigned_to == "" and not t.is_rcpond_processed()]

    return tickets

post_note(tkt, note)

Post a work note to a ticket.

Params: tkt: The ticket note: The note to post

Source code in rcpond/servicenow.py
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
def post_note(self, tkt: Ticket, note: str) -> None:
    """Post a work note to a ticket.

    Params:
        tkt: The ticket
        note: The note to post
    """
    prefix = _note_prefix()

    ## This will append the `note` param to `work_notes` field
    resp = self.session.patch(
        f"{self._base_api_url}/{self._TABLE}/{tkt.sys_id}",
        json={"work_notes": prefix + note},
    )
    resp.raise_for_status()

web_url(tkt)

Return the ServiceNow Web UI URL for tkt.

Parameters:
  • tkt (Ticket) –

    The ticket to generate a URL for.

Source code in rcpond/servicenow.py
343
344
345
346
347
348
349
350
351
def web_url(self, tkt: Ticket) -> str:
    """Return the ServiceNow Web UI URL for ``tkt``.

    Parameters
    ----------
    tkt : Ticket
        The ticket to generate a URL for.
    """
    return f"{self._web_base_url.rstrip('/')}/{self._TABLE}.do?sys_id={tkt.sys_id}"

Ticket dataclass

A ticket; contains only high-level details about the ticket.

Source code in rcpond/servicenow.py
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
@dataclass
class Ticket:
    """A ticket; contains only high-level details about the ticket."""

    sys_id: str
    """The internal ServiceNow identifier for the ticket."""
    number: str
    """The ticket number as recognised by agents."""
    opened_at: str
    """A timestamp, formatted as `DD/MM/YYYY HH:MM:SS` """
    requested_for: str
    u_category: str
    u_sub_category: str
    short_description: str
    state: str
    """Human-readable ticket state, e.g. 'New', 'In Progress', 'On Hold', 'Resolved', 'Closed'."""
    assigned_to: str
    """Display name of the assigned agent, or empty string if unassigned."""
    work_notes: str
    """Raw display_value string for work notes, as returned by the ServiceNow API."""
    comments: str
    """Raw display_value string for additional comments, as returned by the ServiceNow API."""

    _REFRESHABLE_FIELDS: ClassVar[frozenset[str]] = frozenset({"state", "assigned_to", "work_notes", "comments"})

    def assign_to_me(self, service_now: ServiceNow) -> None:
        """Assign this ticket to the currently authenticated OAuth user.

        Parameters
        ----------
        service_now : ServiceNow
            The ServiceNow client. Must be configured with OAuth credentials.

        Raises
        ------
        NotImplementedError
            If the ServiceNow client is using static token authentication.
        """
        service_now.assign_to_me(self)

    def get_combined_notes(self) -> list[NoteEntry]:
        """Return work notes and comments merged and sorted chronologically (oldest first).

        Returns
        -------
        list[NoteEntry]
            All entries from ``work_notes`` and ``comments``, sorted by timestamp.
            The most recent entry is ``result[-1]``; the list is empty when both
            fields are blank.
        """
        entries = _parse_comment_display_values(self.work_notes) + _parse_comment_display_values(self.comments)
        return sorted(entries, key=lambda e: e.datetime_stamp)

    def is_rcpond_processed(self) -> bool:
        """Returns `True` if RCPond (any version) has ever posted a Comment or Work Note on this ticket. `False` otherwise."""
        return any(_RCPOND_NOTE_RE.match(e.content) for e in self.get_combined_notes())

    def is_rcpond_most_recent_process(self) -> bool:
        """Returns `True` if the current version of RCPond posted the most recent Comment or Work Note on this ticket. `False` otherwise."""
        notes = self.get_combined_notes()
        return bool(notes) and notes[-1].content.startswith(_note_prefix())

    def refresh(self, service_now: ServiceNow) -> None:
        """Refresh mutable fields by re-querying the ServiceNow API.

        Parameters
        ----------
        service_now : ServiceNow
            The ServiceNow client used to fetch updated values.
        """
        values = service_now._fetch_fields(self.sys_id, set(self._REFRESHABLE_FIELDS))
        for field, value in values.items():
            setattr(self, field, value)

assigned_to instance-attribute

Display name of the assigned agent, or empty string if unassigned.

comments instance-attribute

Raw display_value string for additional comments, as returned by the ServiceNow API.

number instance-attribute

The ticket number as recognised by agents.

opened_at instance-attribute

A timestamp, formatted as DD/MM/YYYY HH:MM:SS

state instance-attribute

Human-readable ticket state, e.g. 'New', 'In Progress', 'On Hold', 'Resolved', 'Closed'.

sys_id instance-attribute

The internal ServiceNow identifier for the ticket.

work_notes instance-attribute

Raw display_value string for work notes, as returned by the ServiceNow API.

assign_to_me(service_now)

Assign this ticket to the currently authenticated OAuth user.

Parameters:
  • service_now (ServiceNow) –

    The ServiceNow client. Must be configured with OAuth credentials.

Raises:
  • NotImplementedError

    If the ServiceNow client is using static token authentication.

Source code in rcpond/servicenow.py
72
73
74
75
76
77
78
79
80
81
82
83
84
85
def assign_to_me(self, service_now: ServiceNow) -> None:
    """Assign this ticket to the currently authenticated OAuth user.

    Parameters
    ----------
    service_now : ServiceNow
        The ServiceNow client. Must be configured with OAuth credentials.

    Raises
    ------
    NotImplementedError
        If the ServiceNow client is using static token authentication.
    """
    service_now.assign_to_me(self)

get_combined_notes()

Return work notes and comments merged and sorted chronologically (oldest first).

Returns:
  • list[NoteEntry]

    All entries from work_notes and comments, sorted by timestamp. The most recent entry is result[-1]; the list is empty when both fields are blank.

Source code in rcpond/servicenow.py
87
88
89
90
91
92
93
94
95
96
97
98
def get_combined_notes(self) -> list[NoteEntry]:
    """Return work notes and comments merged and sorted chronologically (oldest first).

    Returns
    -------
    list[NoteEntry]
        All entries from ``work_notes`` and ``comments``, sorted by timestamp.
        The most recent entry is ``result[-1]``; the list is empty when both
        fields are blank.
    """
    entries = _parse_comment_display_values(self.work_notes) + _parse_comment_display_values(self.comments)
    return sorted(entries, key=lambda e: e.datetime_stamp)

is_rcpond_most_recent_process()

Returns True if the current version of RCPond posted the most recent Comment or Work Note on this ticket. False otherwise.

Source code in rcpond/servicenow.py
104
105
106
107
def is_rcpond_most_recent_process(self) -> bool:
    """Returns `True` if the current version of RCPond posted the most recent Comment or Work Note on this ticket. `False` otherwise."""
    notes = self.get_combined_notes()
    return bool(notes) and notes[-1].content.startswith(_note_prefix())

is_rcpond_processed()

Returns True if RCPond (any version) has ever posted a Comment or Work Note on this ticket. False otherwise.

Source code in rcpond/servicenow.py
100
101
102
def is_rcpond_processed(self) -> bool:
    """Returns `True` if RCPond (any version) has ever posted a Comment or Work Note on this ticket. `False` otherwise."""
    return any(_RCPOND_NOTE_RE.match(e.content) for e in self.get_combined_notes())

refresh(service_now)

Refresh mutable fields by re-querying the ServiceNow API.

Parameters:
  • service_now (ServiceNow) –

    The ServiceNow client used to fetch updated values.

Source code in rcpond/servicenow.py
109
110
111
112
113
114
115
116
117
118
119
def refresh(self, service_now: ServiceNow) -> None:
    """Refresh mutable fields by re-querying the ServiceNow API.

    Parameters
    ----------
    service_now : ServiceNow
        The ServiceNow client used to fetch updated values.
    """
    values = service_now._fetch_fields(self.sys_id, set(self._REFRESHABLE_FIELDS))
    for field, value in values.items():
        setattr(self, field, value)

tool

Generic LLM tool interface.

Provides:

  • Tool: Abstract base class for LLM tools. Each tool exposes its schema via to_openai_dict() and runs its action via execute().

Concrete implementations live in rcpond.tools.

Example use

class MyTool(Tool): ... @property ... def name(self) -> str: ... return "my_tool" ... ... @property ... def description(self) -> str: ... return "Does something." ... ... def to_openai_dict(self) -> dict: ... ... def execute(self, service_now, ticket, **kwargs) -> None: ...

Tool

Bases: ABC

Abstract base class for an LLM tool.

Subclasses define their own schema (to_openai_dict) and execution logic (execute). The name and description properties drive both the schema and tool-call dispatch in command.py.

Source code in rcpond/tool.py
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
class Tool(ABC):
    """Abstract base class for an LLM tool.

    Subclasses define their own schema (``to_openai_dict``) and execution logic
    (``execute``). The ``name`` and ``description`` properties drive both the
    schema and tool-call dispatch in ``command.py``.
    """

    @property
    @abstractmethod
    def name(self) -> str:
        """The function name exposed to the LLM."""

    @property
    @abstractmethod
    def description(self) -> str:
        """The human-readable description exposed to the LLM."""

    @abstractmethod
    def to_openai_dict(self) -> dict:
        """Return this tool's schema in OpenAI function-calling format.

        Returns
        -------
        dict
            A dict suitable for the ``tools`` parameter of the chat completions API.
        """

    @abstractmethod
    def execute(self, service_now: ServiceNow, ticket: FullTicket, **kwargs) -> None:
        """Execute this tool's action.

        Parameters
        ----------
        service_now : ServiceNow
            The ServiceNow client used to perform the action.
        ticket : FullTicket
            The ticket the action should be applied to.
        **kwargs
            Arguments supplied by the LLM.
        """

description abstractmethod property

The human-readable description exposed to the LLM.

name abstractmethod property

The function name exposed to the LLM.

execute(service_now, ticket, **kwargs) abstractmethod

Execute this tool's action.

Parameters:
  • service_now (ServiceNow) –

    The ServiceNow client used to perform the action.

  • ticket (FullTicket) –

    The ticket the action should be applied to.

  • **kwargs

    Arguments supplied by the LLM.

Source code in rcpond/tool.py
63
64
65
66
67
68
69
70
71
72
73
74
75
@abstractmethod
def execute(self, service_now: ServiceNow, ticket: FullTicket, **kwargs) -> None:
    """Execute this tool's action.

    Parameters
    ----------
    service_now : ServiceNow
        The ServiceNow client used to perform the action.
    ticket : FullTicket
        The ticket the action should be applied to.
    **kwargs
        Arguments supplied by the LLM.
    """

to_openai_dict() abstractmethod

Return this tool's schema in OpenAI function-calling format.

Returns:
  • dict

    A dict suitable for the tools parameter of the chat completions API.

Source code in rcpond/tool.py
53
54
55
56
57
58
59
60
61
@abstractmethod
def to_openai_dict(self) -> dict:
    """Return this tool's schema in OpenAI function-calling format.

    Returns
    -------
    dict
        A dict suitable for the ``tools`` parameter of the chat completions API.
    """

tools

rcpond-specific tool definitions.

Provides:

  • PostFreeformNoteTool: Posts a freeform LLM-written note to ServiceNow.
  • PostTemplatedNoteTool: Renders a Jinja2 template selected by the LLM and posts it.
  • get_available_tools: Returns the list of tools available to the LLM.

The generic Tool ABC is defined in rcpond.tool.

Example use
tools = get_available_tools(config)
response = llm.generate(system, user, model, tools=tools)
if response.planned_tool_call:
    name = response.planned_tool_call["function"]["name"]
    args = response.planned_tool_call["function"]["arguments"]
    for t in tools:
        if t.name == name:
            t.execute(service_now, ticket, **args)

PostFreeformNoteTool

Bases: Tool

Posts a freeform work note written by the LLM to the ServiceNow ticket.

Example:

tool = PostFreeformNoteTool()

Source code in rcpond/tools.py
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
class PostFreeformNoteTool(Tool):
    """Posts a freeform work note written by the LLM to the ServiceNow ticket.

    Example:
    >>> tool = PostFreeformNoteTool()
    """

    @property
    def name(self) -> str:
        return "post_freeform_note"

    @property
    def description(self) -> str:
        return "Post a freeform work note to the ServiceNow ticket, written by the LLM."

    def to_openai_dict(self) -> dict:
        return {
            "type": "function",
            "function": {
                "name": self.name,
                "description": self.description,
                "parameters": {
                    "type": "object",
                    "properties": {"note": {"type": "string"}},
                    "required": ["note"],
                },
            },
        }

    def execute(self, service_now: ServiceNow, ticket: FullTicket, **kwargs) -> None:
        service_now.post_note(ticket, note=kwargs["note"])

PostTemplatedNoteTool

Bases: Tool

Renders a Jinja2 template selected by the LLM and posts it as a work note.

Templates are read from email_templates_dir. The ticket object is available in every template as {{ ticket.<field> }}. All other variables are supplied by the LLM.

Example:

tool = PostTemplatedNoteTool(config)

Source code in rcpond/tools.py
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
class PostTemplatedNoteTool(Tool):
    """Renders a Jinja2 template selected by the LLM and posts it as a work note.

    Templates are read from ``email_templates_dir``. The ``ticket`` object is
    available in every template as ``{{ ticket.<field> }}``. All other variables
    are supplied by the LLM.

    Example:
    >>> tool = PostTemplatedNoteTool(config)
    """

    def __init__(self, config: Config) -> None:
        self._dir = config.email_templates_dir
        self._templates: dict[str, Path] = {f.name: f for f in sorted(self._dir.glob("*.j2"))}

    @property
    def name(self) -> str:
        return "post_templated_note"

    @property
    def description(self) -> str:
        return (
            "Post a work note to the ServiceNow ticket using a predefined Jinja2 template. "
            "Select the most appropriate template and supply the required parameters."
        )

    def _llm_vars(self) -> set[str]:
        """Return the union of undeclared template variables across all templates, excluding 'ticket'."""
        env = jinja2.Environment()
        vars: set[str] = set()
        for path in self._templates.values():
            ast = env.parse(path.read_text())
            vars |= jinja2.meta.find_undeclared_variables(ast)
        vars.discard("ticket")
        return vars

    def to_openai_dict(self) -> dict:
        """Templates prefixed '_' are omitted from the template_name enum but their variables are still surfaced to the LLM."""
        llm_vars = self._llm_vars()
        properties: dict = {
            "template_name": {
                "type": "string",
                "enum": [k for k in self._templates if not k.startswith("_")],
            }
        }
        for var in sorted(llm_vars):
            properties[var] = {"type": "string"}
        required = ["template_name", *sorted(llm_vars)]
        return {
            "type": "function",
            "function": {
                "name": self.name,
                "description": self.description,
                "parameters": {
                    "type": "object",
                    "properties": properties,
                    "required": required,
                },
            },
        }

    def execute(self, service_now: ServiceNow, ticket: FullTicket, **kwargs) -> None:
        template_name = kwargs.pop("template_name")
        jinja_env = jinja2.Environment(loader=jinja2.FileSystemLoader(str(self._dir)))
        rendered = jinja_env.get_template(template_name).render(ticket=ticket, **kwargs)
        service_now.post_note(ticket, note=rendered)

to_openai_dict()

Templates prefixed '_' are omitted from the template_name enum but their variables are still surfaced to the LLM.

Source code in rcpond/tools.py
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
def to_openai_dict(self) -> dict:
    """Templates prefixed '_' are omitted from the template_name enum but their variables are still surfaced to the LLM."""
    llm_vars = self._llm_vars()
    properties: dict = {
        "template_name": {
            "type": "string",
            "enum": [k for k in self._templates if not k.startswith("_")],
        }
    }
    for var in sorted(llm_vars):
        properties[var] = {"type": "string"}
    required = ["template_name", *sorted(llm_vars)]
    return {
        "type": "function",
        "function": {
            "name": self.name,
            "description": self.description,
            "parameters": {
                "type": "object",
                "properties": properties,
                "required": required,
            },
        },
    }

get_available_tools(config)

Return the list of tools available to the LLM.

Parameters:
  • config (Config) –

    The loaded configuration.

Returns:
  • list[Tool]

    The tools the LLM may call.

Source code in rcpond/tools.py
145
146
147
148
149
150
151
152
153
154
155
156
157
158
def get_available_tools(config: Config) -> list[Tool]:
    """Return the list of tools available to the LLM.

    Parameters
    ----------
    config : Config
        The loaded configuration.

    Returns
    -------
    list[Tool]
        The tools the LLM may call.
    """
    return [PostFreeformNoteTool(), PostTemplatedNoteTool(config)]