afv-library/skills/agentforce-d360-analyze/scripts/tests/fixtures/synthetic_session.py

425 lines
15 KiB
Python

"""Synthetic session fixture for d360 integration tests.
Provides:
- ``IDS`` a flat namespace of all UUIDs/keys used so tests
can assert against specific values.
- ``make_rows()`` returns ``{dmo_name: [row, ...]}`` for the 24 DMOs
`assemble_dc` reads. Self-consistent: every
Step.GenerationId references a Generation row, etc.
- ``make_manifest`` returns the dc._session_manifest.json shape.
- ``write_to_disk`` materializes the whole fixture under a session dir
so tests that drive the public ``assemble_dc.assemble``
can use ``tmp_path`` + DATA_ROOT redirect without
duplicating layout knowledge.
Shape rules:
- Timestamps anchored on 2026-04-22T10:00:00Z; offsets in seconds.
- All ssot__Id__c values are 18-char-stable strings (not real Salesforce
IDs) — assemble_dc only key-equality-checks them.
- HTML escaping applied to AttributeText__c so the trace_id fallback
path exercises real code.
- parameters__c on gateway_responses uses the "-escaped JSON shape
real DC emits.
"""
from __future__ import annotations
import html
import json
from copy import deepcopy
from pathlib import Path
from typing import Any, Dict, List
# -----------------------------------------------------------------------------
# IDs — exposed flat for cross-test assertions
# -----------------------------------------------------------------------------
class _IDs:
SID = "019dface-0000-7000-8000-000000000001"
"""Agent session UUID. Valid UUIDv7 so window decode works."""
ORG_ID_15 = "00D000000000ABC"
ORG_ID_18 = "00D000000000ABCDEF"
AGENT_API = "DemoAgent"
AGENT_VERSION = "v5"
BOT_ID = "0Xx000000000DemoBot"
# Interactions
IXN_START = "ixn-start-001"
IXN_TURN = "ixn-turn-002"
IXN_END = "ixn-end-003"
# Steps within the TURN
STEP_TOPIC = "step-topic-001"
STEP_ACTION = "step-action-002"
STEP_GUARDRAIL = "step-guardrail-003"
# Gateway audit chain — one declared, one timestamp-window-only
GW_REQ_DECLARED = "gw-req-declared-001"
GW_REQ_WINDOW = "gw-req-window-002"
GW_RESP_DECLARED = "gw-resp-declared-001"
# Generation chain (declared)
GEN_DECLARED = "gen-declared-001"
# Participants
PART_AGENT = "part-agent-001"
PART_USER = "part-user-002"
# Trace + window
TRACE_ID = "8b820e85aaaaaaaabbbbbbbbcccccccc"
SESSION_START = "2026-04-22T10:00:00.000Z"
SESSION_END = "2026-04-22T10:01:30.000Z"
TURN_START = "2026-04-22T10:00:05.000Z"
TURN_END = "2026-04-22T10:01:00.000Z"
GW_DECLARED_TS = "2026-04-22T10:00:30.000Z" # inside step-action window
GW_WINDOW_TS = "2026-04-22T10:00:45.000Z" # inside the TURN window only
IDS = _IDs
# -----------------------------------------------------------------------------
# Per-DMO row builders
# -----------------------------------------------------------------------------
def _sessions() -> List[dict]:
return [{
"ssot__Id__c": IDS.SID,
"ssot__InternalOrganizationId__c": IDS.ORG_ID_18,
"ssot__StartTimestamp__c": IDS.SESSION_START,
"ssot__EndTimestamp__c": IDS.SESSION_END,
"ssot__AiAgentChannelType__c": "SCRT2 - EmbeddedMessaging",
"ssot__AiAgentSessionEndType__c": "USER_ENDED",
"ssot__RelatedMessagingSessionId__c": "0MwTESTMSG12345AAA",
}]
def _interactions() -> List[dict]:
"""Three interactions: SESSION_START, TURN, SESSION_END.
`AttributeText__c` carries the real internalTraceId in HTML-escaped
JSON form on the TURN row (matches live DC shape).
"""
# No spaces in separators — production logger emits compact JSON, and
# the trace_id regex (`"internalTraceId":"([a-f0-9]+)"`) requires no
# whitespace around the colon.
raw_attr_text = json.dumps(
{"internalTraceId": IDS.TRACE_ID, "label": "react-loop"},
separators=(",", ":"),
)
return [
{
"ssot__Id__c": IDS.IXN_START,
"ssot__AiAgentSessionId__c": IDS.SID,
"ssot__AiAgentInteractionType__c": "SESSION_START",
"ssot__StartTimestamp__c": IDS.SESSION_START,
"ssot__EndTimestamp__c": IDS.SESSION_START,
"ssot__TelemetryTraceId__c": "",
"ssot__AttributeText__c": "",
},
{
"ssot__Id__c": IDS.IXN_TURN,
"ssot__AiAgentSessionId__c": IDS.SID,
"ssot__AiAgentInteractionType__c": "TURN",
"ssot__StartTimestamp__c": IDS.TURN_START,
"ssot__EndTimestamp__c": IDS.TURN_END,
"ssot__TelemetryTraceId__c": "",
# HTML-escaped JSON triggers the AttributeText fallback path.
"ssot__AttributeText__c": html.escape(raw_attr_text, quote=True),
},
{
"ssot__Id__c": IDS.IXN_END,
"ssot__AiAgentSessionId__c": IDS.SID,
"ssot__AiAgentInteractionType__c": "SESSION_END",
"ssot__StartTimestamp__c": IDS.SESSION_END,
"ssot__EndTimestamp__c": IDS.SESSION_END,
"ssot__TelemetryTraceId__c": "NOT_SET",
"ssot__AttributeText__c": "",
},
]
def _steps() -> List[dict]:
"""Three steps inside the TURN. Step times nest inside the turn window
so the timestamp-window pass binds gateway_requests by containment."""
return [
{
"ssot__Id__c": IDS.STEP_TOPIC,
"ssot__AiAgentInteractionId__c": IDS.IXN_TURN,
"ssot__AiAgentInteractionStepType__c": "TOPIC_STEP",
"ssot__StartTimestamp__c": "2026-04-22T10:00:10.000Z",
"ssot__EndTimestamp__c": "2026-04-22T10:00:20.000Z",
"ssot__GenerationId__c": "NOT_SET",
"ssot__Name__c": "TopicSelection",
},
{
"ssot__Id__c": IDS.STEP_ACTION,
"ssot__AiAgentInteractionId__c": IDS.IXN_TURN,
"ssot__AiAgentInteractionStepType__c": "ACTION_STEP",
"ssot__StartTimestamp__c": "2026-04-22T10:00:25.000Z",
"ssot__EndTimestamp__c": "2026-04-22T10:00:40.000Z",
"ssot__GenerationId__c": IDS.GEN_DECLARED,
"ssot__Name__c": "PrimaryAction",
},
{
"ssot__Id__c": IDS.STEP_GUARDRAIL,
"ssot__AiAgentInteractionId__c": IDS.IXN_TURN,
"ssot__AiAgentInteractionStepType__c": "TRUST_GUARDRAILS_STEP",
"ssot__StartTimestamp__c": "2026-04-22T10:00:55.000Z",
"ssot__EndTimestamp__c": "2026-04-22T10:01:00.000Z",
"ssot__GenerationId__c": "NOT_SET",
"ssot__Name__c": "FinalGuard",
},
]
def _messages() -> List[dict]:
return [
{
"ssot__Id__c": "msg-001",
"ssot__AiAgentInteractionId__c": IDS.IXN_TURN,
"ssot__AiAgentInteractionMessageType__c": "USER",
"ssot__ContentText__c": "How do I refund my order?",
"ssot__StartTimestamp__c": IDS.TURN_START,
},
{
"ssot__Id__c": "msg-002",
"ssot__AiAgentInteractionId__c": IDS.IXN_TURN,
"ssot__AiAgentInteractionMessageType__c": "AGENT",
"ssot__ContentText__c": "I'll help you with the refund process.",
"ssot__StartTimestamp__c": IDS.TURN_END,
},
]
def _participants() -> List[dict]:
return [
{
"ssot__Id__c": IDS.PART_AGENT,
"ssot__AiAgentSessionId__c": IDS.SID,
"ssot__AiAgentSessionParticipantRole__c": "AGENT",
"ssot__AiAgentApiName__c": IDS.AGENT_API,
"ssot__AiAgentVersionApiName__c": IDS.AGENT_VERSION,
},
{
"ssot__Id__c": IDS.PART_USER,
"ssot__AiAgentSessionId__c": IDS.SID,
"ssot__AiAgentSessionParticipantRole__c": "USER",
"ssot__AiAgentApiName__c": "",
"ssot__AiAgentVersionApiName__c": "",
},
]
def _gateway_requests() -> List[dict]:
"""Two gateway requests:
- GW_REQ_DECLARED: chained from Step → Generation → Response → Request.
Timestamp also falls within step-action window so either binding
method can resolve it. Declared chain wins.
- GW_REQ_WINDOW: NOT in the declared chain. Timestamp falls inside
the TURN window → exercise timestamp-window fallback.
"""
return [
{
"gatewayRequestId__c": IDS.GW_REQ_DECLARED,
"sf__Id": "sf-" + IDS.GW_REQ_DECLARED,
"sessionId__c": f'"{IDS.SID}"',
"timestamp__c": IDS.GW_DECLARED_TS,
"model__c": "gpt-4o",
"provider__c": "openai",
"promptTemplateDevName__c": "AiCopilot__ReactInitialPrompt",
"feature__c": "plannerservice",
"promptTokens__c": 1200,
"completionTokens__c": 50,
"totalTokens__c": 1250,
"prompt__c": "Declared-bound prompt body.",
},
{
"gatewayRequestId__c": IDS.GW_REQ_WINDOW,
"sf__Id": "sf-" + IDS.GW_REQ_WINDOW,
"sessionId__c": f'"{IDS.SID}"',
"timestamp__c": IDS.GW_WINDOW_TS,
"model__c": "gpt-4o-mini",
"provider__c": "openai",
"promptTemplateDevName__c": "AiCopilot__PromptTemplateGenerationsInvocable",
"feature__c": "plannerservice",
"promptTokens__c": 200,
"completionTokens__c": 20,
"totalTokens__c": 220,
"prompt__c": "Window-bound prompt body.",
},
]
def _gateway_responses() -> List[dict]:
raw = '{"finish_reason":"\\"stop\\""}'
escaped = raw.replace('"', """)
return [{
"generationResponseId__c": IDS.GW_RESP_DECLARED,
"generationRequestId__c": IDS.GW_REQ_DECLARED,
"parameters__c": escaped,
"timestamp__c": IDS.GW_DECLARED_TS,
}]
def _generations() -> List[dict]:
return [{
"generationId__c": IDS.GEN_DECLARED,
"generationResponseId__c": IDS.GW_RESP_DECLARED,
"responseText__c": "I'll help you with the refund process.",
"timestamp__c": IDS.GW_DECLARED_TS,
}]
def _content_quality() -> List[dict]:
return [{
"generationId__c": IDS.GEN_DECLARED,
"trustScore__c": 0.92,
"qualityType__c": "NORMAL",
}]
def _gateway_records() -> List[dict]:
return [{
"id__c": "gw-rec-001",
"parent__c": IDS.GW_REQ_DECLARED,
"sessionId__c": IDS.SID,
"recordType__c": "RAG",
}]
def _gateway_request_tags() -> List[dict]:
return [
{"id__c": "tag-001", "parent__c": IDS.GW_REQ_DECLARED,
"tag__c": "promptTemplateDevName", "tagValue__c": "AiCopilot__ReactInitialPrompt"},
{"id__c": "tag-002", "parent__c": IDS.GW_REQ_DECLARED,
"tag__c": "feature", "tagValue__c": "plannerservice"},
]
# -----------------------------------------------------------------------------
# Public: rows + manifest
# -----------------------------------------------------------------------------
_ALL_TEMPLATES = (
"sessions", "interactions", "messages", "moments", "participants",
"tag_associations", "gateway_requests",
"steps", "moment_interactions",
"telemetry_spans", "generations", "app_generation",
"gateway_request_tags", "gateway_responses", "gateway_records",
"gateway_request_metadata", "gateway_request_llm",
"feedback",
"content_quality", "content_category", "feedback_details",
"tag_definitions", "tag_definition_associations", "tags",
)
def make_rows() -> Dict[str, List[dict]]:
"""Return ``{dmo_name: [row, ...]}`` for all 24 DMOs.
Empty lists for DMOs the synthetic session doesn't exercise — the
code under test reads them by ``.get(name, [])`` and tolerates
missing entries.
"""
populated = {
"sessions": _sessions(),
"interactions": _interactions(),
"steps": _steps(),
"messages": _messages(),
"participants": _participants(),
"gateway_requests": _gateway_requests(),
"gateway_responses": _gateway_responses(),
"generations": _generations(),
"content_quality": _content_quality(),
"gateway_records": _gateway_records(),
"gateway_request_tags": _gateway_request_tags(),
}
rows: Dict[str, List[dict]] = {name: [] for name in _ALL_TEMPLATES}
rows.update(populated)
return deepcopy(rows)
def make_manifest() -> dict:
"""Manifest shape matching what `fetch_dc.py` writes."""
rows = make_rows()
queries = [
{
"name": name,
"row_count": len(rows[name]),
"elapsed_ms": 50,
"where_clause": f"ssot__AiAgentSessionId__c = '{IDS.SID}'",
}
for name in _ALL_TEMPLATES
]
return {
"_doc": "Per-query summary of this DC fetch run.",
"session_id": IDS.SID,
"org_alias": "my-org",
"instance_url": "https://example.my.salesforce.com",
"org_id_15": IDS.ORG_ID_15,
"agent_api_name": IDS.AGENT_API,
"agent_version": IDS.AGENT_VERSION,
"session_shape": "ok",
"started_at_utc": "2026-04-22T10:01:30.000Z",
"finished_at_utc": "2026-04-22T10:01:32.000Z",
"elapsed_ms": 2000,
"queries": queries,
"harvested_ids": {
"interactions": [IDS.IXN_START, IDS.IXN_TURN, IDS.IXN_END],
},
}
# -----------------------------------------------------------------------------
# Disk materialization
# -----------------------------------------------------------------------------
def session_dir_for(data_root: Path) -> Path:
"""Compute the canonical session dir under a tmp DATA_ROOT."""
return (
data_root
/ IDS.ORG_ID_15
/ f"{IDS.AGENT_API}__{IDS.AGENT_VERSION}"
/ IDS.SID
)
def write_to_disk(data_root: Path) -> Path:
"""Materialize the synthetic session under ``<data_root>/<org>/<agent>__<ver>/<sid>/``.
Writes:
- dc._session_manifest.json
- dc.<name>.json for every populated DMO
- The per-org breadcrumb at <org>/_sessions/<sid>.link
Returns the session dir absolute path.
"""
sdir = session_dir_for(data_root)
sdir.mkdir(parents=True, exist_ok=True)
# DC manifest + rows
(sdir / "dc._session_manifest.json").write_text(
json.dumps(make_manifest(), indent=2) + "\n"
)
rows = make_rows()
for name, rs in rows.items():
if rs:
(sdir / f"dc.{name}.json").write_text(json.dumps(rs, indent=2) + "\n")
# Breadcrumb so _find_session_dir's primary path resolves
link_dir = data_root / IDS.ORG_ID_15 / "_sessions"
link_dir.mkdir(parents=True, exist_ok=True)
(link_dir / f"{IDS.SID}.link").write_text(
f"../{IDS.AGENT_API}__{IDS.AGENT_VERSION}/{IDS.SID}\n"
)
return sdir