mirror of
https://github.com/forcedotcom/afv-library.git
synced 2026-07-30 03:09:50 +08:00
557 lines
21 KiB
Python
557 lines
21 KiB
Python
|
|
"""Session discovery for investigating-agentforce-d360 — Data Cloud only.
|
|||
|
|
|
|||
|
|
Lets the user find candidate sessions when they don't have a session id.
|
|||
|
|
Output is a numbered picker table to stdout; the skill agent asks the user
|
|||
|
|
to pick one, then falls through to the existing fetch_dc.py pipeline with
|
|||
|
|
the chosen UUID. Writes no artifacts.
|
|||
|
|
|
|||
|
|
DC-only by design (no OTel path).
|
|||
|
|
|
|||
|
|
## CLI
|
|||
|
|
|
|||
|
|
python3 scripts/discover_sessions.py --org <alias> [options]
|
|||
|
|
|
|||
|
|
Options (all optional except --org):
|
|||
|
|
--since <expr> "last 2 hours" | "last 10" | "today" | "yesterday"
|
|||
|
|
| "YYYY-MM-DD" | "YYYY-MM-DD to YYYY-MM-DD"
|
|||
|
|
| explicit UTC/TZ datetime (RFC 3339). Default: last 24 hours.
|
|||
|
|
--agent <name> Agent API name (e.g. MyAgent). Adds participant JOIN.
|
|||
|
|
--channel <type> Builder | Messaging | Voice. "Messaging" maps to
|
|||
|
|
"SCRT2 - EmbeddedMessaging" in SQL.
|
|||
|
|
--outcome <type> USER_ENDED | ESCALATED | TRANSFERRED | TIMEOUT | NOT_SET
|
|||
|
|
--grep <pattern> Substring in conversation text (user input + agent output).
|
|||
|
|
Adds interaction + message JOINs. Case-insensitive by
|
|||
|
|
default (LOWER on both sides); pass --grep-case-sensitive
|
|||
|
|
for exact match. `%` and `_` are escaped so they match
|
|||
|
|
the literal char.
|
|||
|
|
--grep-case-sensitive
|
|||
|
|
Opt-in: make --grep case-sensitive (rare; default is
|
|||
|
|
case-insensitive because users almost always type the
|
|||
|
|
quote in whatever case they remember it).
|
|||
|
|
--tz <IANA> Override auto-detected local tz (e.g. America/Los_Angeles).
|
|||
|
|
Only affects calendar-day resolution (today/yesterday/
|
|||
|
|
bare-date/date-range); `last N` windows are anchored to
|
|||
|
|
UTC regardless. The chosen tz is echoed in zero-row
|
|||
|
|
output so the user can sanity-check boundaries.
|
|||
|
|
--limit <N> Max sessions. Default 20.
|
|||
|
|
|
|||
|
|
Exit codes:
|
|||
|
|
0 — ≥1 session found
|
|||
|
|
2 — zero sessions (picker prints composed SQL so user can widen)
|
|||
|
|
other — DC query or arg-parse error
|
|||
|
|
"""
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import argparse
|
|||
|
|
import html
|
|||
|
|
import os
|
|||
|
|
import re
|
|||
|
|
import sys
|
|||
|
|
from dataclasses import dataclass
|
|||
|
|
from datetime import datetime, timedelta, timezone
|
|||
|
|
from pathlib import Path
|
|||
|
|
from typing import Optional
|
|||
|
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|||
|
|
|
|||
|
|
sys.path.insert(0, str(Path(__file__).parent))
|
|||
|
|
|
|||
|
|
from dc import DCQueryError, load_sql, post, resolve_org
|
|||
|
|
from _shared.sql import _escape_sql_literal
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---- channel alias ---------------------------------------------------------
|
|||
|
|
|
|||
|
|
# Source-skill convention: users type "Messaging" but DC stores the full
|
|||
|
|
# SCRT2 string. Other channels pass through as-is (Builder, Voice).
|
|||
|
|
_CHANNEL_ALIAS = {
|
|||
|
|
"Messaging": "SCRT2 - EmbeddedMessaging",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---- escaping --------------------------------------------------------------
|
|||
|
|
#
|
|||
|
|
# `_escape_sql_literal` is imported from `_shared.sql` (single source of
|
|||
|
|
# truth per its module docstring). Do NOT re-declare locally — divergence
|
|||
|
|
# would break the documented contract that every DC-SQL caller shares one
|
|||
|
|
# escape strategy.
|
|||
|
|
|
|||
|
|
_LIKE_ESCAPE = "!" # ASCII, doesn't collide with SQL syntax, no quoting ambiguity
|
|||
|
|
|
|||
|
|
def _escape_like_pattern(s: str) -> str:
|
|||
|
|
"""Escape SQL LIKE wildcards (`%`, `_`) and the escape char itself
|
|||
|
|
(`!`) so `--grep "100%_off"` matches the literal text, not "100
|
|||
|
|
anything-underscore-off". Quote-doubling still happens on top of this
|
|||
|
|
via _escape_sql_literal. Used with the `ESCAPE '!'` clause in the
|
|||
|
|
composed SQL.
|
|||
|
|
|
|||
|
|
Backslash was avoided because `ESCAPE '\\'` in source reduces to a
|
|||
|
|
bare backslash on the wire, which some SQL parsers read as an
|
|||
|
|
unterminated string literal (\\' → escaped quote).
|
|||
|
|
"""
|
|||
|
|
return (
|
|||
|
|
s.replace(_LIKE_ESCAPE, _LIKE_ESCAPE * 2)
|
|||
|
|
.replace("%", f"{_LIKE_ESCAPE}%")
|
|||
|
|
.replace("_", f"{_LIKE_ESCAPE}_")
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---- time parsing ----------------------------------------------------------
|
|||
|
|
|
|||
|
|
@dataclass(frozen=True)
|
|||
|
|
class TimeRange:
|
|||
|
|
"""Half-open UTC range [start, end). `expr` is the original user input
|
|||
|
|
so the picker header can echo back what was searched."""
|
|||
|
|
start_utc: datetime
|
|||
|
|
end_utc: datetime
|
|||
|
|
expr: str
|
|||
|
|
tz_name: str # IANA or "UTC"
|
|||
|
|
|
|||
|
|
def iso(self, dt: datetime) -> str:
|
|||
|
|
return dt.strftime("%Y-%m-%dT%H:%M:%S.000Z")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _autodetect_tz() -> str:
|
|||
|
|
"""IANA tz from /etc/localtime symlink (macOS / most Linux). Falls
|
|||
|
|
back to the TZ env var, then UTC."""
|
|||
|
|
link = Path("/etc/localtime")
|
|||
|
|
try:
|
|||
|
|
if link.is_symlink():
|
|||
|
|
target = os.readlink(link)
|
|||
|
|
# Typically .../zoneinfo/America/Los_Angeles
|
|||
|
|
if "zoneinfo/" in target:
|
|||
|
|
return target.split("zoneinfo/", 1)[1]
|
|||
|
|
except OSError:
|
|||
|
|
pass
|
|||
|
|
return os.environ.get("TZ") or "UTC"
|
|||
|
|
|
|||
|
|
|
|||
|
|
_RX_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$") # @rule-suppress starter-sec-002 — re.compile, not eval/exec
|
|||
|
|
_RX_DATE_RANGE = re.compile(r"^(\d{4}-\d{2}-\d{2})\s+to\s+(\d{4}-\d{2}-\d{2})$") # @rule-suppress starter-sec-002 — re.compile, not eval/exec
|
|||
|
|
_RX_LAST_N = re.compile( # @rule-suppress starter-sec-002 — re.compile, not eval/exec
|
|||
|
|
r"^last\s+(\d+)(?:\s+(hour|hours|day|days|minute|minutes))?$",
|
|||
|
|
re.IGNORECASE,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def parse_time_expr(expr: Optional[str], tz_name: str) -> TimeRange:
|
|||
|
|
"""Parse one of the supported time expressions into a UTC half-open
|
|||
|
|
range. Default (None) is "last 24 hours" so every query has a sane
|
|||
|
|
time predicate — DC scans over the whole DMO without one are slow."""
|
|||
|
|
tz_name = tz_name or _autodetect_tz()
|
|||
|
|
try:
|
|||
|
|
local_tz = ZoneInfo(tz_name)
|
|||
|
|
except ZoneInfoNotFoundError:
|
|||
|
|
raise SystemExit(f"discover_sessions: unknown IANA timezone {tz_name!r}")
|
|||
|
|
|
|||
|
|
now_utc = datetime.now(timezone.utc)
|
|||
|
|
|
|||
|
|
if expr is None:
|
|||
|
|
return TimeRange(
|
|||
|
|
start_utc=now_utc - timedelta(hours=24),
|
|||
|
|
end_utc=now_utc,
|
|||
|
|
expr="last 24 hours (default)",
|
|||
|
|
tz_name=tz_name,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
s = expr.strip()
|
|||
|
|
sl = s.lower()
|
|||
|
|
|
|||
|
|
# "last N" / "last N hours" / "last N days" / "last N minutes"
|
|||
|
|
m = _RX_LAST_N.match(sl)
|
|||
|
|
if m:
|
|||
|
|
n = int(m.group(1))
|
|||
|
|
unit = (m.group(2) or "hours").lower()
|
|||
|
|
if unit in ("hour", "hours"):
|
|||
|
|
delta = timedelta(hours=n)
|
|||
|
|
elif unit in ("day", "days"):
|
|||
|
|
delta = timedelta(days=n)
|
|||
|
|
elif unit in ("minute", "minutes"):
|
|||
|
|
delta = timedelta(minutes=n)
|
|||
|
|
else:
|
|||
|
|
raise SystemExit(f"discover_sessions: unsupported time unit {unit!r}")
|
|||
|
|
return TimeRange(
|
|||
|
|
start_utc=now_utc - delta,
|
|||
|
|
end_utc=now_utc,
|
|||
|
|
expr=s,
|
|||
|
|
tz_name=tz_name,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# "today" / "yesterday" — calendar day in local tz
|
|||
|
|
if sl in ("today", "yesterday"):
|
|||
|
|
local_now = datetime.now(local_tz)
|
|||
|
|
offset_days = 0 if sl == "today" else 1
|
|||
|
|
start_local = local_now.replace(hour=0, minute=0, second=0, microsecond=0) - timedelta(days=offset_days)
|
|||
|
|
end_local = start_local + timedelta(days=1)
|
|||
|
|
return TimeRange(
|
|||
|
|
start_utc=start_local.astimezone(timezone.utc),
|
|||
|
|
end_utc=end_local.astimezone(timezone.utc),
|
|||
|
|
expr=s,
|
|||
|
|
tz_name=tz_name,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# "YYYY-MM-DD to YYYY-MM-DD" — inclusive range in local tz
|
|||
|
|
m = _RX_DATE_RANGE.match(s)
|
|||
|
|
if m:
|
|||
|
|
start_d, end_d = m.group(1), m.group(2)
|
|||
|
|
start_local = datetime.strptime(start_d, "%Y-%m-%d").replace(tzinfo=local_tz)
|
|||
|
|
end_local = datetime.strptime(end_d, "%Y-%m-%d").replace(tzinfo=local_tz) + timedelta(days=1)
|
|||
|
|
return TimeRange(
|
|||
|
|
start_utc=start_local.astimezone(timezone.utc),
|
|||
|
|
end_utc=end_local.astimezone(timezone.utc),
|
|||
|
|
expr=s,
|
|||
|
|
tz_name=tz_name,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Bare "YYYY-MM-DD"
|
|||
|
|
if _RX_DATE.match(s):
|
|||
|
|
start_local = datetime.strptime(s, "%Y-%m-%d").replace(tzinfo=local_tz)
|
|||
|
|
end_local = start_local + timedelta(days=1)
|
|||
|
|
return TimeRange(
|
|||
|
|
start_utc=start_local.astimezone(timezone.utc),
|
|||
|
|
end_utc=end_local.astimezone(timezone.utc),
|
|||
|
|
expr=s,
|
|||
|
|
tz_name=tz_name,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# Try RFC 3339 single datetime — caller probably meant "since this time"
|
|||
|
|
try:
|
|||
|
|
dt = datetime.fromisoformat(s.replace("Z", "+00:00"))
|
|||
|
|
if dt.tzinfo is None:
|
|||
|
|
dt = dt.replace(tzinfo=local_tz)
|
|||
|
|
return TimeRange(
|
|||
|
|
start_utc=dt.astimezone(timezone.utc),
|
|||
|
|
end_utc=now_utc,
|
|||
|
|
expr=f"since {s}",
|
|||
|
|
tz_name=tz_name,
|
|||
|
|
)
|
|||
|
|
except ValueError:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
raise SystemExit(
|
|||
|
|
f"discover_sessions: cannot parse --since {s!r}. "
|
|||
|
|
f"Try: `last 2 hours`, `today`, `yesterday`, `2026-04-22`, "
|
|||
|
|
f"`2026-04-22 to 2026-04-25`, or an ISO datetime."
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---- SQL composition -------------------------------------------------------
|
|||
|
|
|
|||
|
|
def compose_sql(
|
|||
|
|
*,
|
|||
|
|
tr: TimeRange,
|
|||
|
|
agent: Optional[str],
|
|||
|
|
channel: Optional[str],
|
|||
|
|
outcome: Optional[str],
|
|||
|
|
grep: Optional[str],
|
|||
|
|
grep_case_sensitive: bool = False,
|
|||
|
|
limit: int,
|
|||
|
|
) -> str:
|
|||
|
|
"""Build the discovery SQL. Conditional JOINs + WHERE ANDs are
|
|||
|
|
composed from `tr` + flag args; the SQL skeleton lives at
|
|||
|
|
`assets/dc/discover_sessions.sql`."""
|
|||
|
|
joins: list[str] = []
|
|||
|
|
wheres: list[str] = [
|
|||
|
|
f"s.ssot__StartTimestamp__c >= '{tr.iso(tr.start_utc)}'",
|
|||
|
|
f"s.ssot__StartTimestamp__c < '{tr.iso(tr.end_utc)}'",
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
if agent:
|
|||
|
|
joins.append(
|
|||
|
|
"JOIN ssot__AiAgentSessionParticipant__dlm p "
|
|||
|
|
"ON s.ssot__Id__c = p.ssot__AiAgentSessionId__c"
|
|||
|
|
)
|
|||
|
|
wheres.append(f"p.ssot__AiAgentApiName__c = '{_escape_sql_literal(agent)}'")
|
|||
|
|
|
|||
|
|
if grep:
|
|||
|
|
joins.append(
|
|||
|
|
"JOIN ssot__AiAgentInteraction__dlm i "
|
|||
|
|
"ON s.ssot__Id__c = i.ssot__AiAgentSessionId__c"
|
|||
|
|
)
|
|||
|
|
joins.append(
|
|||
|
|
"JOIN ssot__AiAgentInteractionMessage__dlm m "
|
|||
|
|
"ON i.ssot__Id__c = m.ssot__AiAgentInteractionId__c"
|
|||
|
|
)
|
|||
|
|
# LIKE wildcards (%, _) and the escape char (!) are neutralized so
|
|||
|
|
# `--grep "100%_off"` matches the literal substring. Quote-doubling
|
|||
|
|
# is layered on top via _escape_sql_literal. The `ESCAPE '!'` clause
|
|||
|
|
# tells SQL the escape char is `!`.
|
|||
|
|
pat = _escape_sql_literal(_escape_like_pattern(grep))
|
|||
|
|
if grep_case_sensitive:
|
|||
|
|
wheres.append(
|
|||
|
|
f"m.ssot__ContentText__c LIKE '%{pat}%' ESCAPE '{_LIKE_ESCAPE}'"
|
|||
|
|
)
|
|||
|
|
else:
|
|||
|
|
# Lowercase both sides so the predicate is case-insensitive.
|
|||
|
|
# Default behaviour — users almost always type the quoted
|
|||
|
|
# phrase in whatever case they remember, not the case the
|
|||
|
|
# agent rendered it in.
|
|||
|
|
wheres.append(
|
|||
|
|
f"LOWER(m.ssot__ContentText__c) LIKE LOWER('%{pat}%') "
|
|||
|
|
f"ESCAPE '{_LIKE_ESCAPE}'"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
if channel:
|
|||
|
|
ch = _CHANNEL_ALIAS.get(channel, channel)
|
|||
|
|
wheres.append(f"s.ssot__AiAgentChannelType__c = '{_escape_sql_literal(ch)}'")
|
|||
|
|
|
|||
|
|
if outcome:
|
|||
|
|
wheres.append(f"s.ssot__AiAgentSessionEndType__c = '{_escape_sql_literal(outcome)}'")
|
|||
|
|
|
|||
|
|
# DISTINCT when JOINs are present — a session can JOIN multiple
|
|||
|
|
# participants / messages and explode rows.
|
|||
|
|
columns = (
|
|||
|
|
"s.ssot__Id__c, s.ssot__StartTimestamp__c, s.ssot__EndTimestamp__c, "
|
|||
|
|
"s.ssot__AiAgentChannelType__c, s.ssot__AiAgentSessionEndType__c"
|
|||
|
|
)
|
|||
|
|
select_list = f"DISTINCT {columns}" if joins else columns
|
|||
|
|
|
|||
|
|
return load_sql(
|
|||
|
|
"discover_sessions",
|
|||
|
|
SELECT_LIST=select_list,
|
|||
|
|
JOINS="\n".join(joins),
|
|||
|
|
WHERE_CLAUSE=" AND ".join(wheres),
|
|||
|
|
LIMIT=str(limit),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---- agent-name enrichment -------------------------------------------------
|
|||
|
|
|
|||
|
|
_NAME_NOT_SET = {"", "NOT_SET", None}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def fetch_agent_names(
|
|||
|
|
session_ids: list[str], *, instance_url: str, token: str
|
|||
|
|
) -> dict[str, str]:
|
|||
|
|
"""One follow-up query to grab agent names for discovered sessions.
|
|||
|
|
The discovery query itself drops agent name from the projection
|
|||
|
|
because adding it to SELECT forces a participant JOIN on every query
|
|||
|
|
(including the no-filter case) and DC penalizes that.
|
|||
|
|
|
|||
|
|
Returns {session_uuid: agent_api_name}. Missing sessions → not in dict.
|
|||
|
|
|
|||
|
|
No role filter on the SQL. Some agent shapes (e.g. MyAgent) leave
|
|||
|
|
AGENT-role rows with ``ssot__AiAgentApiName__c = 'NOT_SET'`` while USER-
|
|||
|
|
role rows correctly carry the agent's api_name. Filtering to ``role =
|
|||
|
|
'AGENT'`` would either return no row (picker shows '—' for a session
|
|||
|
|
that clearly has an agent) or return the literal ``'NOT_SET'`` (picker
|
|||
|
|
misleads the operator). Dropping the role filter and de-duplicating in
|
|||
|
|
Python with the dominant-agent rule (``sorted(...)[0]``) recovers both
|
|||
|
|
cases. Mirrors the fallback in ``fetch_dc._resolve_identity``.
|
|||
|
|
"""
|
|||
|
|
if not session_ids:
|
|||
|
|
return {}
|
|||
|
|
quoted = ", ".join(f"'{_escape_sql_literal(sid)}'" for sid in session_ids)
|
|||
|
|
sql = (
|
|||
|
|
"SELECT ssot__AiAgentSessionId__c, ssot__AiAgentApiName__c "
|
|||
|
|
"FROM ssot__AiAgentSessionParticipant__dlm "
|
|||
|
|
f"WHERE ssot__AiAgentSessionId__c IN ({quoted})"
|
|||
|
|
)
|
|||
|
|
try:
|
|||
|
|
rows = post(sql, instance_url, token, "discover_sessions_agent_names")
|
|||
|
|
except DCQueryError:
|
|||
|
|
return {} # non-fatal — picker shows "—" for agent
|
|||
|
|
# Group all api_names per session, drop NOT_SET sentinels, dominant-agent
|
|||
|
|
# pick (lexicographic first) to match the rule used in fetch_dc.
|
|||
|
|
# Sessions where every participant row is NOT_SET fall out of the result
|
|||
|
|
# entirely — the picker will show '—', which is more honest than
|
|||
|
|
# displaying the literal 'NOT_SET'.
|
|||
|
|
by_sid: dict[str, set[str]] = {}
|
|||
|
|
for row in rows:
|
|||
|
|
sid = row.get("ssot__AiAgentSessionId__c")
|
|||
|
|
name = row.get("ssot__AiAgentApiName__c")
|
|||
|
|
if not sid or name in _NAME_NOT_SET:
|
|||
|
|
continue
|
|||
|
|
by_sid.setdefault(sid, set()).add(name)
|
|||
|
|
return {sid: sorted(names)[0] for sid, names in by_sid.items() if names}
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---- rendering -------------------------------------------------------------
|
|||
|
|
|
|||
|
|
def _fmt_duration(start_iso: Optional[str], end_iso: Optional[str]) -> str:
|
|||
|
|
"""Human-readable duration (e.g. "1m 12s", "28s") from two ISO timestamps.
|
|||
|
|
Returns "—" if start_iso is missing or unparseable. When end_iso is
|
|||
|
|
None but start_iso parses, computes elapsed-from-now and appends "+"
|
|||
|
|
to signal the session is still open (or end timestamp hasn't
|
|||
|
|
materialized in DC yet)."""
|
|||
|
|
if not start_iso:
|
|||
|
|
return "—"
|
|||
|
|
try:
|
|||
|
|
start = datetime.fromisoformat(start_iso.replace("Z", "+00:00"))
|
|||
|
|
except ValueError:
|
|||
|
|
return "—"
|
|||
|
|
ongoing = False
|
|||
|
|
if not end_iso:
|
|||
|
|
end = datetime.now(timezone.utc)
|
|||
|
|
ongoing = True
|
|||
|
|
else:
|
|||
|
|
try:
|
|||
|
|
end = datetime.fromisoformat(end_iso.replace("Z", "+00:00"))
|
|||
|
|
except ValueError:
|
|||
|
|
return "—"
|
|||
|
|
seconds = int((end - start).total_seconds())
|
|||
|
|
if seconds < 0:
|
|||
|
|
return "—"
|
|||
|
|
suffix = "+" if ongoing else ""
|
|||
|
|
if seconds < 60:
|
|||
|
|
return f"{seconds}s{suffix}"
|
|||
|
|
m, s = divmod(seconds, 60)
|
|||
|
|
if m < 60:
|
|||
|
|
return (f"{m}m {s}s" if s else f"{m}m") + suffix
|
|||
|
|
h, m = divmod(m, 60)
|
|||
|
|
return f"{h}h {m}m{suffix}"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _fmt_channel(raw: Optional[str]) -> str:
|
|||
|
|
"""Compact channel label for the picker."""
|
|||
|
|
if not raw:
|
|||
|
|
return "—"
|
|||
|
|
# Reverse the Messaging alias for readability.
|
|||
|
|
if raw == "SCRT2 - EmbeddedMessaging":
|
|||
|
|
return "Messaging"
|
|||
|
|
# Custom channel labels arrive HTML-encoded from DC (e.g. "E & O").
|
|||
|
|
return html.unescape(raw)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _fmt_start(iso: Optional[str]) -> str:
|
|||
|
|
if not iso:
|
|||
|
|
return "—"
|
|||
|
|
try:
|
|||
|
|
dt = datetime.fromisoformat(iso.replace("Z", "+00:00"))
|
|||
|
|
except ValueError:
|
|||
|
|
return iso
|
|||
|
|
return dt.strftime("%Y-%m-%d %H:%M:%SZ")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def render_picker(
|
|||
|
|
*,
|
|||
|
|
rows: list[dict],
|
|||
|
|
agent_by_sid: dict[str, str],
|
|||
|
|
org: str,
|
|||
|
|
tr: TimeRange,
|
|||
|
|
filters: dict[str, Optional[str]],
|
|||
|
|
composed_sql: str,
|
|||
|
|
) -> str:
|
|||
|
|
"""Markdown picker. Returned as a string so the caller prints once
|
|||
|
|
(single subprocess write on stdout)."""
|
|||
|
|
filter_bits = [f"{tr.expr}"]
|
|||
|
|
for k, v in filters.items():
|
|||
|
|
if v:
|
|||
|
|
filter_bits.append(f"{k}={v}")
|
|||
|
|
header_filters = " · ".join(filter_bits)
|
|||
|
|
|
|||
|
|
if not rows:
|
|||
|
|
# Echo the composed SQL so the user can widen the query.
|
|||
|
|
return (
|
|||
|
|
f"No sessions matched in **{org}** ({header_filters}).\n\n"
|
|||
|
|
f"Range searched (UTC): `{tr.iso(tr.start_utc)}` → `{tr.iso(tr.end_utc)}`\n"
|
|||
|
|
f"Local tz: `{tr.tz_name}`\n\n"
|
|||
|
|
f"Composed SQL:\n```sql\n{composed_sql}\n```\n"
|
|||
|
|
f"Try widening: larger `--since`, drop `--channel`/`--outcome`, or broaden `--grep`."
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
lines = [
|
|||
|
|
f"Found **{len(rows)}** session{'s' if len(rows) != 1 else ''} in "
|
|||
|
|
f"**{org}** ({header_filters}):",
|
|||
|
|
"",
|
|||
|
|
"| # | UUID | Start (UTC) | Agent | Channel | Duration | Outcome |",
|
|||
|
|
"|---|------|-------------|-------|---------|----------|---------|",
|
|||
|
|
]
|
|||
|
|
for i, row in enumerate(rows, start=1):
|
|||
|
|
uuid = row.get("ssot__Id__c") or "—"
|
|||
|
|
agent = agent_by_sid.get(uuid, "—")
|
|||
|
|
lines.append(
|
|||
|
|
f"| {i} | `{uuid}` | {_fmt_start(row.get('ssot__StartTimestamp__c'))} "
|
|||
|
|
f"| {agent} | {_fmt_channel(row.get('ssot__AiAgentChannelType__c'))} "
|
|||
|
|
f"| {_fmt_duration(row.get('ssot__StartTimestamp__c'), row.get('ssot__EndTimestamp__c'))} "
|
|||
|
|
f"| {row.get('ssot__AiAgentSessionEndType__c') or '—'} |"
|
|||
|
|
)
|
|||
|
|
lines.append("")
|
|||
|
|
lines.append(f"Pick a number (1–{len(rows)}) to trace, or paste a UUID.")
|
|||
|
|
return "\n".join(lines)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---- CLI -------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
def main() -> int:
|
|||
|
|
ap = argparse.ArgumentParser(
|
|||
|
|
description="Find candidate Agentforce sessions via Data Cloud. "
|
|||
|
|
"Prints a numbered picker; no artifacts written.",
|
|||
|
|
)
|
|||
|
|
ap.add_argument("--org", required=True, help="sf CLI org alias (the one you configured via `sf org login`)")
|
|||
|
|
ap.add_argument("--since", help='time range: "last 2 hours" | "today" | "yesterday" | '
|
|||
|
|
'"YYYY-MM-DD" | "YYYY-MM-DD to YYYY-MM-DD" | ISO datetime. '
|
|||
|
|
'Default: last 24 hours.')
|
|||
|
|
ap.add_argument("--agent", help="Agent API name (exact match)")
|
|||
|
|
ap.add_argument("--channel", help="Builder | Messaging | Voice (or full STDM string)")
|
|||
|
|
ap.add_argument("--outcome", help="USER_ENDED | ESCALATED | TRANSFERRED | TIMEOUT | NOT_SET")
|
|||
|
|
ap.add_argument(
|
|||
|
|
"--grep",
|
|||
|
|
help="substring in conversation text (user + agent); "
|
|||
|
|
"case-insensitive by default — pass --grep-case-sensitive for exact match",
|
|||
|
|
)
|
|||
|
|
ap.add_argument(
|
|||
|
|
"--grep-case-sensitive",
|
|||
|
|
action="store_true",
|
|||
|
|
default=False,
|
|||
|
|
help="opt-in: make --grep case-sensitive (rare; default is case-insensitive)",
|
|||
|
|
)
|
|||
|
|
ap.add_argument("--tz", help="IANA timezone override for calendar-day resolution")
|
|||
|
|
ap.add_argument("--limit", type=int, default=20, help="max sessions (default 20)")
|
|||
|
|
# Runtime-agnostic path overrides; default to ~/.vibe/...
|
|||
|
|
# discover_sessions doesn't read DATA_ROOT directly, but accepts the flags
|
|||
|
|
# for contract consistency with the other 4 d360 entry scripts.
|
|||
|
|
from _shared.cli_override import add_cli_flags, apply_overrides
|
|||
|
|
add_cli_flags(ap)
|
|||
|
|
args = ap.parse_args()
|
|||
|
|
apply_overrides(args, caller_globals=globals())
|
|||
|
|
|
|||
|
|
if args.limit < 1:
|
|||
|
|
raise SystemExit("discover_sessions: --limit must be >= 1")
|
|||
|
|
|
|||
|
|
tr = parse_time_expr(args.since, args.tz or "")
|
|||
|
|
sql = compose_sql(
|
|||
|
|
tr=tr,
|
|||
|
|
agent=args.agent,
|
|||
|
|
channel=args.channel,
|
|||
|
|
outcome=args.outcome,
|
|||
|
|
grep=args.grep,
|
|||
|
|
grep_case_sensitive=args.grep_case_sensitive,
|
|||
|
|
limit=args.limit,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
instance_url, token = resolve_org(args.org)
|
|||
|
|
try:
|
|||
|
|
rows = post(sql, instance_url, token, "discover_sessions")
|
|||
|
|
except DCQueryError as e:
|
|||
|
|
print(f"discover_sessions: DC query failed.\n{e}", file=sys.stderr)
|
|||
|
|
return 1
|
|||
|
|
|
|||
|
|
agent_by_sid: dict[str, str] = {}
|
|||
|
|
if rows:
|
|||
|
|
sids = [r["ssot__Id__c"] for r in rows if r.get("ssot__Id__c")]
|
|||
|
|
agent_by_sid = fetch_agent_names(sids, instance_url=instance_url, token=token)
|
|||
|
|
|
|||
|
|
filters = {
|
|||
|
|
"agent": args.agent,
|
|||
|
|
"channel": args.channel,
|
|||
|
|
"outcome": args.outcome,
|
|||
|
|
"grep": args.grep,
|
|||
|
|
# Only surface the case-sensitive note when --grep is in play AND
|
|||
|
|
# the user opted into exact match — the default (insensitive) is
|
|||
|
|
# the boring path and shouldn't clutter the picker header.
|
|||
|
|
"grep_case_sensitive": "yes" if (args.grep and args.grep_case_sensitive) else None,
|
|||
|
|
}
|
|||
|
|
print(render_picker(
|
|||
|
|
rows=rows,
|
|||
|
|
agent_by_sid=agent_by_sid,
|
|||
|
|
org=args.org,
|
|||
|
|
tr=tr,
|
|||
|
|
filters=filters,
|
|||
|
|
composed_sql=sql,
|
|||
|
|
))
|
|||
|
|
return 0 if rows else 2
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
raise SystemExit(main())
|