From 30bea075959a2635fc37e9ef397ced8f41e7dd3c Mon Sep 17 00:00:00 2001 From: Jaime Bueza Date: Wed, 20 May 2026 20:58:29 -0700 Subject: [PATCH 01/42] Add .env.example file for customer support configuration with placeholders for API keys and tokens (#5895) Co-authored-by: Claude Opus 4.7 (1M context) --- Python/customer-support/.env.example | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 Python/customer-support/.env.example diff --git a/Python/customer-support/.env.example b/Python/customer-support/.env.example new file mode 100644 index 0000000..1b00789 --- /dev/null +++ b/Python/customer-support/.env.example @@ -0,0 +1,15 @@ +# Ingestion (from Project Settings → Engine Token) +ENGINE_TOKEN="[Your Engine Token]" + +# Retrieval / search (PAT + Engine ID) +ENGINE_ID="[Your Engine ID]" +ENGINE_PAT="[Your Engine PAT]" + +# Optional: override API host (must match the stack your ENGINE_TOKEN targets) +# RAILTOWN_API_URL="https://cndr.railtown.ai/api" + +# Railtracks LLM (default: OpenAI — set one provider the Railtracks OpenAI/Anthropic integrations expect) +OPENAI_API_KEY="[Your OpenAI API key]" + +# Optional: Anthropic instead of OpenAI (edit agent.py to use rt.llm.AnthropicLLM) +# ANTHROPIC_API_KEY="[Your Anthropic API key]" From 7e2f46afadbed98570e568e45b7cd02ef612399a Mon Sep 17 00:00:00 2001 From: Jaime Bueza Date: Wed, 20 May 2026 20:59:59 -0700 Subject: [PATCH 02/42] Add engine schema for customer support ticket sample (#5895) Co-authored-by: Claude Opus 4.7 (1M context) --- Python/customer-support/engine-schema.json | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 Python/customer-support/engine-schema.json diff --git a/Python/customer-support/engine-schema.json b/Python/customer-support/engine-schema.json new file mode 100644 index 0000000..accdbb6 --- /dev/null +++ b/Python/customer-support/engine-schema.json @@ -0,0 +1,11 @@ +{ + "id": "ticket-sample-001", + "subject": "Unable to download invoice — billing portal error", + "body": "I'm logged in as finance@acmecorp.example but the invoice PDF returns 500. Our API key for the sandbox is sk-demo-INVALID-KEY-12345. Please call me at +1-555-0199.", + "status": "open", + "tags": ["billing", "portal"], + "createdAt": "2026-05-01T14:30:00Z", + "customerEmail": "finance@acmecorp.example", + "customerPhone": "+1-555-0199", + "productArea": "billing" +} From 55818f5695143f96b8c27963fd22504ea3ecb62e Mon Sep 17 00:00:00 2001 From: Jaime Bueza Date: Wed, 20 May 2026 21:00:05 -0700 Subject: [PATCH 03/42] Add pyproject.toml for customer support triage project setup with dependencies and scripts (#5895) Co-authored-by: Claude Opus 4.7 (1M context) --- Python/customer-support/pyproject.toml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 Python/customer-support/pyproject.toml diff --git a/Python/customer-support/pyproject.toml b/Python/customer-support/pyproject.toml new file mode 100644 index 0000000..d77eab0 --- /dev/null +++ b/Python/customer-support/pyproject.toml @@ -0,0 +1,25 @@ +[build-system] +requires = ["setuptools>=61"] +build-backend = "setuptools.build_meta" + +[project] +name = "customer-support-triage" +version = "0.1.0" +description = "Customer support triage demo: Railengine ingest + search + Railtracks agent" +readme = "README.md" +requires-python = ">=3.10" +dependencies = [ + "rail-engine>=0.2.1", + "rail-engine-ingest>=0.2.1", + "railtracks>=1.3.0", + "pydantic>=2.0", + "streamlit>=1.57.0", + "watchdog>=6.0.0", +] + +[project.scripts] +support-ingest = "customer_support.ingest:main_sync" +support-triage = "customer_support.triage:main_sync" + +[tool.setuptools.packages.find] +where = ["src"] From ef79388d7895e7d26b65c366ef559a3df99f7361 Mon Sep 17 00:00:00 2001 From: Jaime Bueza Date: Wed, 20 May 2026 21:00:12 -0700 Subject: [PATCH 04/42] Add resolved and open ticket fixtures for customer support, including authentication and billing issues (#5895) Co-authored-by: Claude Opus 4.7 (1M context) --- .../fixtures/tickets/resolved_auth_001.json | 11 +++++++++++ .../fixtures/tickets/resolved_billing_001.json | 11 +++++++++++ .../fixtures/tickets/resolved_billing_002.json | 11 +++++++++++ .../customer-support/fixtures/tickets/ticket_001.json | 11 +++++++++++ .../customer-support/fixtures/tickets/ticket_002.json | 11 +++++++++++ 5 files changed, 55 insertions(+) create mode 100644 Python/customer-support/fixtures/tickets/resolved_auth_001.json create mode 100644 Python/customer-support/fixtures/tickets/resolved_billing_001.json create mode 100644 Python/customer-support/fixtures/tickets/resolved_billing_002.json create mode 100644 Python/customer-support/fixtures/tickets/ticket_001.json create mode 100644 Python/customer-support/fixtures/tickets/ticket_002.json diff --git a/Python/customer-support/fixtures/tickets/resolved_auth_001.json b/Python/customer-support/fixtures/tickets/resolved_auth_001.json new file mode 100644 index 0000000..39eb707 --- /dev/null +++ b/Python/customer-support/fixtures/tickets/resolved_auth_001.json @@ -0,0 +1,11 @@ +{ + "id": "ticket-resolved-auth-001", + "subject": "Resolved: MFA enrollment loop for new hires", + "body": "Caused by stale device id in mobile app. Support had users reinstall app and re-enroll TOTP. No tenant-wide outage.", + "status": "resolved", + "tags": ["auth", "mfa", "mobile"], + "createdAt": "2026-03-18T14:00:00Z", + "customerEmail": "redacted@example.com", + "customerPhone": "", + "productArea": "authentication" +} diff --git a/Python/customer-support/fixtures/tickets/resolved_billing_001.json b/Python/customer-support/fixtures/tickets/resolved_billing_001.json new file mode 100644 index 0000000..0d6a7e0 --- /dev/null +++ b/Python/customer-support/fixtures/tickets/resolved_billing_001.json @@ -0,0 +1,11 @@ +{ + "id": "ticket-resolved-billing-001", + "subject": "Resolved: VAT ID missing on invoice export", + "body": "Customer could not download VAT-compliant PDFs. Fix was to add VAT ID in Billing → Company profile and clear CDN cache.", + "status": "resolved", + "tags": ["billing", "vat", "invoice"], + "createdAt": "2026-04-10T09:00:00Z", + "customerEmail": "redacted@example.com", + "customerPhone": "", + "productArea": "billing" +} diff --git a/Python/customer-support/fixtures/tickets/resolved_billing_002.json b/Python/customer-support/fixtures/tickets/resolved_billing_002.json new file mode 100644 index 0000000..23422b4 --- /dev/null +++ b/Python/customer-support/fixtures/tickets/resolved_billing_002.json @@ -0,0 +1,11 @@ +{ + "id": "ticket-resolved-billing-002", + "subject": "Resolved: Portal 500 on invoice — stale session cookie", + "body": "User saw 500 on invoice PDF until cookies cleared. Documented workaround and shipped fix in portal 2.3.1.", + "status": "resolved", + "tags": ["billing", "portal", "cache"], + "createdAt": "2026-04-22T11:30:00Z", + "customerEmail": "redacted@example.com", + "customerPhone": "", + "productArea": "billing" +} diff --git a/Python/customer-support/fixtures/tickets/ticket_001.json b/Python/customer-support/fixtures/tickets/ticket_001.json new file mode 100644 index 0000000..54be87f --- /dev/null +++ b/Python/customer-support/fixtures/tickets/ticket_001.json @@ -0,0 +1,11 @@ +{ + "id": "ticket-open-001", + "subject": "Invoice PDF returns 500 in billing portal", + "body": "I'm logged in as finance@acmecorp.example but every invoice download fails with HTTP 500. Our sandbox integration key is sk-demo-INVALID-KEY-12345 — pasted from the dashboard. Please call me at +1-555-0199 if you need to debug live.", + "status": "open", + "tags": ["billing", "portal", "urgent"], + "createdAt": "2026-05-20T15:00:00Z", + "customerEmail": "finance@acmecorp.example", + "customerPhone": "+1-555-0199", + "productArea": "billing" +} diff --git a/Python/customer-support/fixtures/tickets/ticket_002.json b/Python/customer-support/fixtures/tickets/ticket_002.json new file mode 100644 index 0000000..bef65d6 --- /dev/null +++ b/Python/customer-support/fixtures/tickets/ticket_002.json @@ -0,0 +1,11 @@ +{ + "id": "ticket-open-002", + "subject": "SSO fails after password rotation — error AADSTS50076", + "body": "All users in our eu-west tenant see AADSTS50076 when signing in via SAML. Admin contact: it-lead@contoso.example. Mobile: +44 20 7946 0958. Happened right after we rotated IdP certs.", + "status": "open", + "tags": ["auth", "sso", "azure-ad"], + "createdAt": "2026-05-20T16:20:00Z", + "customerEmail": "it-lead@contoso.example", + "customerPhone": "+44 20 7946 0958", + "productArea": "authentication" +} From 32fd5fc245d0ecbb783a18a3f4bc1530a7f83aae Mon Sep 17 00:00:00 2001 From: Jaime Bueza Date: Wed, 20 May 2026 21:00:18 -0700 Subject: [PATCH 05/42] Add __init__.py for customer support module with versioning information (#5895) Co-authored-by: Claude Opus 4.7 (1M context) --- Python/customer-support/src/customer_support/__init__.py | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 Python/customer-support/src/customer_support/__init__.py diff --git a/Python/customer-support/src/customer_support/__init__.py b/Python/customer-support/src/customer_support/__init__.py new file mode 100644 index 0000000..40ca1d5 --- /dev/null +++ b/Python/customer-support/src/customer_support/__init__.py @@ -0,0 +1,5 @@ +"""Customer support triage example: Railengine + Railtracks.""" + +__all__ = ["__version__"] + +__version__ = "0.1.0" From acee0daf8c16cf5f8782cae7bade4fbdf510416e Mon Sep 17 00:00:00 2001 From: Jaime Bueza Date: Wed, 20 May 2026 21:00:27 -0700 Subject: [PATCH 06/42] Add _env.py to load environment variables for CLI entrypoints in customer support module (#5895) Co-authored-by: Claude Opus 4.7 (1M context) --- .../src/customer_support/_env.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 Python/customer-support/src/customer_support/_env.py diff --git a/Python/customer-support/src/customer_support/_env.py b/Python/customer-support/src/customer_support/_env.py new file mode 100644 index 0000000..76da67a --- /dev/null +++ b/Python/customer-support/src/customer_support/_env.py @@ -0,0 +1,19 @@ +"""Load `.env` for CLI entrypoints before reading os.environ.""" + +from __future__ import annotations + +from pathlib import Path + +from dotenv import load_dotenv + + +def ensure_dotenv_loaded() -> None: + """ + Load env vars used by Railengine ingest/retrieval and LLMs. + + - First loads `Python/customer-support/.env` next to this package (works even when cwd is elsewhere). + - Then `load_dotenv()` so a `.env` in the current working directory can override values. + """ + project_root = Path(__file__).resolve().parents[2] + load_dotenv(project_root / ".env") + load_dotenv() From b75612710b5297e1b7eb0480b5c21f7105bdc592 Mon Sep 17 00:00:00 2001 From: Jaime Bueza Date: Wed, 20 May 2026 21:01:24 -0700 Subject: [PATCH 07/42] Add README.md for customer support triage agent, detailing setup, usage, and security considerations (#5895) Co-authored-by: Claude Opus 4.7 (1M context) --- Python/customer-support/README.md | 105 ++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 Python/customer-support/README.md diff --git a/Python/customer-support/README.md b/Python/customer-support/README.md new file mode 100644 index 0000000..3ca0562 --- /dev/null +++ b/Python/customer-support/README.md @@ -0,0 +1,105 @@ +# Customer Support Triage Agent + +Enterprise-style demo: **ingest** support tickets into [Railengine](https://railengine.ai/), **search** similar resolved cases (index + vector store), and **triage** a new ticket with a [Railtracks](https://github.com/RailtownAI/railtracks) agent that returns structured JSON (priority, summary, draft reply). + +## Prerequisites + +- Python **3.10+** +- [uv](https://docs.astral.sh/uv/) (recommended) or pip + venv +- A Railengine project with a new engine whose schema matches the sample document in [`engine-schema.json`](engine-schema.json) (copy-paste into the engine schema editor when creating the engine). +- Enable **index** and **vector (VectorStore1)** on fields you want to search (e.g. `subject`, `body`, `tags`) in the Railengine console so `search_index` and `search_vector_store` return useful hits. +- Optional: configure **PII masking** on the engine so emails, phone numbers, and `sk-…` style secrets in `body` / `customerEmail` are masked after ingest — compare raw fixtures vs. stored documents to show compliance-friendly data. + +## Setup + +1. Copy environment template and add credentials from the Railengine dashboard: + + ```bash + cd Python/customer-support + cp .env.example .env + ``` + + - `ENGINE_TOKEN` — for ingestion (`rail-engine-ingest`) + - `ENGINE_ID` + `ENGINE_PAT` — for retrieval/search (`rail-engine`) + - `OPENAI_API_KEY` — for the Railtracks OpenAI model in `agent.py` + + **Note:** The ingest CLI does not import Railtracks (which wires `load_dotenv` on import), so this project calls `customer_support._env.ensure_dotenv_loaded()` at startup. That loads `Python/customer-support/.env` beside the package, then the current working directory, so `ENGINE_TOKEN` is available when you run `support-ingest`. + +2. Install dependencies: + + ```bash + uv sync + ``` + + Or: `pip install -e .` + +## Seed tickets (fixtures) + +Ingest historical **resolved** tickets first, then open tickets (order only matters for your mental model — the agent searches by similarity): + +```bash +uv run support-ingest fixtures/tickets/resolved_*.json fixtures/tickets/ticket_001.json fixtures/tickets/ticket_002.json +``` + +Or: + +```bash +uv run python -m customer_support.ingest fixtures/tickets/*.json +``` + +## Run triage on one ticket + +```bash +uv run python -m customer_support.triage --ticket fixtures/tickets/ticket_001.json +``` + +The agent calls Railengine-backed tools (`search_similar_tickets`, `list_recent_tickets`, `get_ticket_by_id`), then emits a **`TriageAssessment`** (priority, category, internal summary, draft reply, similar ticket ids). + +## Streamlit UI + +Local demo browser: + +```bash +cd Python/customer-support +uv sync +uv run streamlit run src/customer_support/streamlit_app.py +``` + +- Metrics show whether **`ENGINE_TOKEN`**, **`ENGINE_PAT`**, **`ENGINE_ID`**, and **`OPENAI_API_KEY`** are set (values are never shown). +- Sidebar: pick a **`fixtures/tickets/*.json`** file and click **Load into editor**. +- **Ingest to Railengine** and **Run triage** use the same helpers as the CLIs (`ingest_ticket`, `triage_ticket`). + +**Do not expose this Streamlit server** on the public internet without authentication; it inherits the usual risks of forwarding API credentials through a prototype web app. + +## Optional: webhook receiver + +To exercise **activate** (webhook publishing) locally: + +```bash +uv run python -m customer_support.webhook_receiver --port 8765 +``` + +Configure the engine to POST to `http://:8765/webhook`. Each event is parsed and printed as JSON (ticket id, subject, status). + +## Security note + +Treat this sample like other examples in this repo: **do not** expose `ENGINE_TOKEN`, `ENGINE_PAT`, or `OPENAI_API_KEY` on a public host without authentication and rate limits. Treat the Streamlit UI the same way as the CLI: keep it **local** or behind a VPN and access control. + +## Layout + +| Path | Purpose | +|------|---------| +| `engine-schema.json` | Sample document for engine schema setup | +| `fixtures/tickets/` | JSON tickets with **synthetic** PII and a fake API key for masking demos | +| `src/customer_support/models.py` | `SupportTicket`, `TriageAssessment` | +| `src/customer_support/tools.py` | Railtracks `@function_node` tools using `rail-engine` | +| `src/customer_support/agent.py` | Railtracks agent + structured output | +| `src/customer_support/triage.py` | CLI entrypoint | +| `src/customer_support/triage_runner.py` | Shared async `triage_ticket()` for CLI and UI | +| `src/customer_support/ingest.py` | CLI + `ingest_ticket` helper | +| `src/customer_support/streamlit_app.py` | Streamlit demo UI (ingest + triage) | +| `src/customer_support/webhook_receiver.py` | Optional stdlib HTTP webhook | + +## Talk track (1 sentence) + +> “Tickets land in Railengine with masking and indexing; the Railtracks agent uses your engine as the **system of record** for similar cases and returns auditable structured triage.” From b12507c4cabf88371cc39ac87d631b4ef8025a2f Mon Sep 17 00:00:00 2001 From: Jaime Bueza Date: Wed, 20 May 2026 21:01:28 -0700 Subject: [PATCH 08/42] Update .gitignore to exclude railtracks and uv.lock files (#5895) Co-authored-by: Claude Opus 4.7 (1M context) --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 185ad45..12ba88a 100644 --- a/.gitignore +++ b/.gitignore @@ -105,3 +105,6 @@ dmypy.json /CSharp/Examples/RailenginePoweredStatusPage/appsettings.Development.json + +**/.railtracks +**/uv.lock From 04550d8657b995f7f15921803362143dcf8c2c01 Mon Sep 17 00:00:00 2001 From: Jaime Bueza Date: Wed, 20 May 2026 21:01:44 -0700 Subject: [PATCH 09/42] Add shared async triage runner for customer support, implementing ticket triage logic with Railengine and Railtracks (#5895) Co-authored-by: Claude Opus 4.7 (1M context) --- .../src/customer_support/triage_runner.py | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 Python/customer-support/src/customer_support/triage_runner.py diff --git a/Python/customer-support/src/customer_support/triage_runner.py b/Python/customer-support/src/customer_support/triage_runner.py new file mode 100644 index 0000000..3c7bf21 --- /dev/null +++ b/Python/customer-support/src/customer_support/triage_runner.py @@ -0,0 +1,43 @@ +"""Shared async triage runner for CLI and Streamlit.""" + +from __future__ import annotations + +import json + +import railtracks as rt +from railtracks.built_nodes.concrete.response import StructuredResponse +from railtown.engine import Railengine + +from customer_support.agent import build_triage_agent +from customer_support.models import SupportTicket, TriageAssessment +from customer_support.runtime import set_railengine_client + + +async def triage_ticket(ticket: SupportTicket) -> TriageAssessment: + """Run the Railtracks triage Flow against Railengine-backed tools.""" + async with Railengine() as engine: + set_railengine_client(engine) + agent_cls = build_triage_agent() + flow = rt.Flow(name="CustomerSupportTriage", entry_point=agent_cls) + + prompt = f"""Triage this ticket: + +```json +{json.dumps(ticket.model_dump(), indent=2, ensure_ascii=False)} +``` + +1) Call search_similar_tickets with a query built from subject, body, and productArea. +2) Optionally call list_recent_tickets(status="resolved") for extra historical context. +3) Return a TriageAssessment with priority, category, internal_summary, draft_reply_to_customer, similar_ticket_ids, and reasoning. +""" + result = await flow.ainvoke(prompt) + if isinstance(result, StructuredResponse): + structured = result.structured + if isinstance(structured, TriageAssessment): + return structured + return TriageAssessment.model_validate(structured) + if isinstance(result, TriageAssessment): + return result + if hasattr(result, "model_dump"): + return TriageAssessment.model_validate(result.model_dump()) + raise TypeError(f"Unexpected agent result type: {type(result)}") From e61ee5102d877d288a22d44adf29e9acc996d517 Mon Sep 17 00:00:00 2001 From: Jaime Bueza Date: Wed, 20 May 2026 21:01:53 -0700 Subject: [PATCH 10/42] Add customer support triage functionality with new modules for agent definition, ticket ingestion, and webhook handling. Implemented models for support tickets and triage assessments, along with tools for searching and processing tickets in Railengine. Enhanced Streamlit UI for ticket management and triage output display. (#5895) Co-authored-by: Claude Opus 4.7 (1M context) --- .../src/customer_support/agent.py | 47 +++++ .../src/customer_support/ingest.py | 57 ++++++ .../src/customer_support/models.py | 47 +++++ .../src/customer_support/rail_payload.py | 76 ++++++++ .../src/customer_support/runtime.py | 23 +++ .../src/customer_support/streamlit_app.py | 160 ++++++++++++++++ .../src/customer_support/tools.py | 181 ++++++++++++++++++ .../src/customer_support/triage.py | 54 ++++++ .../src/customer_support/webhook_receiver.py | 85 ++++++++ 9 files changed, 730 insertions(+) create mode 100644 Python/customer-support/src/customer_support/agent.py create mode 100644 Python/customer-support/src/customer_support/ingest.py create mode 100644 Python/customer-support/src/customer_support/models.py create mode 100644 Python/customer-support/src/customer_support/rail_payload.py create mode 100644 Python/customer-support/src/customer_support/runtime.py create mode 100644 Python/customer-support/src/customer_support/streamlit_app.py create mode 100644 Python/customer-support/src/customer_support/tools.py create mode 100644 Python/customer-support/src/customer_support/triage.py create mode 100644 Python/customer-support/src/customer_support/webhook_receiver.py diff --git a/Python/customer-support/src/customer_support/agent.py b/Python/customer-support/src/customer_support/agent.py new file mode 100644 index 0000000..e6d1a20 --- /dev/null +++ b/Python/customer-support/src/customer_support/agent.py @@ -0,0 +1,47 @@ +"""Railtracks triage agent definition.""" + +from __future__ import annotations + +import os + +import railtracks as rt + +from customer_support.models import TriageAssessment +from customer_support.tools import ( + get_ticket_by_id, + list_recent_tickets, + search_similar_tickets, +) + + +def build_triage_agent(): + """Create agent with structured JSON output and Railengine-backed tools.""" + api_key = os.environ.get("OPENAI_API_KEY", "").strip() + if not api_key: + raise RuntimeError( + "OPENAI_API_KEY is not set. Add it to .env or export it before running triage." + ) + + llm = rt.llm.OpenAILLM("gpt-4o") + + system = """You are an enterprise support triage lead. You receive a single support ticket (JSON). + +Rules: +- Use the search tools to find similar RESOLVED tickets before writing a reply. +- Never copy API keys, tokens, or passwords into the draft_reply — refer to them only as "the credential mentioned in the ticket" if needed. +- Prioritize customer impact and whether the issue blocks billing, security, or wide outages. +- Populate similar_ticket_ids with ids you actually saw from tool results (may be empty if none). +- Keep internal_summary factual and concise. +""" + + return rt.agent_node( + "Support Triage Agent", + tool_nodes=( + search_similar_tickets, + list_recent_tickets, + get_ticket_by_id, + ), + llm=llm, + system_message=system, + output_schema=TriageAssessment, + ) diff --git a/Python/customer-support/src/customer_support/ingest.py b/Python/customer-support/src/customer_support/ingest.py new file mode 100644 index 0000000..2211b12 --- /dev/null +++ b/Python/customer-support/src/customer_support/ingest.py @@ -0,0 +1,57 @@ +"""Load fixtures into Railengine via rail-engine-ingest.""" + +from __future__ import annotations + +import argparse +import asyncio +import json +from pathlib import Path + +from railtown.engine.ingest import RailengineIngest + +from customer_support._env import ensure_dotenv_loaded +from customer_support.models import SupportTicket + + +async def ingest_ticket_with_client(client: RailengineIngest, ticket: SupportTicket) -> int: + """Upsert using an existing ingest client.""" + resp = await client.upsert(ticket) + return resp.status_code + + +async def ingest_ticket(ticket: SupportTicket) -> int: + """Upsert a single ticket (opens its own ingest client/session).""" + async with RailengineIngest(model=SupportTicket) as client: + return await ingest_ticket_with_client(client, ticket) + + +async def ingest_paths(paths: list[Path]) -> None: + async with RailengineIngest(model=SupportTicket) as client: + for p in paths: + raw = json.loads(p.read_text(encoding="utf-8")) + ticket = SupportTicket.model_validate(raw) + status = await ingest_ticket_with_client(client, ticket) + print(f"Ingested {p.name} -> HTTP {status}") + + +def main_sync() -> None: + ensure_dotenv_loaded() + parser = argparse.ArgumentParser( + description="Upsert support ticket JSON fixtures into Railengine.", + ) + parser.add_argument( + "files", + nargs="+", + help="Fixture paths (e.g. fixtures/tickets/*.json)", + ) + args = parser.parse_args() + paths = [Path(f) for f in args.files] + asyncio.run(ingest_paths(paths)) + + +def main() -> None: + main_sync() + + +if __name__ == "__main__": + main() diff --git a/Python/customer-support/src/customer_support/models.py b/Python/customer-support/src/customer_support/models.py new file mode 100644 index 0000000..0f36af4 --- /dev/null +++ b/Python/customer-support/src/customer_support/models.py @@ -0,0 +1,47 @@ +"""Pydantic models for support tickets and agent output.""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, Field + + +class SupportTicket(BaseModel): + """Document shape stored in Railengine (matches engine-schema sample).""" + + id: str = Field(..., description="Stable ticket id / business key") + subject: str + body: str + status: Literal["open", "pending", "resolved"] + tags: list[str] = Field(default_factory=list) + createdAt: str + customerEmail: str = "" + customerPhone: str = "" + productArea: str = "" + + +class TriageAssessment(BaseModel): + """Structured triage output from the Railtracks agent.""" + + priority: Literal["p1", "p2", "p3", "p4"] = Field( + ..., + description="p1 critical outage / legal; p2 major customer pain; p3 normal; p4 low", + ) + category: str = Field(..., description="Short category label, e.g. billing, auth") + internal_summary: str = Field( + ..., + description="Internal 2–4 sentence summary for support leads (no raw secrets).", + ) + draft_reply_to_customer: str = Field( + ..., + description="Empathetic draft reply. Do not repeat suspected secrets or API keys.", + ) + similar_ticket_ids: list[str] = Field( + default_factory=list, + description="Ids of similar historical tickets found via search tools", + ) + reasoning: str = Field( + ..., + description="Brief justification for priority and category", + ) diff --git a/Python/customer-support/src/customer_support/rail_payload.py b/Python/customer-support/src/customer_support/rail_payload.py new file mode 100644 index 0000000..e847105 --- /dev/null +++ b/Python/customer-support/src/customer_support/rail_payload.py @@ -0,0 +1,76 @@ +"""Normalize Railengine storage/search rows into ticket-shaped dicts. + +The HTTP API may expose user JSON under Body, body, content, or Content (string or object), +matching patterns used elsewhere in railengine-examples TypeScript helpers. +""" + +from __future__ import annotations + +import json +from typing import Any + +from pydantic import BaseModel + +from customer_support.models import SupportTicket + + +def _parse_json_if_string(raw: Any) -> Any: + if raw is None: + return None + if isinstance(raw, str): + try: + return json.loads(raw) + except json.JSONDecodeError: + return None + return raw + + +def row_as_dict(row: Any) -> dict[str, Any] | None: + """Coerce SDK hits (dict or Pydantic, e.g. vector rows) into a plain dict.""" + if row is None: + return None + if isinstance(row, dict): + return row + if isinstance(row, BaseModel): + return row.model_dump(by_alias=True) + return None + + +def body_from_row(row: Any) -> dict[str, Any] | None: + """Extract the user document object from a storage or search row.""" + d = row_as_dict(row) + if not d: + return None + raw = d.get("Body") or d.get("body") or d.get("content") or d.get("Content") + parsed = _parse_json_if_string(raw) + if isinstance(parsed, dict): + return parsed + # Row may already be the flat document (unlikely but tolerate) + if "id" in d and "subject" in d: + return dict(d) + return None + + +def ticket_from_row(row: Any) -> SupportTicket | None: + """Parse a storage/search hit into SupportTicket if possible.""" + if isinstance(row, SupportTicket): + return row + body = body_from_row(row) + if not body: + return None + try: + return SupportTicket.model_validate(body) + except Exception: + return None + + +def row_preview(row: Any, max_len: int = 400) -> str: + """Short string for logging tool results.""" + t = ticket_from_row(row) + if t: + return f"{t.id} | {t.status} | {t.subject[:80]}" + body = body_from_row(row) + if body: + s = json.dumps(body, ensure_ascii=False)[:max_len] + return s + ("…" if len(s) == max_len else "") + return str(row)[:max_len] diff --git a/Python/customer-support/src/customer_support/runtime.py b/Python/customer-support/src/customer_support/runtime.py new file mode 100644 index 0000000..e85613e --- /dev/null +++ b/Python/customer-support/src/customer_support/runtime.py @@ -0,0 +1,23 @@ +"""Process-wide Railengine client for tool nodes (set from CLI before agent run).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from railtown.engine import Railengine + +_client: Railengine | None = None + + +def set_railengine_client(client: Railengine) -> None: + global _client + _client = client + + +def get_railengine_client() -> Railengine: + if _client is None: + raise RuntimeError( + "Railengine client is not configured. Call set_railengine_client() before invoking tools." + ) + return _client diff --git a/Python/customer-support/src/customer_support/streamlit_app.py b/Python/customer-support/src/customer_support/streamlit_app.py new file mode 100644 index 0000000..79d8a1b --- /dev/null +++ b/Python/customer-support/src/customer_support/streamlit_app.py @@ -0,0 +1,160 @@ +"""Streamlit UI: edit ticket JSON, ingest, and triage.""" + +from __future__ import annotations + +import asyncio +import json +import os +import traceback +from pathlib import Path + +import streamlit as st +from pydantic import ValidationError + +from customer_support._env import ensure_dotenv_loaded +from customer_support.ingest import ingest_ticket +from customer_support.models import SupportTicket, TriageAssessment +from customer_support.triage_runner import triage_ticket + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +FIXTURES_DIR = PROJECT_ROOT / "fixtures" / "tickets" + + +def _env_ok() -> dict[str, bool]: + return { + "ENGINE_TOKEN": bool(os.environ.get("ENGINE_TOKEN", "").strip()), + "ENGINE_PAT": bool(os.environ.get("ENGINE_PAT", "").strip()), + "ENGINE_ID": bool(os.environ.get("ENGINE_ID", "").strip()), + "OPENAI_API_KEY": bool(os.environ.get("OPENAI_API_KEY", "").strip()), + } + + +def _fixture_paths() -> list[Path]: + if not FIXTURES_DIR.is_dir(): + return [] + return sorted(FIXTURES_DIR.glob("*.json")) + + +def main() -> None: + ensure_dotenv_loaded() + + st.set_page_config(page_title="Support Triage", layout="wide") + st.title("Customer support triage") + st.caption("Railengine ingest + retrieval + Railtracks structured triage.") + + cols = st.columns(4) + status = _env_ok() + for i, (key, ok) in enumerate(status.items()): + cols[i].metric(label=key, value="set" if ok else "missing") + + if "ticket_editor" not in st.session_state: + st.session_state.ticket_editor = "" + + sidebar = st.sidebar + sidebar.subheader("Fixtures") + fixtures = _fixture_paths() + fixture_names = [p.name for p in fixtures] + + if fixture_names: + pick = sidebar.selectbox("Pick fixture file", fixture_names) + if sidebar.button("Load into editor", type="secondary"): + text = (FIXTURES_DIR / pick).read_text(encoding="utf-8") + st.session_state.ticket_editor = text + st.rerun() + + sidebar.caption( + f"`fixtures/` path: `{FIXTURES_DIR}`" + if fixtures + else f"No `{FIXTURES_DIR}` — paste JSON manually." + ) + + txt = st.text_area( + "Ticket JSON (`SupportTicket` schema)", + height=340, + key="ticket_editor", + ) + + c1, c2 = st.columns(2) + + ingest_ok = status["ENGINE_TOKEN"] + + ticket: SupportTicket | None = None + if txt.strip(): + try: + ticket = SupportTicket.model_validate(json.loads(txt)) + st.success("JSON matches `SupportTicket` schema.") + except json.JSONDecodeError as e: + st.warning(f"Invalid JSON: {e}") + except ValidationError as e: + st.error(f"Does not match `SupportTicket`: {e}") + + with c1: + do_ingest = st.button( + "Ingest to Railengine", + disabled=not (ticket and ingest_ok), + help="Requires ENGINE_TOKEN.", + ) + + triage_ready = ( + ticket + and status["ENGINE_PAT"] + and status["ENGINE_ID"] + and status["OPENAI_API_KEY"] + ) + + with c2: + do_triage = st.button( + "Run triage", + disabled=not triage_ready, + help="Requires ENGINE_PAT, ENGINE_ID, and OPENAI_API_KEY.", + ) + + if do_ingest and ticket: + try: + with st.spinner("Ingesting…"): + http_status = asyncio.run(ingest_ticket(ticket)) + st.success(f"Ingest succeeded (HTTP {http_status}).") + except Exception: + st.error(traceback.format_exc()) + + if do_triage and ticket: + try: + with st.spinner("Running Railtracks triage (tools + structured output)…"): + assessment = asyncio.run(triage_ticket(ticket)) + if not isinstance(assessment, TriageAssessment): + assessment = TriageAssessment.model_validate(assessment) + + st.subheader("Triage output") + m1, m2 = st.columns(2) + m1.metric("Priority", assessment.priority.upper()) + m2.metric("Category", assessment.category) + + st.markdown("**Internal summary**") + st.write(assessment.internal_summary) + + st.markdown("**Draft reply**") + st.write(assessment.draft_reply_to_customer) + + st.markdown("**Reasoning**") + st.write(assessment.reasoning) + + sid = assessment.similar_ticket_ids + if sid: + st.markdown("**Similar ticket ids**") + st.code(", ".join(sid)) + + raw = assessment.model_dump() + st.download_button( + label="Download TriageAssessment JSON", + file_name=f"triage-{ticket.id}.json", + mime="application/json", + data=json.dumps(raw, indent=2, ensure_ascii=False).encode("utf-8"), + ) + with st.expander("Full JSON"): + st.json(raw) + except Exception: + st.error(traceback.format_exc()) + + +if __name__ == "__main__": + main() diff --git a/Python/customer-support/src/customer_support/tools.py b/Python/customer-support/src/customer_support/tools.py new file mode 100644 index 0000000..4c94314 --- /dev/null +++ b/Python/customer-support/src/customer_support/tools.py @@ -0,0 +1,181 @@ +"""Railtracks tool nodes backed by rail-engine retrieval.""" + +from __future__ import annotations + +import json +from typing import Any, Literal + +import railtracks as rt + +from customer_support.models import SupportTicket +from customer_support.rail_payload import ticket_from_row +from customer_support.runtime import get_railengine_client + + +def _ticket_to_brief(t: SupportTicket) -> dict[str, Any]: + return { + "id": t.id, + "subject": t.subject, + "status": t.status, + "tags": t.tags, + "productArea": t.productArea, + "createdAt": t.createdAt, + "body_excerpt": (t.body[:280] + "…") if len(t.body) > 280 else t.body, + } + + +async def _collect_index_hits(query: str, limit: int) -> list[SupportTicket]: + """Index search returns ``IndexingSearchResult`` (rail-engine ``await``, not ``async for``).""" + client = get_railengine_client() + out: list[SupportTicket] = [] + result = await client.search_index(query={"search": query}, raw=True) + for row in result.items: + t = ticket_from_row(row) + if t: + out.append(t) + if len(out) >= limit: + break + return out + + +async def _collect_vector_hits(query: str, limit: int) -> list[SupportTicket]: + """Vector search returns ``list`` (rail-engine ``await``, not ``async for``).""" + client = get_railengine_client() + items = await client.search_vector_store( + vector_store="VectorStore1", + query=query, + top=limit, + ) + out: list[SupportTicket] = [] + for row in items: + t = ticket_from_row(row) + if t: + out.append(t) + if len(out) >= limit: + break + return out + + +async def _iter_raw_storage_pages(*, page_size: int, max_docs: int): + """Walk storage pages until max_docs yielded or pages exhausted.""" + client = get_railengine_client() + scanned = 0 + pn = 1 + while scanned < max_docs: + page = await client.list_storage_documents( + page_number=pn, + page_size=page_size, + raw=True, + ) + for row in page.items: + yield row + scanned += 1 + if scanned >= max_docs: + return + if page.total_pages < 1 or pn >= page.total_pages: + return + pn += 1 + + +@rt.function_node +async def search_similar_tickets( + query: str, + mode: Literal["vector", "index", "both"] = "both", + limit: int = 6, +) -> str: + """ + Search historical tickets in Railengine using keyword index and/or semantic vector search. + + Args: + query: Natural language or keywords describing the issue (subject + symptoms). + mode: Use embedding search (vector), keyword index (index), or run both and merge. + limit: Max tickets to return per search mode (capped for latency). + """ + limit = max(1, min(int(limit), 25)) + merged: dict[str, SupportTicket] = {} + + if mode in ("index", "both"): + for t in await _collect_index_hits(query, limit): + merged[t.id] = t + if mode in ("vector", "both"): + for t in await _collect_vector_hits(query, limit): + merged[t.id] = t + + briefs = [_ticket_to_brief(t) for t in merged.values()] + return json.dumps(briefs, ensure_ascii=False, indent=2) + + +@rt.function_node +async def list_recent_tickets(status: str = "resolved", limit: int = 15) -> str: + """ + List recent tickets from hot storage. Filter client-side by status. + + Args: + status: open | pending | resolved — only tickets matching this status are returned. + limit: Max rows to return (JSONPath query when supported; otherwise capped scan). + """ + client = get_railengine_client() + want = status.strip().lower() + if want not in ("open", "pending", "resolved"): + return json.dumps( + {"error": "status must be open, pending, or resolved"}, + indent=2, + ) + + cap = max(1, min(int(limit), 50)) + collected: list[SupportTicket] = [] + + page = await client.query_storage_by_jsonpath(json_path_query=f"$.status:{want}") + for row in page.items: + t = ticket_from_row(row) + if t: + collected.append(t) + if len(collected) >= cap: + break + + if len(collected) < cap: + max_scan = 400 + seen_ids = {t.id for t in collected} + async for row in _iter_raw_storage_pages(page_size=100, max_docs=max_scan): + t = ticket_from_row(row) + if not t or t.status != want: + continue + if t.id in seen_ids: + continue + seen_ids.add(t.id) + collected.append(t) + if len(collected) >= cap: + break + + return json.dumps( + [_ticket_to_brief(t) for t in collected], + ensure_ascii=False, + indent=2, + ) + + +@rt.function_node +async def get_ticket_by_id(ticket_id: str) -> str: + """ + Fetch a ticket by its business id (the `id` field inside the ingested JSON document). + + Uses JSONPath storage query when available; falls back to a capped scan of recent pages. + """ + tid = ticket_id.strip() + if not tid: + return json.dumps({"error": "ticket_id required"}, indent=2) + + client = get_railengine_client() + + page = await client.query_storage_by_jsonpath(json_path_query=f"$.id:{tid}") + for row in page.items: + t = ticket_from_row(row) + if t and t.id == tid: + return json.dumps(_ticket_to_brief(t), ensure_ascii=False, indent=2) + + async for row in _iter_raw_storage_pages(page_size=100, max_docs=500): + t = ticket_from_row(row) + if t and t.id == tid: + return json.dumps(_ticket_to_brief(t), ensure_ascii=False, indent=2) + + return json.dumps({"error": f"ticket not found: {tid}"}, indent=2) diff --git a/Python/customer-support/src/customer_support/triage.py b/Python/customer-support/src/customer_support/triage.py new file mode 100644 index 0000000..756f19d --- /dev/null +++ b/Python/customer-support/src/customer_support/triage.py @@ -0,0 +1,54 @@ +"""CLI: run Railtracks triage for one ticket fixture against live Railengine data.""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import sys +from pathlib import Path + +from pydantic import ValidationError + +from customer_support._env import ensure_dotenv_loaded +from customer_support.models import SupportTicket +from customer_support.triage_runner import triage_ticket + + +def main_sync() -> None: + ensure_dotenv_loaded() + parser = argparse.ArgumentParser( + description="Run the Customer Support Triage agent for one ticket JSON file.", + ) + parser.add_argument( + "--ticket", + type=Path, + required=True, + help="Path to ticket JSON (e.g. fixtures/tickets/ticket_001.json)", + ) + args = parser.parse_args() + + if not args.ticket.is_file(): + print(f"File not found: {args.ticket}", file=sys.stderr) + sys.exit(1) + + try: + raw = json.loads(args.ticket.read_text(encoding="utf-8")) + ticket = SupportTicket.model_validate(raw) + except json.JSONDecodeError as e: + print(f"Invalid ticket JSON syntax: {e}", file=sys.stderr) + sys.exit(1) + except ValidationError as e: + print(f"Invalid ticket JSON: {e}", file=sys.stderr) + sys.exit(1) + + assessment = asyncio.run(triage_ticket(ticket)) + print(json.dumps(assessment.model_dump(), indent=2, ensure_ascii=False)) + + +def main() -> None: + main_sync() + + +if __name__ == "__main__": + main() diff --git a/Python/customer-support/src/customer_support/webhook_receiver.py b/Python/customer-support/src/customer_support/webhook_receiver.py new file mode 100644 index 0000000..0490b78 --- /dev/null +++ b/Python/customer-support/src/customer_support/webhook_receiver.py @@ -0,0 +1,85 @@ +""" +Optional local webhook receiver for Railengine publishing (activation demo). + +Run from this directory after installing the package:: + + uv run python -m customer_support.webhook_receiver --port 8765 + +Point your engine webhook URL at http://localhost:8765/webhook (use ngrok or similar for a public URL). + +The handler parses `WebhookPublishingPayload` bodies using the same `SupportTicket` model as ingest. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from http.server import BaseHTTPRequestHandler, HTTPServer +from typing import Any + +from railtown.engine.ingest import WebhookHandler + +from customer_support._env import ensure_dotenv_loaded +from customer_support.models import SupportTicket + + +class Handler(BaseHTTPRequestHandler): + model_handler = WebhookHandler(SupportTicket) + + def log_message(self, format: str, *args: Any) -> None: + print(f"[webhook] {self.address_string()} - {format % args}") + + def do_POST(self) -> None: # noqa: N802 + if self.path.rstrip("/") != "/webhook": + self.send_error(404, "Not Found") + return + length = int(self.headers.get("Content-Length", "0")) + raw = self.rfile.read(length).decode("utf-8", errors="replace") + try: + payload: Any = json.loads(raw) if raw.strip() else {} + except json.JSONDecodeError: + self.send_error(400, "Invalid JSON") + return + + try: + events = self.model_handler.parse(payload) + except Exception as e: + self.send_error(400, f"Parse error: {e}") + return + + for ev in events: + print( + json.dumps( + { + "eventId": ev.EventId, + "ticketId": ev.body.id, + "subject": ev.body.subject, + "status": ev.body.status, + }, + indent=2, + ) + ) + + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(b'{"ok":true}') + + +def main() -> None: + ensure_dotenv_loaded() + parser = argparse.ArgumentParser(description="Receive Railengine webhook POSTs locally.") + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=8765) + args = parser.parse_args() + httpd = HTTPServer((args.host, args.port), Handler) + print(f"Listening on http://{args.host}:{args.port}/webhook (POST)", file=sys.stderr) + try: + httpd.serve_forever() + except KeyboardInterrupt: + print("\nShutting down.", file=sys.stderr) + + +if __name__ == "__main__": + main() From cfe805da4254891e808a755cfb3b9d0f63d3536f Mon Sep 17 00:00:00 2001 From: Jaime Bueza Date: Wed, 20 May 2026 21:02:00 -0700 Subject: [PATCH 11/42] Update README files to include details about the Customer Support Triage Agent in Python examples and enhance the description of the Railengine Python SDK. Added links for better navigation and clarity on usage. (#5895) Co-authored-by: Claude Opus 4.7 (1M context) --- Python/README.md | 6 +++++- README.md | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/Python/README.md b/Python/README.md index 2bc0b66..46f3016 100644 --- a/Python/README.md +++ b/Python/README.md @@ -1 +1,5 @@ -These samples use the Railengine Python SDK +These samples use the Railengine Python SDK and [Railtracks](https://github.com/RailtownAI/railtracks). + +## Examples + +- **[Customer Support Triage Agent](customer-support)** — Ingest tickets with `rail-engine-ingest`, search with `rail-engine`, triage with a Railtracks agent (structured JSON output). diff --git a/README.md b/README.md index 071a5d0..52fb5c8 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ [TypeScript](https://github.com/RailtownAI/railengine-examples/tree/main/TypeScript) -[Python](https://github.com/RailtownAI/railengine-examples/tree/main/Python) +[Python](https://github.com/RailtownAI/railengine-examples/tree/main/Python) — includes [Customer Support Triage Agent](https://github.com/RailtownAI/railengine-examples/tree/main/Python/customer-support) (Railengine + Railtracks) ### AIoT @@ -31,4 +31,6 @@ [Food Diary](https://github.com/RailtownAI/railengine-examples/tree/main/TypeScript/nextjs-food-diary) +[Customer Support Triage (Python)](https://github.com/RailtownAI/railengine-examples/tree/main/Python/customer-support) + [Thermometer](https://github.com/RailtownAI/railengine-examples/tree/main/MicroPython) From df38eea47b86eeae0f8166fbba0560610ac98d9a Mon Sep 17 00:00:00 2001 From: Jaime Bueza Date: Wed, 20 May 2026 21:13:11 -0700 Subject: [PATCH 12/42] Update README.md to enhance the description of the Customer Support Triage Agent, including a link to the detailed setup in the customer-support directory. Improved clarity on usage and functionality. (#5895) Co-authored-by: Claude Opus 4.7 (1M context) --- Python/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Python/README.md b/Python/README.md index 46f3016..4f1ee4a 100644 --- a/Python/README.md +++ b/Python/README.md @@ -2,4 +2,4 @@ These samples use the Railengine Python SDK and [Railtracks](https://github.com/ ## Examples -- **[Customer Support Triage Agent](customer-support)** — Ingest tickets with `rail-engine-ingest`, search with `rail-engine`, triage with a Railtracks agent (structured JSON output). +- **[Customer Support Triage Agent](customer-support/README.md)** — Ingest with `rail-engine-ingest`, search with `rail-engine`, triage with a Railtracks agent (dashboard + ingest UI; setup details are in `customer-support/README.md`). From 02a9dac8f2ae40eef7c7dbd656d5d8257e123760 Mon Sep 17 00:00:00 2001 From: Jaime Bueza Date: Wed, 20 May 2026 21:13:20 -0700 Subject: [PATCH 13/42] Refactor customer support triage project structure and enhance functionality. Updated `pyproject.toml` to include `python-dotenv` dependency and modified script paths for CLI commands. Introduced new modules for Streamlit UI, ticket ingestion, and triage services, along with a webhook receiver for Railengine. Improved README for clarity on setup and usage. Added models for support tickets and triage assessments, and implemented repository layer for ticket management. (#5895) Co-authored-by: Claude Opus 4.7 (1M context) --- Python/customer-support/README.md | 124 +++++--------- Python/customer-support/pyproject.toml | 21 ++- .../src/{customer_support => }/__init__.py | 0 .../customer-support/src/agents/__init__.py | 5 + .../src/{customer_support => agents}/tools.py | 92 ++-------- .../agent.py => agents/triage_agent.py} | 4 +- .../customer-support/src/config/__init__.py | 5 + Python/customer-support/src/config/env.py | 17 ++ .../src/controllers/__init__.py | 1 + .../src/controllers/cli/__init__.py | 0 .../src/controllers/cli/ingest.py | 33 ++++ .../cli}/triage.py | 8 +- .../webhook.py} | 12 +- .../src/customer_support/_env.py | 19 --- .../src/customer_support/ingest.py | 57 ------- .../src/customer_support/runtime.py | 23 --- .../src/customer_support/streamlit_app.py | 160 ------------------ .../customer-support/src/models/__init__.py | 7 + Python/customer-support/src/models/ticket.py | 21 +++ .../src/models/ticket_page.py | 18 ++ .../models.py => models/triage.py} | 16 +- Python/customer-support/src/pages/__init__.py | 1 + .../customer-support/src/pages/dashboard.py | 55 ++++++ .../customer-support/src/pages/ingest_page.py | 131 ++++++++++++++ .../src/repositories/__init__.py | 5 + .../mappers.py} | 9 +- .../src/repositories/ticket_repository.py | 124 ++++++++++++++ .../customer-support/src/services/__init__.py | 7 + .../src/services/ingest_service.py | 23 +++ .../src/services/ticket_list_service.py | 16 ++ .../triage_service.py} | 14 +- Python/customer-support/src/streamlit_app.py | 23 +++ .../customer-support/src/streamlit_common.py | 34 ++++ 33 files changed, 628 insertions(+), 457 deletions(-) rename Python/customer-support/src/{customer_support => }/__init__.py (100%) create mode 100644 Python/customer-support/src/agents/__init__.py rename Python/customer-support/src/{customer_support => agents}/tools.py (54%) rename Python/customer-support/src/{customer_support/agent.py => agents/triage_agent.py} (97%) create mode 100644 Python/customer-support/src/config/__init__.py create mode 100644 Python/customer-support/src/config/env.py create mode 100644 Python/customer-support/src/controllers/__init__.py create mode 100644 Python/customer-support/src/controllers/cli/__init__.py create mode 100644 Python/customer-support/src/controllers/cli/ingest.py rename Python/customer-support/src/{customer_support => controllers/cli}/triage.py (83%) rename Python/customer-support/src/{customer_support/webhook_receiver.py => controllers/webhook.py} (87%) delete mode 100644 Python/customer-support/src/customer_support/_env.py delete mode 100644 Python/customer-support/src/customer_support/ingest.py delete mode 100644 Python/customer-support/src/customer_support/runtime.py delete mode 100644 Python/customer-support/src/customer_support/streamlit_app.py create mode 100644 Python/customer-support/src/models/__init__.py create mode 100644 Python/customer-support/src/models/ticket.py create mode 100644 Python/customer-support/src/models/ticket_page.py rename Python/customer-support/src/{customer_support/models.py => models/triage.py} (68%) create mode 100644 Python/customer-support/src/pages/__init__.py create mode 100644 Python/customer-support/src/pages/dashboard.py create mode 100644 Python/customer-support/src/pages/ingest_page.py create mode 100644 Python/customer-support/src/repositories/__init__.py rename Python/customer-support/src/{customer_support/rail_payload.py => repositories/mappers.py} (83%) create mode 100644 Python/customer-support/src/repositories/ticket_repository.py create mode 100644 Python/customer-support/src/services/__init__.py create mode 100644 Python/customer-support/src/services/ingest_service.py create mode 100644 Python/customer-support/src/services/ticket_list_service.py rename Python/customer-support/src/{customer_support/triage_runner.py => services/triage_service.py} (75%) create mode 100644 Python/customer-support/src/streamlit_app.py create mode 100644 Python/customer-support/src/streamlit_common.py diff --git a/Python/customer-support/README.md b/Python/customer-support/README.md index 3ca0562..0e58692 100644 --- a/Python/customer-support/README.md +++ b/Python/customer-support/README.md @@ -1,105 +1,75 @@ -# Customer Support Triage Agent +# Customer Support Triage -Enterprise-style demo: **ingest** support tickets into [Railengine](https://railengine.ai/), **search** similar resolved cases (index + vector store), and **triage** a new ticket with a [Railtracks](https://github.com/RailtownAI/railtracks) agent that returns structured JSON (priority, summary, draft reply). +Demo stack: ingest support tickets into [Railengine](https://railengine.ai/), search resolved history (keyword index + `VectorStore1`), and triage a case with [Railtracks](https://github.com/RailtownAI/railtracks) structured output (priority, category, summaries, draft reply). -## Prerequisites +## Before you start -- Python **3.10+** -- [uv](https://docs.astral.sh/uv/) (recommended) or pip + venv -- A Railengine project with a new engine whose schema matches the sample document in [`engine-schema.json`](engine-schema.json) (copy-paste into the engine schema editor when creating the engine). -- Enable **index** and **vector (VectorStore1)** on fields you want to search (e.g. `subject`, `body`, `tags`) in the Railengine console so `search_index` and `search_vector_store` return useful hits. -- Optional: configure **PII masking** on the engine so emails, phone numbers, and `sk-…` style secrets in `body` / `customerEmail` are masked after ingest — compare raw fixtures vs. stored documents to show compliance-friendly data. +- A [Railengine](https://railengine.ai/) account plus a **new engine** configured with the sample schema in [`engine-schema.json`](engine-schema.json). +- Paste that schema into your engine schema editor so documents match **`SupportTicket`**. +- Enable **Index** plus **VectorStore1** on fields such as `subject`, `body`, and `tags` in the Railengine console so search tools get useful hits beyond raw storage scans. -## Setup +## Quick start -1. Copy environment template and add credentials from the Railengine dashboard: +From `Python/customer-support/`: - ```bash - cd Python/customer-support - cp .env.example .env - ``` - - - `ENGINE_TOKEN` — for ingestion (`rail-engine-ingest`) - - `ENGINE_ID` + `ENGINE_PAT` — for retrieval/search (`rail-engine`) - - `OPENAI_API_KEY` — for the Railtracks OpenAI model in `agent.py` - - **Note:** The ingest CLI does not import Railtracks (which wires `load_dotenv` on import), so this project calls `customer_support._env.ensure_dotenv_loaded()` at startup. That loads `Python/customer-support/.env` beside the package, then the current working directory, so `ENGINE_TOKEN` is available when you run `support-ingest`. - -2. Install dependencies: - - ```bash - uv sync - ``` +```bash +cd Python/customer-support +cp .env.example .env # fill ENGINE_TOKEN, ENGINE_ID, ENGINE_PAT, OPENAI_API_KEY +uv sync +uv run streamlit run src/streamlit_app.py +``` - Or: `pip install -e .` +## First demo flow -## Seed tickets (fixtures) +1. Open **Ingest**, load **`fixtures/tickets/ticket_001.json`**, and click **Ingest to Railengine**. +2. (Optional history) Seed resolved examples: + `uv run support-ingest fixtures/tickets/resolved_*.json` +3. On **Ingest**, click **Run triage** and review priority + draft reply. +4. Switch to **Dashboard**, click **Load / refresh**, and confirm the ticket row appears. -Ingest historical **resolved** tickets first, then open tickets (order only matters for your mental model — the agent searches by similarity): +## CLI (optional) ```bash -uv run support-ingest fixtures/tickets/resolved_*.json fixtures/tickets/ticket_001.json fixtures/tickets/ticket_002.json +uv run support-ingest fixtures/tickets/*.json +uv run support-triage --ticket fixtures/tickets/ticket_001.json ``` -Or: +## Environment variables -```bash -uv run python -m customer_support.ingest fixtures/tickets/*.json -``` +| Variable | Used for | Required when | +|----------|-----------|---------------| +| `ENGINE_TOKEN` | Ingest SDK | **Ingest** page / `support-ingest` | +| `ENGINE_PAT` | Retrieval / list | **Dashboard** / triage tools | +| `ENGINE_ID` | Engine routing | **Dashboard** / triage tools | +| `OPENAI_API_KEY` | Railtracks LLM | **Run triage** | -## Run triage on one ticket +A local `.env` next to [`pyproject.toml`](pyproject.toml) is loaded automatically for CLIs and Streamlit. -```bash -uv run python -m customer_support.triage --ticket fixtures/tickets/ticket_001.json -``` +
+Optional: PII masking -The agent calls Railengine-backed tools (`search_similar_tickets`, `list_recent_tickets`, `get_ticket_by_id`), then emits a **`TriageAssessment`** (priority, category, internal summary, draft reply, similar ticket ids). +If your engine masks sensitive fields after ingest, compare raw fixtures to stored docs in the dashboard to illustrate compliance-aware storage.
-## Streamlit UI +## Project layout -Local demo browser: +- `src/models/` — Pydantic shapes (`customer_support.models` at import time) +- `src/repositories/` — Railengine / ingest SDK I/O +- `src/services/` — ingest, list, triage use cases +- `src/controllers/` — CLI + webhook only +- `src/agents/` — Railtracks agent + tools +- `src/pages/` — Streamlit Dashboard + Ingest scripts +- [`src/streamlit_app.py`](src/streamlit_app.py) — navigation entry (importable as `customer_support.streamlit_app`) -```bash -cd Python/customer-support -uv sync -uv run streamlit run src/customer_support/streamlit_app.py -``` - -- Metrics show whether **`ENGINE_TOKEN`**, **`ENGINE_PAT`**, **`ENGINE_ID`**, and **`OPENAI_API_KEY`** are set (values are never shown). -- Sidebar: pick a **`fixtures/tickets/*.json`** file and click **Load into editor**. -- **Ingest to Railengine** and **Run triage** use the same helpers as the CLIs (`ingest_ticket`, `triage_ticket`). +## Local only -**Do not expose this Streamlit server** on the public internet without authentication; it inherits the usual risks of forwarding API credentials through a prototype web app. +Treat Streamlit like the CLI prototypes in this repo: **do not** expose it on the public internet with live credentials unless you add authentication and hardening yourself. ## Optional: webhook receiver -To exercise **activate** (webhook publishing) locally: +Activation / publishing smoke test: ```bash -uv run python -m customer_support.webhook_receiver --port 8765 +uv run python -m customer_support.controllers.webhook --port 8765 ``` -Configure the engine to POST to `http://:8765/webhook`. Each event is parsed and printed as JSON (ticket id, subject, status). - -## Security note - -Treat this sample like other examples in this repo: **do not** expose `ENGINE_TOKEN`, `ENGINE_PAT`, or `OPENAI_API_KEY` on a public host without authentication and rate limits. Treat the Streamlit UI the same way as the CLI: keep it **local** or behind a VPN and access control. - -## Layout - -| Path | Purpose | -|------|---------| -| `engine-schema.json` | Sample document for engine schema setup | -| `fixtures/tickets/` | JSON tickets with **synthetic** PII and a fake API key for masking demos | -| `src/customer_support/models.py` | `SupportTicket`, `TriageAssessment` | -| `src/customer_support/tools.py` | Railtracks `@function_node` tools using `rail-engine` | -| `src/customer_support/agent.py` | Railtracks agent + structured output | -| `src/customer_support/triage.py` | CLI entrypoint | -| `src/customer_support/triage_runner.py` | Shared async `triage_ticket()` for CLI and UI | -| `src/customer_support/ingest.py` | CLI + `ingest_ticket` helper | -| `src/customer_support/streamlit_app.py` | Streamlit demo UI (ingest + triage) | -| `src/customer_support/webhook_receiver.py` | Optional stdlib HTTP webhook | - -## Talk track (1 sentence) - -> “Tickets land in Railengine with masking and indexing; the Railtracks agent uses your engine as the **system of record** for similar cases and returns auditable structured triage.” +POST to `http://127.0.0.1:8765/webhook` (tunnel with ngrok if you need a public URL). diff --git a/Python/customer-support/pyproject.toml b/Python/customer-support/pyproject.toml index d77eab0..4e216d9 100644 --- a/Python/customer-support/pyproject.toml +++ b/Python/customer-support/pyproject.toml @@ -13,13 +13,26 @@ dependencies = [ "rail-engine-ingest>=0.2.1", "railtracks>=1.3.0", "pydantic>=2.0", + "python-dotenv>=1.0.0", "streamlit>=1.57.0", "watchdog>=6.0.0", ] [project.scripts] -support-ingest = "customer_support.ingest:main_sync" -support-triage = "customer_support.triage:main_sync" +support-ingest = "customer_support.controllers.cli.ingest:main_sync" +support-triage = "customer_support.controllers.cli.triage:main_sync" -[tool.setuptools.packages.find] -where = ["src"] +# Modules live directly under ``src/``; setuptools maps that tree to ``customer_support.*``. +[tool.setuptools] +package-dir = { "customer_support" = "src" } +packages = [ + "customer_support", + "customer_support.agents", + "customer_support.config", + "customer_support.controllers", + "customer_support.controllers.cli", + "customer_support.models", + "customer_support.pages", + "customer_support.repositories", + "customer_support.services", +] diff --git a/Python/customer-support/src/customer_support/__init__.py b/Python/customer-support/src/__init__.py similarity index 100% rename from Python/customer-support/src/customer_support/__init__.py rename to Python/customer-support/src/__init__.py diff --git a/Python/customer-support/src/agents/__init__.py b/Python/customer-support/src/agents/__init__.py new file mode 100644 index 0000000..398bd9e --- /dev/null +++ b/Python/customer-support/src/agents/__init__.py @@ -0,0 +1,5 @@ +"""Railtracks agents.""" + +from customer_support.agents.triage_agent import build_triage_agent + +__all__ = ["build_triage_agent"] diff --git a/Python/customer-support/src/customer_support/tools.py b/Python/customer-support/src/agents/tools.py similarity index 54% rename from Python/customer-support/src/customer_support/tools.py rename to Python/customer-support/src/agents/tools.py index 4c94314..d11afc6 100644 --- a/Python/customer-support/src/customer_support/tools.py +++ b/Python/customer-support/src/agents/tools.py @@ -1,4 +1,4 @@ -"""Railtracks tool nodes backed by rail-engine retrieval.""" +"""Railtracks tool nodes backed by TicketRepository (no global SDK client).""" from __future__ import annotations @@ -8,8 +8,8 @@ import railtracks as rt from customer_support.models import SupportTicket -from customer_support.rail_payload import ticket_from_row -from customer_support.runtime import get_railengine_client +from customer_support.repositories import TicketRepository +from customer_support.repositories.mappers import ticket_from_row def _ticket_to_brief(t: SupportTicket) -> dict[str, Any]: @@ -24,59 +24,6 @@ def _ticket_to_brief(t: SupportTicket) -> dict[str, Any]: } -async def _collect_index_hits(query: str, limit: int) -> list[SupportTicket]: - """Index search returns ``IndexingSearchResult`` (rail-engine ``await``, not ``async for``).""" - client = get_railengine_client() - out: list[SupportTicket] = [] - result = await client.search_index(query={"search": query}, raw=True) - for row in result.items: - t = ticket_from_row(row) - if t: - out.append(t) - if len(out) >= limit: - break - return out - - -async def _collect_vector_hits(query: str, limit: int) -> list[SupportTicket]: - """Vector search returns ``list`` (rail-engine ``await``, not ``async for``).""" - client = get_railengine_client() - items = await client.search_vector_store( - vector_store="VectorStore1", - query=query, - top=limit, - ) - out: list[SupportTicket] = [] - for row in items: - t = ticket_from_row(row) - if t: - out.append(t) - if len(out) >= limit: - break - return out - - -async def _iter_raw_storage_pages(*, page_size: int, max_docs: int): - """Walk storage pages until max_docs yielded or pages exhausted.""" - client = get_railengine_client() - scanned = 0 - pn = 1 - while scanned < max_docs: - page = await client.list_storage_documents( - page_number=pn, - page_size=page_size, - raw=True, - ) - for row in page.items: - yield row - scanned += 1 - if scanned >= max_docs: - return - if page.total_pages < 1 or pn >= page.total_pages: - return - pn += 1 - - @rt.function_node async def search_similar_tickets( query: str, @@ -93,12 +40,13 @@ async def search_similar_tickets( """ limit = max(1, min(int(limit), 25)) merged: dict[str, SupportTicket] = {} + repo = TicketRepository() if mode in ("index", "both"): - for t in await _collect_index_hits(query, limit): + for t in await repo.search_index_hits(query, limit): merged[t.id] = t if mode in ("vector", "both"): - for t in await _collect_vector_hits(query, limit): + for t in await repo.search_vector_hits(query, limit): merged[t.id] = t briefs = [_ticket_to_brief(t) for t in merged.values()] @@ -114,7 +62,7 @@ async def list_recent_tickets(status: str = "resolved", limit: int = 15) -> str: status: open | pending | resolved — only tickets matching this status are returned. limit: Max rows to return (JSONPath query when supported; otherwise capped scan). """ - client = get_railengine_client() + repo = TicketRepository() want = status.strip().lower() if want not in ("open", "pending", "resolved"): return json.dumps( @@ -125,18 +73,15 @@ async def list_recent_tickets(status: str = "resolved", limit: int = 15) -> str: cap = max(1, min(int(limit), 50)) collected: list[SupportTicket] = [] - page = await client.query_storage_by_jsonpath(json_path_query=f"$.status:{want}") - for row in page.items: - t = ticket_from_row(row) - if t: - collected.append(t) + for t in await repo.query_jsonpath_tickets(f"$.status:{want}"): + collected.append(t) if len(collected) >= cap: break if len(collected) < cap: max_scan = 400 seen_ids = {t.id for t in collected} - async for row in _iter_raw_storage_pages(page_size=100, max_docs=max_scan): + async for row in repo.iter_raw_storage_pages(page_size=100, max_docs=max_scan): t = ticket_from_row(row) if not t or t.status != want: continue @@ -161,21 +106,18 @@ async def get_ticket_by_id(ticket_id: str) -> str: Uses JSONPath storage query when available; falls back to a capped scan of recent pages. """ + repo = TicketRepository() tid = ticket_id.strip() if not tid: return json.dumps({"error": "ticket_id required"}, indent=2) - client = get_railengine_client() - - page = await client.query_storage_by_jsonpath(json_path_query=f"$.id:{tid}") - for row in page.items: - t = ticket_from_row(row) - if t and t.id == tid: + for t in await repo.query_jsonpath_tickets(f"$.id:{tid}"): + if t.id == tid: return json.dumps(_ticket_to_brief(t), ensure_ascii=False, indent=2) - async for row in _iter_raw_storage_pages(page_size=100, max_docs=500): - t = ticket_from_row(row) - if t and t.id == tid: - return json.dumps(_ticket_to_brief(t), ensure_ascii=False, indent=2) + async for row in repo.iter_raw_storage_pages(page_size=100, max_docs=500): + t_row = ticket_from_row(row) + if t_row and t_row.id == tid: + return json.dumps(_ticket_to_brief(t_row), ensure_ascii=False, indent=2) return json.dumps({"error": f"ticket not found: {tid}"}, indent=2) diff --git a/Python/customer-support/src/customer_support/agent.py b/Python/customer-support/src/agents/triage_agent.py similarity index 97% rename from Python/customer-support/src/customer_support/agent.py rename to Python/customer-support/src/agents/triage_agent.py index e6d1a20..44fd1c3 100644 --- a/Python/customer-support/src/customer_support/agent.py +++ b/Python/customer-support/src/agents/triage_agent.py @@ -6,12 +6,12 @@ import railtracks as rt -from customer_support.models import TriageAssessment -from customer_support.tools import ( +from customer_support.agents.tools import ( get_ticket_by_id, list_recent_tickets, search_similar_tickets, ) +from customer_support.models import TriageAssessment def build_triage_agent(): diff --git a/Python/customer-support/src/config/__init__.py b/Python/customer-support/src/config/__init__.py new file mode 100644 index 0000000..24d3f64 --- /dev/null +++ b/Python/customer-support/src/config/__init__.py @@ -0,0 +1,5 @@ +"""Configuration helpers.""" + +from customer_support.config.env import ensure_dotenv_loaded + +__all__ = ["ensure_dotenv_loaded"] diff --git a/Python/customer-support/src/config/env.py b/Python/customer-support/src/config/env.py new file mode 100644 index 0000000..37965af --- /dev/null +++ b/Python/customer-support/src/config/env.py @@ -0,0 +1,17 @@ +"""Load `.env` before reading Railengine / LLM environment variables.""" + +from __future__ import annotations + +from pathlib import Path + +from dotenv import load_dotenv + + +def ensure_dotenv_loaded() -> None: + """ + Load env from `Python/customer-support/.env`, then cwd `.env` (override). + """ + # ``src/config/env.py`` → parents[2] == customer-support project root + project_root = Path(__file__).resolve().parents[2] + load_dotenv(project_root / ".env") + load_dotenv() diff --git a/Python/customer-support/src/controllers/__init__.py b/Python/customer-support/src/controllers/__init__.py new file mode 100644 index 0000000..a897677 --- /dev/null +++ b/Python/customer-support/src/controllers/__init__.py @@ -0,0 +1 @@ +"""CLI/controller package.""" diff --git a/Python/customer-support/src/controllers/cli/__init__.py b/Python/customer-support/src/controllers/cli/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/Python/customer-support/src/controllers/cli/ingest.py b/Python/customer-support/src/controllers/cli/ingest.py new file mode 100644 index 0000000..3caf970 --- /dev/null +++ b/Python/customer-support/src/controllers/cli/ingest.py @@ -0,0 +1,33 @@ +"""CLI: ingest ticket fixtures.""" + +from __future__ import annotations + +import argparse +import asyncio +from pathlib import Path + +from customer_support.config.env import ensure_dotenv_loaded +from customer_support.services.ingest_service import IngestService + + +def main_sync() -> None: + ensure_dotenv_loaded() + parser = argparse.ArgumentParser( + description="Upsert support ticket JSON fixtures into Railengine.", + ) + parser.add_argument( + "files", + nargs="+", + help="Fixture paths (e.g. fixtures/tickets/*.json)", + ) + args = parser.parse_args() + paths = [Path(f) for f in args.files] + asyncio.run(IngestService().ingest_paths(paths)) + + +def main() -> None: + main_sync() + + +if __name__ == "__main__": + main() diff --git a/Python/customer-support/src/customer_support/triage.py b/Python/customer-support/src/controllers/cli/triage.py similarity index 83% rename from Python/customer-support/src/customer_support/triage.py rename to Python/customer-support/src/controllers/cli/triage.py index 756f19d..46b426a 100644 --- a/Python/customer-support/src/customer_support/triage.py +++ b/Python/customer-support/src/controllers/cli/triage.py @@ -1,4 +1,4 @@ -"""CLI: run Railtracks triage for one ticket fixture against live Railengine data.""" +"""CLI: run triage on one ticket fixture.""" from __future__ import annotations @@ -10,9 +10,9 @@ from pydantic import ValidationError -from customer_support._env import ensure_dotenv_loaded +from customer_support.config.env import ensure_dotenv_loaded from customer_support.models import SupportTicket -from customer_support.triage_runner import triage_ticket +from customer_support.services.triage_service import TriageService def main_sync() -> None: @@ -42,7 +42,7 @@ def main_sync() -> None: print(f"Invalid ticket JSON: {e}", file=sys.stderr) sys.exit(1) - assessment = asyncio.run(triage_ticket(ticket)) + assessment = asyncio.run(TriageService().run(ticket)) print(json.dumps(assessment.model_dump(), indent=2, ensure_ascii=False)) diff --git a/Python/customer-support/src/customer_support/webhook_receiver.py b/Python/customer-support/src/controllers/webhook.py similarity index 87% rename from Python/customer-support/src/customer_support/webhook_receiver.py rename to Python/customer-support/src/controllers/webhook.py index 0490b78..d3f618d 100644 --- a/Python/customer-support/src/customer_support/webhook_receiver.py +++ b/Python/customer-support/src/controllers/webhook.py @@ -1,13 +1,13 @@ """ Optional local webhook receiver for Railengine publishing (activation demo). -Run from this directory after installing the package:: +Run:: - uv run python -m customer_support.webhook_receiver --port 8765 + uv run python -m customer_support.controllers.webhook --port 8765 Point your engine webhook URL at http://localhost:8765/webhook (use ngrok or similar for a public URL). -The handler parses `WebhookPublishingPayload` bodies using the same `SupportTicket` model as ingest. +The handler parses `WebhookPublishingPayload` bodies using the same ``SupportTicket`` model as ingest. """ from __future__ import annotations @@ -20,7 +20,7 @@ from railtown.engine.ingest import WebhookHandler -from customer_support._env import ensure_dotenv_loaded +from customer_support.config.env import ensure_dotenv_loaded from customer_support.models import SupportTicket @@ -35,9 +35,9 @@ def do_POST(self) -> None: # noqa: N802 self.send_error(404, "Not Found") return length = int(self.headers.get("Content-Length", "0")) - raw = self.rfile.read(length).decode("utf-8", errors="replace") + raw_body = self.rfile.read(length).decode("utf-8", errors="replace") try: - payload: Any = json.loads(raw) if raw.strip() else {} + payload: Any = json.loads(raw_body) if raw_body.strip() else {} except json.JSONDecodeError: self.send_error(400, "Invalid JSON") return diff --git a/Python/customer-support/src/customer_support/_env.py b/Python/customer-support/src/customer_support/_env.py deleted file mode 100644 index 76da67a..0000000 --- a/Python/customer-support/src/customer_support/_env.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Load `.env` for CLI entrypoints before reading os.environ.""" - -from __future__ import annotations - -from pathlib import Path - -from dotenv import load_dotenv - - -def ensure_dotenv_loaded() -> None: - """ - Load env vars used by Railengine ingest/retrieval and LLMs. - - - First loads `Python/customer-support/.env` next to this package (works even when cwd is elsewhere). - - Then `load_dotenv()` so a `.env` in the current working directory can override values. - """ - project_root = Path(__file__).resolve().parents[2] - load_dotenv(project_root / ".env") - load_dotenv() diff --git a/Python/customer-support/src/customer_support/ingest.py b/Python/customer-support/src/customer_support/ingest.py deleted file mode 100644 index 2211b12..0000000 --- a/Python/customer-support/src/customer_support/ingest.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Load fixtures into Railengine via rail-engine-ingest.""" - -from __future__ import annotations - -import argparse -import asyncio -import json -from pathlib import Path - -from railtown.engine.ingest import RailengineIngest - -from customer_support._env import ensure_dotenv_loaded -from customer_support.models import SupportTicket - - -async def ingest_ticket_with_client(client: RailengineIngest, ticket: SupportTicket) -> int: - """Upsert using an existing ingest client.""" - resp = await client.upsert(ticket) - return resp.status_code - - -async def ingest_ticket(ticket: SupportTicket) -> int: - """Upsert a single ticket (opens its own ingest client/session).""" - async with RailengineIngest(model=SupportTicket) as client: - return await ingest_ticket_with_client(client, ticket) - - -async def ingest_paths(paths: list[Path]) -> None: - async with RailengineIngest(model=SupportTicket) as client: - for p in paths: - raw = json.loads(p.read_text(encoding="utf-8")) - ticket = SupportTicket.model_validate(raw) - status = await ingest_ticket_with_client(client, ticket) - print(f"Ingested {p.name} -> HTTP {status}") - - -def main_sync() -> None: - ensure_dotenv_loaded() - parser = argparse.ArgumentParser( - description="Upsert support ticket JSON fixtures into Railengine.", - ) - parser.add_argument( - "files", - nargs="+", - help="Fixture paths (e.g. fixtures/tickets/*.json)", - ) - args = parser.parse_args() - paths = [Path(f) for f in args.files] - asyncio.run(ingest_paths(paths)) - - -def main() -> None: - main_sync() - - -if __name__ == "__main__": - main() diff --git a/Python/customer-support/src/customer_support/runtime.py b/Python/customer-support/src/customer_support/runtime.py deleted file mode 100644 index e85613e..0000000 --- a/Python/customer-support/src/customer_support/runtime.py +++ /dev/null @@ -1,23 +0,0 @@ -"""Process-wide Railengine client for tool nodes (set from CLI before agent run).""" - -from __future__ import annotations - -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from railtown.engine import Railengine - -_client: Railengine | None = None - - -def set_railengine_client(client: Railengine) -> None: - global _client - _client = client - - -def get_railengine_client() -> Railengine: - if _client is None: - raise RuntimeError( - "Railengine client is not configured. Call set_railengine_client() before invoking tools." - ) - return _client diff --git a/Python/customer-support/src/customer_support/streamlit_app.py b/Python/customer-support/src/customer_support/streamlit_app.py deleted file mode 100644 index 79d8a1b..0000000 --- a/Python/customer-support/src/customer_support/streamlit_app.py +++ /dev/null @@ -1,160 +0,0 @@ -"""Streamlit UI: edit ticket JSON, ingest, and triage.""" - -from __future__ import annotations - -import asyncio -import json -import os -import traceback -from pathlib import Path - -import streamlit as st -from pydantic import ValidationError - -from customer_support._env import ensure_dotenv_loaded -from customer_support.ingest import ingest_ticket -from customer_support.models import SupportTicket, TriageAssessment -from customer_support.triage_runner import triage_ticket - -PROJECT_ROOT = Path(__file__).resolve().parents[2] -FIXTURES_DIR = PROJECT_ROOT / "fixtures" / "tickets" - - -def _env_ok() -> dict[str, bool]: - return { - "ENGINE_TOKEN": bool(os.environ.get("ENGINE_TOKEN", "").strip()), - "ENGINE_PAT": bool(os.environ.get("ENGINE_PAT", "").strip()), - "ENGINE_ID": bool(os.environ.get("ENGINE_ID", "").strip()), - "OPENAI_API_KEY": bool(os.environ.get("OPENAI_API_KEY", "").strip()), - } - - -def _fixture_paths() -> list[Path]: - if not FIXTURES_DIR.is_dir(): - return [] - return sorted(FIXTURES_DIR.glob("*.json")) - - -def main() -> None: - ensure_dotenv_loaded() - - st.set_page_config(page_title="Support Triage", layout="wide") - st.title("Customer support triage") - st.caption("Railengine ingest + retrieval + Railtracks structured triage.") - - cols = st.columns(4) - status = _env_ok() - for i, (key, ok) in enumerate(status.items()): - cols[i].metric(label=key, value="set" if ok else "missing") - - if "ticket_editor" not in st.session_state: - st.session_state.ticket_editor = "" - - sidebar = st.sidebar - sidebar.subheader("Fixtures") - fixtures = _fixture_paths() - fixture_names = [p.name for p in fixtures] - - if fixture_names: - pick = sidebar.selectbox("Pick fixture file", fixture_names) - if sidebar.button("Load into editor", type="secondary"): - text = (FIXTURES_DIR / pick).read_text(encoding="utf-8") - st.session_state.ticket_editor = text - st.rerun() - - sidebar.caption( - f"`fixtures/` path: `{FIXTURES_DIR}`" - if fixtures - else f"No `{FIXTURES_DIR}` — paste JSON manually." - ) - - txt = st.text_area( - "Ticket JSON (`SupportTicket` schema)", - height=340, - key="ticket_editor", - ) - - c1, c2 = st.columns(2) - - ingest_ok = status["ENGINE_TOKEN"] - - ticket: SupportTicket | None = None - if txt.strip(): - try: - ticket = SupportTicket.model_validate(json.loads(txt)) - st.success("JSON matches `SupportTicket` schema.") - except json.JSONDecodeError as e: - st.warning(f"Invalid JSON: {e}") - except ValidationError as e: - st.error(f"Does not match `SupportTicket`: {e}") - - with c1: - do_ingest = st.button( - "Ingest to Railengine", - disabled=not (ticket and ingest_ok), - help="Requires ENGINE_TOKEN.", - ) - - triage_ready = ( - ticket - and status["ENGINE_PAT"] - and status["ENGINE_ID"] - and status["OPENAI_API_KEY"] - ) - - with c2: - do_triage = st.button( - "Run triage", - disabled=not triage_ready, - help="Requires ENGINE_PAT, ENGINE_ID, and OPENAI_API_KEY.", - ) - - if do_ingest and ticket: - try: - with st.spinner("Ingesting…"): - http_status = asyncio.run(ingest_ticket(ticket)) - st.success(f"Ingest succeeded (HTTP {http_status}).") - except Exception: - st.error(traceback.format_exc()) - - if do_triage and ticket: - try: - with st.spinner("Running Railtracks triage (tools + structured output)…"): - assessment = asyncio.run(triage_ticket(ticket)) - if not isinstance(assessment, TriageAssessment): - assessment = TriageAssessment.model_validate(assessment) - - st.subheader("Triage output") - m1, m2 = st.columns(2) - m1.metric("Priority", assessment.priority.upper()) - m2.metric("Category", assessment.category) - - st.markdown("**Internal summary**") - st.write(assessment.internal_summary) - - st.markdown("**Draft reply**") - st.write(assessment.draft_reply_to_customer) - - st.markdown("**Reasoning**") - st.write(assessment.reasoning) - - sid = assessment.similar_ticket_ids - if sid: - st.markdown("**Similar ticket ids**") - st.code(", ".join(sid)) - - raw = assessment.model_dump() - st.download_button( - label="Download TriageAssessment JSON", - file_name=f"triage-{ticket.id}.json", - mime="application/json", - data=json.dumps(raw, indent=2, ensure_ascii=False).encode("utf-8"), - ) - with st.expander("Full JSON"): - st.json(raw) - except Exception: - st.error(traceback.format_exc()) - - -if __name__ == "__main__": - main() diff --git a/Python/customer-support/src/models/__init__.py b/Python/customer-support/src/models/__init__.py new file mode 100644 index 0000000..fb32130 --- /dev/null +++ b/Python/customer-support/src/models/__init__.py @@ -0,0 +1,7 @@ +"""Domain models.""" + +from customer_support.models.ticket import SupportTicket +from customer_support.models.ticket_page import TicketPage +from customer_support.models.triage import TriageAssessment + +__all__ = ["SupportTicket", "TicketPage", "TriageAssessment"] diff --git a/Python/customer-support/src/models/ticket.py b/Python/customer-support/src/models/ticket.py new file mode 100644 index 0000000..0cc8da7 --- /dev/null +++ b/Python/customer-support/src/models/ticket.py @@ -0,0 +1,21 @@ +"""Support ticket document (Railengine schema).""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, Field + + +class SupportTicket(BaseModel): + """Document shape stored in Railengine (matches engine-schema sample).""" + + id: str = Field(..., description="Stable ticket id / business key") + subject: str + body: str + status: Literal["open", "pending", "resolved"] + tags: list[str] = Field(default_factory=list) + createdAt: str + customerEmail: str = "" + customerPhone: str = "" + productArea: str = "" diff --git a/Python/customer-support/src/models/ticket_page.py b/Python/customer-support/src/models/ticket_page.py new file mode 100644 index 0000000..ef85f4c --- /dev/null +++ b/Python/customer-support/src/models/ticket_page.py @@ -0,0 +1,18 @@ +"""Paging DTO for storage list.""" + +from __future__ import annotations + +from dataclasses import dataclass + +from customer_support.models.ticket import SupportTicket + + +@dataclass(frozen=True) +class TicketPage: + """One page of tickets from Railengine storage list.""" + + items: list[SupportTicket] + total_pages: int + total_count: int + page_number: int + page_size: int diff --git a/Python/customer-support/src/customer_support/models.py b/Python/customer-support/src/models/triage.py similarity index 68% rename from Python/customer-support/src/customer_support/models.py rename to Python/customer-support/src/models/triage.py index 0f36af4..ad9d93d 100644 --- a/Python/customer-support/src/customer_support/models.py +++ b/Python/customer-support/src/models/triage.py @@ -1,4 +1,4 @@ -"""Pydantic models for support tickets and agent output.""" +"""Structured triage output from the Railtracks agent.""" from __future__ import annotations @@ -7,20 +7,6 @@ from pydantic import BaseModel, Field -class SupportTicket(BaseModel): - """Document shape stored in Railengine (matches engine-schema sample).""" - - id: str = Field(..., description="Stable ticket id / business key") - subject: str - body: str - status: Literal["open", "pending", "resolved"] - tags: list[str] = Field(default_factory=list) - createdAt: str - customerEmail: str = "" - customerPhone: str = "" - productArea: str = "" - - class TriageAssessment(BaseModel): """Structured triage output from the Railtracks agent.""" diff --git a/Python/customer-support/src/pages/__init__.py b/Python/customer-support/src/pages/__init__.py new file mode 100644 index 0000000..b1956fd --- /dev/null +++ b/Python/customer-support/src/pages/__init__.py @@ -0,0 +1 @@ +"""Streamlit multipage scripts (included in the wheel for paths relative to ``streamlit_app``).""" diff --git a/Python/customer-support/src/pages/dashboard.py b/Python/customer-support/src/pages/dashboard.py new file mode 100644 index 0000000..45288d2 --- /dev/null +++ b/Python/customer-support/src/pages/dashboard.py @@ -0,0 +1,55 @@ +"""Dashboard: paginated tickets from Railengine storage list.""" + +from __future__ import annotations + +import asyncio +import traceback + +import streamlit as st + +from customer_support.services.ticket_list_service import TicketListService +from customer_support.streamlit_common import env_ok, render_env_metrics + + +st.title("Dashboard") +st.caption("Tickets loaded via `Railengine.list_storage_documents` (paginated).") + +render_env_metrics() + +status = env_ok() +list_ready = status["ENGINE_PAT"] and status["ENGINE_ID"] + +if not list_ready: + st.warning( + "Set **ENGINE_PAT** and **ENGINE_ID** so the Dashboard can call `list_storage_documents`." + ) + +col_a, col_b, col_c = st.columns([2, 2, 4]) +page_number = int(col_a.number_input("Page", min_value=1, max_value=9999, value=1, step=1)) +page_size = int(col_b.selectbox("Rows per page", options=[25, 50, 75, 100], index=1)) + +refresh = col_c.button("Load / refresh") + +if refresh and list_ready: + svc = TicketListService() + try: + with st.spinner("Loading storage page…"): + page = asyncio.run(svc.fetch_page(page_number=page_number, page_size=page_size)) + except Exception: + st.error(traceback.format_exc()) + else: + st.caption( + f"Page **{page.page_number}** of **{page.total_pages}** • total documents: **{page.total_count}**" + ) + rows = [ + { + "id": t.id, + "subject": t.subject[:120] + ("…" if len(t.subject) > 120 else ""), + "status": t.status, + "tags": ", ".join(t.tags), + "productArea": t.productArea, + "createdAt": t.createdAt, + } + for t in page.items + ] + st.dataframe(rows, hide_index=True, use_container_width=True) diff --git a/Python/customer-support/src/pages/ingest_page.py b/Python/customer-support/src/pages/ingest_page.py new file mode 100644 index 0000000..4358bec --- /dev/null +++ b/Python/customer-support/src/pages/ingest_page.py @@ -0,0 +1,131 @@ +"""Streamlit page: ingest + triage.""" + +from __future__ import annotations + +import asyncio +import json +import traceback + +import streamlit as st +from pydantic import ValidationError + +from customer_support.models import SupportTicket, TriageAssessment +from customer_support.services.ingest_service import IngestService +from customer_support.services.triage_service import TriageService +from customer_support.streamlit_common import FIXTURES_DIR, env_ok, fixture_paths, render_env_metrics + + +st.title("Ingest") +st.caption("Edit ticket JSON, send to Railengine, or run structured triage with Railtracks.") + +render_env_metrics() + +status = env_ok() + +if "ticket_editor" not in st.session_state: + st.session_state.ticket_editor = "" + +sidebar = st.sidebar +sidebar.subheader("Fixtures") +fixtures = fixture_paths() +fixture_names = [p.name for p in fixtures] + +if fixture_names: + pick = sidebar.selectbox("Pick fixture file", fixture_names) + if sidebar.button("Load into editor", type="secondary"): + text = (FIXTURES_DIR / pick).read_text(encoding="utf-8") + st.session_state.ticket_editor = text + st.rerun() + +sidebar.caption( + f"`fixtures/` path: `{FIXTURES_DIR}`" + if fixtures + else f"No `{FIXTURES_DIR}` — paste JSON manually." +) + +txt = st.text_area( + "Ticket JSON (`SupportTicket` schema)", + height=340, + key="ticket_editor", +) + +c1, c2 = st.columns(2) + +ingest_ready = status["ENGINE_TOKEN"] + +ticket: SupportTicket | None = None +if txt.strip(): + try: + ticket = SupportTicket.model_validate(json.loads(txt)) + st.success("JSON matches `SupportTicket` schema.") + except json.JSONDecodeError as e: + st.warning(f"Invalid JSON: {e}") + except ValidationError as e: + st.error(f"Does not match `SupportTicket`: {e}") + +with c1: + do_ingest = st.button( + "Ingest to Railengine", + disabled=not (ticket and ingest_ready), + help="Requires ENGINE_TOKEN.", + ) + +triage_ready = ( + ticket + and status["ENGINE_PAT"] + and status["ENGINE_ID"] + and status["OPENAI_API_KEY"] +) + +with c2: + do_triage = st.button( + "Run triage", + disabled=not triage_ready, + help="Requires ENGINE_PAT, ENGINE_ID, and OPENAI_API_KEY.", + ) + +if do_ingest and ticket: + try: + with st.spinner("Ingesting…"): + http_status = asyncio.run(IngestService().ingest_ticket(ticket)) + st.success(f"Ingest succeeded (HTTP {http_status}).") + except Exception: + st.error(traceback.format_exc()) + +if do_triage and ticket: + try: + with st.spinner("Running Railtracks triage (tools + structured output)…"): + assessment = asyncio.run(TriageService().run(ticket)) + if not isinstance(assessment, TriageAssessment): + assessment = TriageAssessment.model_validate(assessment) + + st.subheader("Triage output") + m1, m2 = st.columns(2) + m1.metric("Priority", assessment.priority.upper()) + m2.metric("Category", assessment.category) + + st.markdown("**Internal summary**") + st.write(assessment.internal_summary) + + st.markdown("**Draft reply**") + st.write(assessment.draft_reply_to_customer) + + st.markdown("**Reasoning**") + st.write(assessment.reasoning) + + sid = assessment.similar_ticket_ids + if sid: + st.markdown("**Similar ticket ids**") + st.code(", ".join(sid)) + + raw = assessment.model_dump() + st.download_button( + label="Download TriageAssessment JSON", + file_name=f"triage-{ticket.id}.json", + mime="application/json", + data=json.dumps(raw, indent=2, ensure_ascii=False).encode("utf-8"), + ) + with st.expander("Full JSON"): + st.json(raw) + except Exception: + st.error(traceback.format_exc()) diff --git a/Python/customer-support/src/repositories/__init__.py b/Python/customer-support/src/repositories/__init__.py new file mode 100644 index 0000000..d60d8dd --- /dev/null +++ b/Python/customer-support/src/repositories/__init__.py @@ -0,0 +1,5 @@ +"""Repository layer.""" + +from customer_support.repositories.ticket_repository import TicketRepository, ingest_ticket_with_client + +__all__ = ["TicketRepository", "ingest_ticket_with_client"] diff --git a/Python/customer-support/src/customer_support/rail_payload.py b/Python/customer-support/src/repositories/mappers.py similarity index 83% rename from Python/customer-support/src/customer_support/rail_payload.py rename to Python/customer-support/src/repositories/mappers.py index e847105..a5a9596 100644 --- a/Python/customer-support/src/customer_support/rail_payload.py +++ b/Python/customer-support/src/repositories/mappers.py @@ -1,8 +1,4 @@ -"""Normalize Railengine storage/search rows into ticket-shaped dicts. - -The HTTP API may expose user JSON under Body, body, content, or Content (string or object), -matching patterns used elsewhere in railengine-examples TypeScript helpers. -""" +"""Map Railengine retrieval rows/documents -> SupportTicket models.""" from __future__ import annotations @@ -11,7 +7,7 @@ from pydantic import BaseModel -from customer_support.models import SupportTicket +from customer_support.models.ticket import SupportTicket def _parse_json_if_string(raw: Any) -> Any: @@ -45,7 +41,6 @@ def body_from_row(row: Any) -> dict[str, Any] | None: parsed = _parse_json_if_string(raw) if isinstance(parsed, dict): return parsed - # Row may already be the flat document (unlikely but tolerate) if "id" in d and "subject" in d: return dict(d) return None diff --git a/Python/customer-support/src/repositories/ticket_repository.py b/Python/customer-support/src/repositories/ticket_repository.py new file mode 100644 index 0000000..65c06b9 --- /dev/null +++ b/Python/customer-support/src/repositories/ticket_repository.py @@ -0,0 +1,124 @@ +"""Railengine ingest + retrieval I/O.""" + +from __future__ import annotations + +import json +from collections.abc import AsyncIterator +from pathlib import Path +from typing import Any + +from railtown.engine import Railengine +from railtown.engine.ingest import RailengineIngest + +from customer_support.models import SupportTicket, TicketPage +from customer_support.repositories.mappers import ticket_from_row + + +async def ingest_ticket_with_client(client: RailengineIngest, ticket: SupportTicket) -> int: + """Upsert using an existing ingest client.""" + resp = await client.upsert(ticket) + return resp.status_code + + +class TicketRepository: + """SDK-backed ticket persistence and search.""" + + async def upsert(self, ticket: SupportTicket) -> int: + async with RailengineIngest(model=SupportTicket) as client: + return await ingest_ticket_with_client(client, ticket) + + async def ingest_paths(self, paths: list[Path]) -> None: + """Batch ingest preserving a single ingest session.""" + async with RailengineIngest(model=SupportTicket) as client: + for path in paths: + raw = json.loads(path.read_text(encoding="utf-8")) + ticket = SupportTicket.model_validate(raw) + status = await ingest_ticket_with_client(client, ticket) + print(f"Ingested {path.name} -> HTTP {status}") + + async def list_page(self, page_number: int = 1, page_size: int = 100) -> TicketPage: + capped = max(1, min(int(page_size), 100)) + ps = capped + async with Railengine() as client: + page = await client.list_storage_documents( + page_number=page_number, + page_size=ps, + raw=True, + ) + + tickets: list[SupportTicket] = [] + for row in page.items: + t = ticket_from_row(row) + if t: + tickets.append(t) + + return TicketPage( + items=tickets, + total_pages=getattr(page, "total_pages", 0) or 0, + total_count=getattr(page, "total_count", len(tickets)), + page_number=getattr(page, "page_number", page_number), + page_size=getattr(page, "page_size", ps), + ) + + async def search_index_hits(self, query: str, limit: int) -> list[SupportTicket]: + out: list[SupportTicket] = [] + async with Railengine() as client: + result = await client.search_index(query={"search": query}, raw=True) + for row in result.items: + t = ticket_from_row(row) + if t: + out.append(t) + if len(out) >= limit: + break + return out + + async def search_vector_hits(self, query: str, limit: int) -> list[SupportTicket]: + out: list[SupportTicket] = [] + async with Railengine() as client: + items = await client.search_vector_store( + vector_store="VectorStore1", + query=query, + top=limit, + ) + for row in items: + t = ticket_from_row(row) + if t: + out.append(t) + if len(out) >= limit: + break + return out + + async def query_jsonpath_tickets(self, jq: str) -> list[SupportTicket]: + async with Railengine() as client: + page = await client.query_storage_by_jsonpath(json_path_query=jq) + collected: list[SupportTicket] = [] + for row in page.items: + t = ticket_from_row(row) + if t: + collected.append(t) + return collected + + async def iter_raw_storage_pages( + self, + *, + page_size: int, + max_docs: int, + ) -> AsyncIterator[Any]: + """Yield raw rows until ``max_docs`` or pages exhausted (single session).""" + async with Railengine() as client: + scanned = 0 + pn = 1 + while scanned < max_docs: + page = await client.list_storage_documents( + page_number=pn, + page_size=page_size, + raw=True, + ) + for row in page.items: + yield row + scanned += 1 + if scanned >= max_docs: + return + if page.total_pages < 1 or pn >= page.total_pages: + return + pn += 1 diff --git a/Python/customer-support/src/services/__init__.py b/Python/customer-support/src/services/__init__.py new file mode 100644 index 0000000..b169b7e --- /dev/null +++ b/Python/customer-support/src/services/__init__.py @@ -0,0 +1,7 @@ +"""Application services.""" + +from customer_support.services.ingest_service import IngestService +from customer_support.services.ticket_list_service import TicketListService +from customer_support.services.triage_service import TriageService + +__all__ = ["IngestService", "TicketListService", "TriageService"] diff --git a/Python/customer-support/src/services/ingest_service.py b/Python/customer-support/src/services/ingest_service.py new file mode 100644 index 0000000..1891d66 --- /dev/null +++ b/Python/customer-support/src/services/ingest_service.py @@ -0,0 +1,23 @@ +"""Ingest orchestration.""" + +from __future__ import annotations + +from pathlib import Path + +from customer_support.models import SupportTicket +from customer_support.repositories import TicketRepository + + +class IngestService: + """Use-case wrapper for fixture → Railengine ingest.""" + + def __init__(self, repository: TicketRepository | None = None) -> None: + self._repo = repository or TicketRepository() + + async def ingest_ticket(self, ticket: SupportTicket) -> int: + """Upsert a single ticket.""" + return await self._repo.upsert(ticket) + + async def ingest_paths(self, paths: list[Path]) -> None: + """Batch-ingest fixture files (single ingest session).""" + await self._repo.ingest_paths(paths) diff --git a/Python/customer-support/src/services/ticket_list_service.py b/Python/customer-support/src/services/ticket_list_service.py new file mode 100644 index 0000000..00231f5 --- /dev/null +++ b/Python/customer-support/src/services/ticket_list_service.py @@ -0,0 +1,16 @@ +"""Paginated storage list orchestration.""" + +from __future__ import annotations + +from customer_support.models import TicketPage +from customer_support.repositories import TicketRepository + + +class TicketListService: + """Expose ``list_storage_documents`` as a TicketPage.""" + + def __init__(self, repository: TicketRepository | None = None) -> None: + self._repo = repository or TicketRepository() + + async def fetch_page(self, page_number: int = 1, page_size: int = 50) -> TicketPage: + return await self._repo.list_page(page_number=page_number, page_size=page_size) diff --git a/Python/customer-support/src/customer_support/triage_runner.py b/Python/customer-support/src/services/triage_service.py similarity index 75% rename from Python/customer-support/src/customer_support/triage_runner.py rename to Python/customer-support/src/services/triage_service.py index 3c7bf21..9f7e193 100644 --- a/Python/customer-support/src/customer_support/triage_runner.py +++ b/Python/customer-support/src/services/triage_service.py @@ -1,4 +1,4 @@ -"""Shared async triage runner for CLI and Streamlit.""" +"""Railtracks triage flow orchestration.""" from __future__ import annotations @@ -6,17 +6,15 @@ import railtracks as rt from railtracks.built_nodes.concrete.response import StructuredResponse -from railtown.engine import Railengine -from customer_support.agent import build_triage_agent +from customer_support.agents.triage_agent import build_triage_agent from customer_support.models import SupportTicket, TriageAssessment -from customer_support.runtime import set_railengine_client -async def triage_ticket(ticket: SupportTicket) -> TriageAssessment: - """Run the Railtracks triage Flow against Railengine-backed tools.""" - async with Railengine() as engine: - set_railengine_client(engine) +class TriageService: + """Run the structured triage agent (tools use TicketRepository internally).""" + + async def run(self, ticket: SupportTicket) -> TriageAssessment: agent_cls = build_triage_agent() flow = rt.Flow(name="CustomerSupportTriage", entry_point=agent_cls) diff --git a/Python/customer-support/src/streamlit_app.py b/Python/customer-support/src/streamlit_app.py new file mode 100644 index 0000000..1fcbfff --- /dev/null +++ b/Python/customer-support/src/streamlit_app.py @@ -0,0 +1,23 @@ +"""Streamlit multipage entry: Dashboard + Ingest.""" + +from __future__ import annotations + +import streamlit as st + +from customer_support.config.env import ensure_dotenv_loaded + + +def main() -> None: + ensure_dotenv_loaded() + st.set_page_config(page_title="Support Triage", layout="wide") + pg = st.navigation( + [ + st.Page("pages/dashboard.py", title="Dashboard", default=True), + st.Page("pages/ingest_page.py", title="Ingest"), + ] + ) + pg.run() + + +if __name__ == "__main__": + main() diff --git a/Python/customer-support/src/streamlit_common.py b/Python/customer-support/src/streamlit_common.py new file mode 100644 index 0000000..5b088f3 --- /dev/null +++ b/Python/customer-support/src/streamlit_common.py @@ -0,0 +1,34 @@ +"""Shared Streamlit helpers: paths, fixtures, env status.""" + +from __future__ import annotations + +import os +from pathlib import Path + +import streamlit as st + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +FIXTURES_DIR = PROJECT_ROOT / "fixtures" / "tickets" + + +def env_ok() -> dict[str, bool]: + return { + "ENGINE_TOKEN": bool(os.environ.get("ENGINE_TOKEN", "").strip()), + "ENGINE_PAT": bool(os.environ.get("ENGINE_PAT", "").strip()), + "ENGINE_ID": bool(os.environ.get("ENGINE_ID", "").strip()), + "OPENAI_API_KEY": bool(os.environ.get("OPENAI_API_KEY", "").strip()), + } + + +def fixture_paths() -> list[Path]: + if not FIXTURES_DIR.is_dir(): + return [] + return sorted(FIXTURES_DIR.glob("*.json")) + + +def render_env_metrics() -> None: + """Render four env flags (values never shown).""" + status = env_ok() + cols = st.columns(4) + for i, (key, ok) in enumerate(status.items()): + cols[i].metric(label=key, value="set" if ok else "missing") From af21ad7350fe4202858e06920eb32b56b36224fd Mon Sep 17 00:00:00 2001 From: Jaime Bueza Date: Wed, 20 May 2026 21:21:04 -0700 Subject: [PATCH 14/42] Enhance customer support triage functionality by updating README with allowed ticket status values and improving demo flow instructions. Refactor ticket status handling in the dashboard and implement Kanban view for better ticket management. Introduce new methods for listing all tickets and updating ticket statuses in the repository and services. Update ticket model to support new status values. (#5895) Co-authored-by: Claude Opus 4.7 (1M context) --- Python/customer-support/README.md | 9 +- .../fixtures/tickets/pending_001.json | 11 +++ .../fixtures/tickets/ticket_002.json | 2 +- Python/customer-support/src/agents/tools.py | 9 +- .../customer-support/src/models/__init__.py | 16 +++- Python/customer-support/src/models/ticket.py | 13 ++- .../customer-support/src/pages/dashboard.py | 92 +++++++++++++++---- .../src/repositories/ticket_repository.py | 22 +++++ .../src/services/ingest_service.py | 7 +- .../src/services/ticket_list_service.py | 4 + .../customer-support/src/streamlit_common.py | 49 ++++++++++ 11 files changed, 201 insertions(+), 33 deletions(-) create mode 100644 Python/customer-support/fixtures/tickets/pending_001.json diff --git a/Python/customer-support/README.md b/Python/customer-support/README.md index 0e58692..60e1100 100644 --- a/Python/customer-support/README.md +++ b/Python/customer-support/README.md @@ -6,6 +6,7 @@ Demo stack: ingest support tickets into [Railengine](https://railengine.ai/), se - A [Railengine](https://railengine.ai/) account plus a **new engine** configured with the sample schema in [`engine-schema.json`](engine-schema.json). - Paste that schema into your engine schema editor so documents match **`SupportTicket`**. +- Allowed ticket **`status`** values when ingesting vs. validating in-app: **`pending`**, **`open`**, **`in_progress`**, **`resolved`** — update long-lived engine/schema rules if yours differ before re-ingesting fixtures. - Enable **Index** plus **VectorStore1** on fields such as `subject`, `body`, and `tags` in the Railengine console so search tools get useful hits beyond raw storage scans. ## Quick start @@ -22,10 +23,10 @@ uv run streamlit run src/streamlit_app.py ## First demo flow 1. Open **Ingest**, load **`fixtures/tickets/ticket_001.json`**, and click **Ingest to Railengine**. -2. (Optional history) Seed resolved examples: - `uv run support-ingest fixtures/tickets/resolved_*.json` +2. (Optional breadth) Seed the rest: + `uv run support-ingest fixtures/tickets/resolved_*.json fixtures/tickets/pending_001.json fixtures/tickets/ticket_002.json` 3. On **Ingest**, click **Run triage** and review priority + draft reply. -4. Switch to **Dashboard**, click **Load / refresh**, and confirm the ticket row appears. +4. Switch to **Dashboard**, click **Refresh board**, and browse the Kanban (**Pending**, **Open**, **In Progress**, **Resolved**). Change status from each card’s **dropdown** (**requires `ENGINE_TOKEN`** alongside list credentials). ## CLI (optional) @@ -38,7 +39,7 @@ uv run support-triage --ticket fixtures/tickets/ticket_001.json | Variable | Used for | Required when | |----------|-----------|---------------| -| `ENGINE_TOKEN` | Ingest SDK | **Ingest** page / `support-ingest` | +| `ENGINE_TOKEN` | Ingest SDK | **Ingest** page · `support-ingest` · **Kanban status** dropdown | | `ENGINE_PAT` | Retrieval / list | **Dashboard** / triage tools | | `ENGINE_ID` | Engine routing | **Dashboard** / triage tools | | `OPENAI_API_KEY` | Railtracks LLM | **Run triage** | diff --git a/Python/customer-support/fixtures/tickets/pending_001.json b/Python/customer-support/fixtures/tickets/pending_001.json new file mode 100644 index 0000000..8916519 --- /dev/null +++ b/Python/customer-support/fixtures/tickets/pending_001.json @@ -0,0 +1,11 @@ +{ + "id": "ticket-pending-001", + "subject": "Awaiting billing approval for quota increase", + "body": "Customer requested doubling API quota pending finance sign-off on the purchase order.", + "status": "pending", + "tags": ["billing", "quota"], + "createdAt": "2026-05-21T09:00:00Z", + "customerEmail": "ops-lead@vendor.example", + "customerPhone": "", + "productArea": "billing" +} diff --git a/Python/customer-support/fixtures/tickets/ticket_002.json b/Python/customer-support/fixtures/tickets/ticket_002.json index bef65d6..bfc2d1c 100644 --- a/Python/customer-support/fixtures/tickets/ticket_002.json +++ b/Python/customer-support/fixtures/tickets/ticket_002.json @@ -2,7 +2,7 @@ "id": "ticket-open-002", "subject": "SSO fails after password rotation — error AADSTS50076", "body": "All users in our eu-west tenant see AADSTS50076 when signing in via SAML. Admin contact: it-lead@contoso.example. Mobile: +44 20 7946 0958. Happened right after we rotated IdP certs.", - "status": "open", + "status": "in_progress", "tags": ["auth", "sso", "azure-ad"], "createdAt": "2026-05-20T16:20:00Z", "customerEmail": "it-lead@contoso.example", diff --git a/Python/customer-support/src/agents/tools.py b/Python/customer-support/src/agents/tools.py index d11afc6..bf7cf91 100644 --- a/Python/customer-support/src/agents/tools.py +++ b/Python/customer-support/src/agents/tools.py @@ -7,7 +7,7 @@ import railtracks as rt -from customer_support.models import SupportTicket +from customer_support.models.ticket import TICKET_STATUSES, SupportTicket from customer_support.repositories import TicketRepository from customer_support.repositories.mappers import ticket_from_row @@ -59,14 +59,15 @@ async def list_recent_tickets(status: str = "resolved", limit: int = 15) -> str: List recent tickets from hot storage. Filter client-side by status. Args: - status: open | pending | resolved — only tickets matching this status are returned. + status: pending | open | in_progress | resolved — only matching tickets returned. limit: Max rows to return (JSONPath query when supported; otherwise capped scan). """ repo = TicketRepository() want = status.strip().lower() - if want not in ("open", "pending", "resolved"): + valid = set(TICKET_STATUSES) + if want not in valid: return json.dumps( - {"error": "status must be open, pending, or resolved"}, + {"error": "status must be pending, open, in_progress, or resolved"}, indent=2, ) diff --git a/Python/customer-support/src/models/__init__.py b/Python/customer-support/src/models/__init__.py index fb32130..8fcaa27 100644 --- a/Python/customer-support/src/models/__init__.py +++ b/Python/customer-support/src/models/__init__.py @@ -1,7 +1,19 @@ """Domain models.""" -from customer_support.models.ticket import SupportTicket +from customer_support.models.ticket import ( + KANBAN_COLUMNS, + TICKET_STATUSES, + TicketStatus, + SupportTicket, +) from customer_support.models.ticket_page import TicketPage from customer_support.models.triage import TriageAssessment -__all__ = ["SupportTicket", "TicketPage", "TriageAssessment"] +__all__ = [ + "KANBAN_COLUMNS", + "TICKET_STATUSES", + "TicketPage", + "TicketStatus", + "SupportTicket", + "TriageAssessment", +] diff --git a/Python/customer-support/src/models/ticket.py b/Python/customer-support/src/models/ticket.py index 0cc8da7..d339f69 100644 --- a/Python/customer-support/src/models/ticket.py +++ b/Python/customer-support/src/models/ticket.py @@ -6,6 +6,17 @@ from pydantic import BaseModel, Field +TicketStatus = Literal["pending", "open", "in_progress", "resolved"] + +TICKET_STATUSES: tuple[TicketStatus, ...] = ("pending", "open", "in_progress", "resolved") + +KANBAN_COLUMNS: tuple[tuple[str, TicketStatus], ...] = ( + ("Pending", "pending"), + ("Open", "open"), + ("In Progress", "in_progress"), + ("Resolved", "resolved"), +) + class SupportTicket(BaseModel): """Document shape stored in Railengine (matches engine-schema sample).""" @@ -13,7 +24,7 @@ class SupportTicket(BaseModel): id: str = Field(..., description="Stable ticket id / business key") subject: str body: str - status: Literal["open", "pending", "resolved"] + status: TicketStatus = Field(...) tags: list[str] = Field(default_factory=list) createdAt: str customerEmail: str = "" diff --git a/Python/customer-support/src/pages/dashboard.py b/Python/customer-support/src/pages/dashboard.py index 45288d2..a2771ba 100644 --- a/Python/customer-support/src/pages/dashboard.py +++ b/Python/customer-support/src/pages/dashboard.py @@ -1,4 +1,4 @@ -"""Dashboard: paginated tickets from Railengine storage list.""" +"""Dashboard: interactive Kanban from full storage snapshot.""" from __future__ import annotations @@ -7,49 +7,101 @@ import streamlit as st +from customer_support.models.ticket import KANBAN_COLUMNS, TicketStatus, SupportTicket +from customer_support.services.ingest_service import IngestService from customer_support.services.ticket_list_service import TicketListService -from customer_support.streamlit_common import env_ok, render_env_metrics +from customer_support.streamlit_common import ( + env_ok, + group_tickets_by_status, + render_env_metrics, + render_kanban_ticket_card, +) +_KANBAN_SESSION_KEY = "kanban_tickets" st.title("Dashboard") -st.caption("Tickets loaded via `Railengine.list_storage_documents` (paginated).") +st.caption("Kanban sourced from **`list_storage_documents`** · moves persist via **ingest upsert**.") render_env_metrics() -status = env_ok() -list_ready = status["ENGINE_PAT"] and status["ENGINE_ID"] +env = env_ok() +list_ready = env["ENGINE_PAT"] and env["ENGINE_ID"] +ingest_ready = env["ENGINE_TOKEN"] + +if _KANBAN_SESSION_KEY not in st.session_state: + st.session_state[_KANBAN_SESSION_KEY] = [] if not list_ready: st.warning( - "Set **ENGINE_PAT** and **ENGINE_ID** so the Dashboard can call `list_storage_documents`." + "Set **ENGINE_PAT** and **ENGINE_ID** to load tickets from Railengine.", ) -col_a, col_b, col_c = st.columns([2, 2, 4]) -page_number = int(col_a.number_input("Page", min_value=1, max_value=9999, value=1, step=1)) -page_size = int(col_b.selectbox("Rows per page", options=[25, 50, 75, 100], index=1)) +if not ingest_ready: + st.warning("Set **ENGINE_TOKEN** to change status from card dropdowns (updates use ingest upsert).") -refresh = col_c.button("Load / refresh") +toolbar = st.columns([2, 6]) +refresh = toolbar[0].button("Refresh board") if refresh and list_ready: - svc = TicketListService() try: - with st.spinner("Loading storage page…"): - page = asyncio.run(svc.fetch_page(page_number=page_number, page_size=page_size)) + with st.spinner("Loading tickets from storage…"): + st.session_state[_KANBAN_SESSION_KEY] = asyncio.run(TicketListService().fetch_all()) except Exception: st.error(traceback.format_exc()) else: - st.caption( - f"Page **{page.page_number}** of **{page.total_pages}** • total documents: **{page.total_count}**" - ) - rows = [ + st.success(f"Loaded **{len(st.session_state[_KANBAN_SESSION_KEY])}** tickets.") + +tickets: list[SupportTicket] = st.session_state[_KANBAN_SESSION_KEY] +buckets = group_tickets_by_status(tickets) + +cols = st.columns(4) + +move_hit: tuple[SupportTicket, TicketStatus] | None = None + +for idx, (_label, stat) in enumerate(KANBAN_COLUMNS): + with cols[idx]: + cnt = len(buckets[stat]) + st.markdown(f"#### {_label} ") + st.caption(f"{cnt} ticket(s)") + for ticket in buckets[stat]: + target = render_kanban_ticket_card( + ticket, + moves_disabled=not ingest_ready, + ) + if target is not None: + move_hit = (ticket, target) + +if move_hit is not None and ingest_ready: + tk, dest = move_hit + svc = IngestService() + try: + with st.spinner("Updating status…"): + status_code = asyncio.run(svc.update_status(tk, dest)) + if list_ready: + st.session_state[_KANBAN_SESSION_KEY] = asyncio.run(TicketListService().fetch_all()) + else: + st.session_state[_KANBAN_SESSION_KEY] = [ + t.model_copy(update={"status": dest}) if t.id == tk.id else t + for t in st.session_state[_KANBAN_SESSION_KEY] + ] + st.toast(f"Moved `{tk.id}` → **{dest}** (HTTP {status_code})", icon="✅") + st.rerun() + except Exception: + st.error(traceback.format_exc()) + +if tickets: + with st.expander("Table view"): + tf = [ { "id": t.id, - "subject": t.subject[:120] + ("…" if len(t.subject) > 120 else ""), + "subject": t.subject[:140] + ("…" if len(t.subject) > 140 else ""), "status": t.status, "tags": ", ".join(t.tags), "productArea": t.productArea, "createdAt": t.createdAt, } - for t in page.items + for t in tickets ] - st.dataframe(rows, hide_index=True, use_container_width=True) + st.dataframe(tf, hide_index=True, use_container_width=True) +elif list_ready: + st.info("Click **Refresh board** to load tickets.") diff --git a/Python/customer-support/src/repositories/ticket_repository.py b/Python/customer-support/src/repositories/ticket_repository.py index 65c06b9..3653ba5 100644 --- a/Python/customer-support/src/repositories/ticket_repository.py +++ b/Python/customer-support/src/repositories/ticket_repository.py @@ -60,6 +60,28 @@ async def list_page(self, page_number: int = 1, page_size: int = 100) -> TicketP page_size=getattr(page, "page_size", ps), ) + async def list_all(self, *, page_size: int = 100) -> list[SupportTicket]: + """Walk every storage page in one Railengine session; return parsed tickets.""" + capped = max(1, min(int(page_size), 100)) + out: list[SupportTicket] = [] + async with Railengine() as client: + pn = 1 + while True: + page = await client.list_storage_documents( + page_number=pn, + page_size=capped, + raw=True, + ) + for row in page.items: + t = ticket_from_row(row) + if t: + out.append(t) + total_pages = getattr(page, "total_pages", 0) or 0 + if total_pages < 1 or pn >= total_pages: + break + pn += 1 + return out + async def search_index_hits(self, query: str, limit: int) -> list[SupportTicket]: out: list[SupportTicket] = [] async with Railengine() as client: diff --git a/Python/customer-support/src/services/ingest_service.py b/Python/customer-support/src/services/ingest_service.py index 1891d66..e90d344 100644 --- a/Python/customer-support/src/services/ingest_service.py +++ b/Python/customer-support/src/services/ingest_service.py @@ -4,7 +4,7 @@ from pathlib import Path -from customer_support.models import SupportTicket +from customer_support.models.ticket import SupportTicket, TicketStatus from customer_support.repositories import TicketRepository @@ -18,6 +18,11 @@ async def ingest_ticket(self, ticket: SupportTicket) -> int: """Upsert a single ticket.""" return await self._repo.upsert(ticket) + async def update_status(self, ticket: SupportTicket, status: TicketStatus) -> int: + """Persist a Kanban column change via upsert (requires ENGINE_TOKEN).""" + updated = ticket.model_copy(update={"status": status}) + return await self._repo.upsert(updated) + async def ingest_paths(self, paths: list[Path]) -> None: """Batch-ingest fixture files (single ingest session).""" await self._repo.ingest_paths(paths) diff --git a/Python/customer-support/src/services/ticket_list_service.py b/Python/customer-support/src/services/ticket_list_service.py index 00231f5..256ed83 100644 --- a/Python/customer-support/src/services/ticket_list_service.py +++ b/Python/customer-support/src/services/ticket_list_service.py @@ -14,3 +14,7 @@ def __init__(self, repository: TicketRepository | None = None) -> None: async def fetch_page(self, page_number: int = 1, page_size: int = 50) -> TicketPage: return await self._repo.list_page(page_number=page_number, page_size=page_size) + + async def fetch_all(self, *, page_size: int = 100) -> list[SupportTicket]: + """Full storage snapshot for Kanban (paginated internally).""" + return await self._repo.list_all(page_size=page_size) diff --git a/Python/customer-support/src/streamlit_common.py b/Python/customer-support/src/streamlit_common.py index 5b088f3..7032c9f 100644 --- a/Python/customer-support/src/streamlit_common.py +++ b/Python/customer-support/src/streamlit_common.py @@ -7,6 +7,13 @@ import streamlit as st +from customer_support.models.ticket import ( + KANBAN_COLUMNS, + TICKET_STATUSES, + TicketStatus, + SupportTicket, +) + PROJECT_ROOT = Path(__file__).resolve().parents[1] FIXTURES_DIR = PROJECT_ROOT / "fixtures" / "tickets" @@ -32,3 +39,45 @@ def render_env_metrics() -> None: cols = st.columns(4) for i, (key, ok) in enumerate(status.items()): cols[i].metric(label=key, value="set" if ok else "missing") + + +def group_tickets_by_status(tickets: list[SupportTicket]) -> dict[TicketStatus, list[SupportTicket]]: + buckets: dict[TicketStatus, list[SupportTicket]] = {s: [] for s in TICKET_STATUSES} + for t in tickets: + buckets[t.status].append(t) + return buckets + + +def render_kanban_ticket_card( + ticket: SupportTicket, + *, + moves_disabled: bool, +) -> TicketStatus | None: + """ + One Kanban card with a status dropdown. + Returns the new status when the selection differs from the ticket's current status. + """ + labels = [label for label, _ in KANBAN_COLUMNS] + status_by_label = {label: stat for label, stat in KANBAN_COLUMNS} + index = next(i for i, (_, s) in enumerate(KANBAN_COLUMNS) if s == ticket.status) + + with st.container(border=True): + subj = ticket.subject[:100] + ("…" if len(ticket.subject) > 100 else "") + st.markdown(f"**{subj}**") + tags = ", ".join(ticket.tags[:6]) + st.caption(f"`{ticket.id}` · _{ticket.productArea}_ · {ticket.createdAt[:10]}") + if tags: + st.caption(tags) + selected_label = st.selectbox( + "Status", + options=labels, + index=index, + key=f"status:{ticket.id}", + disabled=moves_disabled, + label_visibility="collapsed", + ) + + selected = status_by_label[selected_label] + if selected != ticket.status: + return selected + return None From c8dc8797e8c14975e36e810ef31f30e0adf1167c Mon Sep 17 00:00:00 2001 From: Jaime Bueza Date: Wed, 20 May 2026 21:25:39 -0700 Subject: [PATCH 15/42] Enhance Streamlit UI for customer support by adding a dialog for viewing ticket details and updating the environment metrics display within a collapsible expander. This improves user experience by organizing information more effectively. (#5895) Co-authored-by: Claude Opus 4.7 (1M context) --- .../customer-support/src/streamlit_common.py | 64 ++++++++++++++++--- 1 file changed, 56 insertions(+), 8 deletions(-) diff --git a/Python/customer-support/src/streamlit_common.py b/Python/customer-support/src/streamlit_common.py index 7032c9f..041c87f 100644 --- a/Python/customer-support/src/streamlit_common.py +++ b/Python/customer-support/src/streamlit_common.py @@ -33,12 +33,13 @@ def fixture_paths() -> list[Path]: return sorted(FIXTURES_DIR.glob("*.json")) -def render_env_metrics() -> None: - """Render four env flags (values never shown).""" - status = env_ok() - cols = st.columns(4) - for i, (key, ok) in enumerate(status.items()): - cols[i].metric(label=key, value="set" if ok else "missing") +def render_env_metrics(*, expanded: bool = False) -> None: + """Env flags in a collapsed expander (values never shown).""" + with st.expander("Environment variables", expanded=expanded): + status = env_ok() + cols = st.columns(4) + for i, (key, ok) in enumerate(status.items()): + cols[i].metric(label=key, value="set" if ok else "missing") def group_tickets_by_status(tickets: list[SupportTicket]) -> dict[TicketStatus, list[SupportTicket]]: @@ -48,13 +49,54 @@ def group_tickets_by_status(tickets: list[SupportTicket]) -> dict[TicketStatus, return buckets +@st.dialog("Ticket details", width="large") +def show_ticket_details_dialog(ticket: SupportTicket) -> None: + """Modal with full ticket fields (read-only).""" + st.markdown(f"### {ticket.subject}") + + m1, m2, m3 = st.columns(3) + status_label = next( + (label for label, stat in KANBAN_COLUMNS if stat == ticket.status), + ticket.status, + ) + m1.metric("Status", status_label) + m2.metric("Product area", ticket.productArea or "—") + m3.metric("Created", ticket.createdAt[:10] if ticket.createdAt else "—") + + st.markdown("**Ticket ID**") + st.code(ticket.id) + + if ticket.tags: + st.markdown("**Tags**") + st.write(", ".join(ticket.tags)) + + if ticket.customerEmail or ticket.customerPhone: + st.markdown("**Customer**") + if ticket.customerEmail: + st.write(f"Email: `{ticket.customerEmail}`") + if ticket.customerPhone: + st.write(f"Phone: `{ticket.customerPhone}`") + + st.markdown("**Body**") + st.text_area( + "Body", + value=ticket.body, + height=220, + disabled=True, + label_visibility="collapsed", + ) + + with st.expander("Raw JSON"): + st.json(ticket.model_dump()) + + def render_kanban_ticket_card( ticket: SupportTicket, *, moves_disabled: bool, ) -> TicketStatus | None: """ - One Kanban card with a status dropdown. + One Kanban card with a status dropdown and a dialog trigger on the subject. Returns the new status when the selection differs from the ticket's current status. """ labels = [label for label, _ in KANBAN_COLUMNS] @@ -63,7 +105,13 @@ def render_kanban_ticket_card( with st.container(border=True): subj = ticket.subject[:100] + ("…" if len(ticket.subject) > 100 else "") - st.markdown(f"**{subj}**") + if st.button( + subj, + key=f"view:{ticket.id}", + use_container_width=True, + type="tertiary", + ): + show_ticket_details_dialog(ticket) tags = ", ".join(ticket.tags[:6]) st.caption(f"`{ticket.id}` · _{ticket.productArea}_ · {ticket.createdAt[:10]}") if tags: From f1ff5b3c4e40e1156f47244b5edc1d4f5807cb6c Mon Sep 17 00:00:00 2001 From: Jaime Bueza Date: Wed, 20 May 2026 21:25:48 -0700 Subject: [PATCH 16/42] Update README.md to clarify the process of changing ticket status in the customer support dashboard. Enhanced instructions by specifying that users can click on a card subject to view ticket details in a dialog, improving usability and navigation. (#5895) Co-authored-by: Claude Opus 4.7 (1M context) --- Python/customer-support/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Python/customer-support/README.md b/Python/customer-support/README.md index 60e1100..a9603f7 100644 --- a/Python/customer-support/README.md +++ b/Python/customer-support/README.md @@ -26,7 +26,7 @@ uv run streamlit run src/streamlit_app.py 2. (Optional breadth) Seed the rest: `uv run support-ingest fixtures/tickets/resolved_*.json fixtures/tickets/pending_001.json fixtures/tickets/ticket_002.json` 3. On **Ingest**, click **Run triage** and review priority + draft reply. -4. Switch to **Dashboard**, click **Refresh board**, and browse the Kanban (**Pending**, **Open**, **In Progress**, **Resolved**). Change status from each card’s **dropdown** (**requires `ENGINE_TOKEN`** alongside list credentials). +4. Switch to **Dashboard**, click **Refresh board**, and browse the Kanban. Click a **card subject** to open ticket details in a dialog; change status from the card **dropdown** (**requires `ENGINE_TOKEN`** alongside list credentials). ## CLI (optional) From edd1fd850b5ea99de28dd7e38310ae9262db87a9 Mon Sep 17 00:00:00 2001 From: Jaime Bueza Date: Wed, 20 May 2026 21:29:35 -0700 Subject: [PATCH 17/42] Enhance customer support triage functionality by updating the README to clarify ticket triage processes and adding a new Triage Agent section. Refactor Streamlit app to include the Triage Agent in the multipage entry and improve ticket handling in services. Introduce methods for batch processing of tickets in the Triage Service, enhancing overall usability and functionality. (#5895) Co-authored-by: Claude Opus 4.7 (1M context) --- Python/customer-support/README.md | 4 +- .../src/pages/triage_agent_page.py | 128 ++++++++++++++++++ .../src/services/ticket_list_service.py | 9 +- .../src/services/triage_service.py | 7 + Python/customer-support/src/streamlit_app.py | 7 +- .../customer-support/src/streamlit_common.py | 23 ++++ 6 files changed, 171 insertions(+), 7 deletions(-) create mode 100644 Python/customer-support/src/pages/triage_agent_page.py diff --git a/Python/customer-support/README.md b/Python/customer-support/README.md index a9603f7..b653b05 100644 --- a/Python/customer-support/README.md +++ b/Python/customer-support/README.md @@ -25,7 +25,7 @@ uv run streamlit run src/streamlit_app.py 1. Open **Ingest**, load **`fixtures/tickets/ticket_001.json`**, and click **Ingest to Railengine**. 2. (Optional breadth) Seed the rest: `uv run support-ingest fixtures/tickets/resolved_*.json fixtures/tickets/pending_001.json fixtures/tickets/ticket_002.json` -3. On **Ingest**, click **Run triage** and review priority + draft reply. +3. On **Ingest**, click **Run triage** on a single ticket, or open **Triage Agent** → **Load queue** → **Triage queue** to prioritize all **open** and **pending** tickets. 4. Switch to **Dashboard**, click **Refresh board**, and browse the Kanban. Click a **card subject** to open ticket details in a dialog; change status from the card **dropdown** (**requires `ENGINE_TOKEN`** alongside list credentials). ## CLI (optional) @@ -58,7 +58,7 @@ If your engine masks sensitive fields after ingest, compare raw fixtures to stor - `src/services/` — ingest, list, triage use cases - `src/controllers/` — CLI + webhook only - `src/agents/` — Railtracks agent + tools -- `src/pages/` — Streamlit Dashboard + Ingest scripts +- `src/pages/` — Streamlit Dashboard, Ingest, and Triage Agent - [`src/streamlit_app.py`](src/streamlit_app.py) — navigation entry (importable as `customer_support.streamlit_app`) ## Local only diff --git a/Python/customer-support/src/pages/triage_agent_page.py b/Python/customer-support/src/pages/triage_agent_page.py new file mode 100644 index 0000000..1bb50b4 --- /dev/null +++ b/Python/customer-support/src/pages/triage_agent_page.py @@ -0,0 +1,128 @@ +"""Streamlit page: triage open and pending tickets with the Railtracks agent.""" + +from __future__ import annotations + +import asyncio +import traceback + +import streamlit as st + +from customer_support.models import SupportTicket, TriageAssessment +from customer_support.services.ticket_list_service import TicketListService +from customer_support.services.triage_service import TriageService +from customer_support.streamlit_common import ( + PRIORITY_ORDER, + env_ok, + render_env_metrics, + render_triage_assessment, +) + +_QUEUE_KEY = "triage_agent_queue" +_RESULTS_KEY = "triage_agent_results" + +st.title("Triage Agent") +st.caption( + "Load **open** and **pending** tickets from Railengine, then run the Railtracks agent " + "to prioritize the queue and explain why each ticket needs attention." +) + +render_env_metrics() + +env = env_ok() +list_ready = env["ENGINE_PAT"] and env["ENGINE_ID"] +triage_ready = list_ready and env["OPENAI_API_KEY"] + +if _QUEUE_KEY not in st.session_state: + st.session_state[_QUEUE_KEY] = [] +if _RESULTS_KEY not in st.session_state: + st.session_state[_RESULTS_KEY] = {} + +if not list_ready: + st.warning("Set **ENGINE_PAT** and **ENGINE_ID** to load the triage queue.") +if not env["OPENAI_API_KEY"]: + st.warning("Set **OPENAI_API_KEY** to run the triage agent.") + +toolbar = st.columns([2, 2, 4]) +load_queue = toolbar[0].button("Load queue") +triage_all = toolbar[1].button( + "Triage queue", + disabled=not triage_ready or len(st.session_state[_QUEUE_KEY]) == 0, +) + +if load_queue and list_ready: + try: + with st.spinner("Loading open and pending tickets…"): + st.session_state[_QUEUE_KEY] = asyncio.run(TicketListService().fetch_open_and_pending()) + except Exception: + st.error(traceback.format_exc()) + else: + n = len(st.session_state[_QUEUE_KEY]) + st.success(f"Queue loaded: **{n}** ticket(s) (open + pending).") + +queue: list[SupportTicket] = st.session_state[_QUEUE_KEY] +results: dict[str, TriageAssessment] = st.session_state[_RESULTS_KEY] + +if triage_all and triage_ready and queue: + try: + progress = st.progress(0.0, text="Running triage agent…") + + async def _triage_queue() -> dict[str, TriageAssessment]: + svc = TriageService() + batch: dict[str, TriageAssessment] = {} + total = len(queue) + for i, ticket in enumerate(queue, start=1): + progress.progress(i / total, text=f"Triage {i}/{total}: {ticket.id}") + batch[ticket.id] = await svc.run(ticket) + return batch + + batch = asyncio.run(_triage_queue()) + st.session_state[_RESULTS_KEY] = batch + results = batch + progress.empty() + st.success(f"Triage complete for **{len(batch)}** ticket(s).") + except Exception: + st.error(traceback.format_exc()) + +if queue: + st.subheader("Queue") + st.caption(f"{len(queue)} ticket(s) awaiting triage (open or pending).") + for ticket in queue: + cols = st.columns([5, 1]) + with cols[0]: + st.markdown(f"**{ticket.subject}**") + st.caption(f"`{ticket.id}` · **{ticket.status}** · {ticket.productArea}") + with cols[1]: + if st.button( + "Triage", + key=f"triage_one:{ticket.id}", + disabled=not triage_ready, + use_container_width=True, + ): + try: + with st.spinner(f"Triage {ticket.id}…"): + assessment = asyncio.run(TriageService().run(ticket)) + st.session_state[_RESULTS_KEY][ticket.id] = assessment + st.rerun() + except Exception: + st.error(traceback.format_exc()) +else: + st.info("Click **Load queue** to fetch open and pending tickets from storage.") + +if results: + st.subheader("Triage results") + st.caption("Sorted by priority (P1 first).") + + ticket_by_id = {t.id: t for t in queue} + sorted_ids = sorted( + results.keys(), + key=lambda tid: ( + PRIORITY_ORDER.get(results[tid].priority, 99), + tid, + ), + ) + + for tid in sorted_ids: + ticket = ticket_by_id.get(tid) + if ticket is None: + continue + render_triage_assessment(ticket, results[tid]) diff --git a/Python/customer-support/src/services/ticket_list_service.py b/Python/customer-support/src/services/ticket_list_service.py index 256ed83..ebd73de 100644 --- a/Python/customer-support/src/services/ticket_list_service.py +++ b/Python/customer-support/src/services/ticket_list_service.py @@ -2,7 +2,7 @@ from __future__ import annotations -from customer_support.models import TicketPage +from customer_support.models import SupportTicket, TicketPage from customer_support.repositories import TicketRepository @@ -16,5 +16,10 @@ async def fetch_page(self, page_number: int = 1, page_size: int = 50) -> TicketP return await self._repo.list_page(page_number=page_number, page_size=page_size) async def fetch_all(self, *, page_size: int = 100) -> list[SupportTicket]: - """Full storage snapshot for Kanban (paginated internally).""" + """Full storage snapshot (paginated internally).""" return await self._repo.list_all(page_size=page_size) + + async def fetch_open_and_pending(self, *, page_size: int = 100) -> list[SupportTicket]: + """Tickets in ``open`` or ``pending`` status for the triage queue.""" + tickets = await self.fetch_all(page_size=page_size) + return [t for t in tickets if t.status in ("open", "pending")] diff --git a/Python/customer-support/src/services/triage_service.py b/Python/customer-support/src/services/triage_service.py index 9f7e193..6764106 100644 --- a/Python/customer-support/src/services/triage_service.py +++ b/Python/customer-support/src/services/triage_service.py @@ -39,3 +39,10 @@ async def run(self, ticket: SupportTicket) -> TriageAssessment: if hasattr(result, "model_dump"): return TriageAssessment.model_validate(result.model_dump()) raise TypeError(f"Unexpected agent result type: {type(result)}") + + async def run_batch(self, tickets: list[SupportTicket]) -> dict[str, TriageAssessment]: + """Triage each ticket sequentially (one agent run per ticket).""" + results: dict[str, TriageAssessment] = {} + for ticket in tickets: + results[ticket.id] = await self.run(ticket) + return results diff --git a/Python/customer-support/src/streamlit_app.py b/Python/customer-support/src/streamlit_app.py index 1fcbfff..1e33dad 100644 --- a/Python/customer-support/src/streamlit_app.py +++ b/Python/customer-support/src/streamlit_app.py @@ -1,4 +1,4 @@ -"""Streamlit multipage entry: Dashboard + Ingest.""" +"""Streamlit multipage entry: Dashboard, Ingest, Triage Agent.""" from __future__ import annotations @@ -12,8 +12,9 @@ def main() -> None: st.set_page_config(page_title="Support Triage", layout="wide") pg = st.navigation( [ - st.Page("pages/dashboard.py", title="Dashboard", default=True), - st.Page("pages/ingest_page.py", title="Ingest"), + st.Page("pages/dashboard.py", title="Dashboard", icon="🗂️", default=True), + st.Page("pages/ingest_page.py", title="Ingest", icon="📥"), + st.Page("pages/triage_agent_page.py", title="Triage Agent", icon="🤖"), ] ) pg.run() diff --git a/Python/customer-support/src/streamlit_common.py b/Python/customer-support/src/streamlit_common.py index 041c87f..4dc11f2 100644 --- a/Python/customer-support/src/streamlit_common.py +++ b/Python/customer-support/src/streamlit_common.py @@ -13,10 +13,13 @@ TicketStatus, SupportTicket, ) +from customer_support.models.triage import TriageAssessment PROJECT_ROOT = Path(__file__).resolve().parents[1] FIXTURES_DIR = PROJECT_ROOT / "fixtures" / "tickets" +PRIORITY_ORDER = {"p1": 0, "p2": 1, "p3": 2, "p4": 3} + def env_ok() -> dict[str, bool]: return { @@ -129,3 +132,23 @@ def render_kanban_ticket_card( if selected != ticket.status: return selected return None + + +def render_triage_assessment(ticket: SupportTicket, assessment: TriageAssessment) -> None: + """Display structured triage output for a single ticket.""" + with st.container(border=True): + st.markdown(f"**{ticket.subject}**") + st.caption(f"`{ticket.id}` · queue status: **{ticket.status}**") + m1, m2 = st.columns(2) + m1.metric("Priority", assessment.priority.upper()) + m2.metric("Category", assessment.category) + st.markdown("**Why work on this**") + st.write(assessment.reasoning) + st.markdown("**Internal summary**") + st.write(assessment.internal_summary) + if assessment.similar_ticket_ids: + st.caption(f"Similar tickets: {', '.join(assessment.similar_ticket_ids)}") + with st.expander("Draft reply & full JSON"): + st.markdown("**Draft reply**") + st.write(assessment.draft_reply_to_customer) + st.json(assessment.model_dump()) From 3d63b6c806907d2d33d8e145d6d1ce0d7e7181a8 Mon Sep 17 00:00:00 2001 From: Jaime Bueza Date: Wed, 20 May 2026 21:35:38 -0700 Subject: [PATCH 18/42] Update README.md to include optional debugging and visualization instructions for the Triage Agent, enhancing user experience. Update triage_agent.py to use the latest OpenAI model version (gpt-5.4) for improved performance. (#5895) Co-authored-by: Claude Opus 4.7 (1M context) --- Python/customer-support/.gitignore | 1 + Python/customer-support/README.md | 13 +++++++++++++ Python/customer-support/src/agents/triage_agent.py | 2 +- 3 files changed, 15 insertions(+), 1 deletion(-) create mode 100644 Python/customer-support/.gitignore diff --git a/Python/customer-support/.gitignore b/Python/customer-support/.gitignore new file mode 100644 index 0000000..d1c298e --- /dev/null +++ b/Python/customer-support/.gitignore @@ -0,0 +1 @@ +.railtracks diff --git a/Python/customer-support/README.md b/Python/customer-support/README.md index b653b05..cfe08a6 100644 --- a/Python/customer-support/README.md +++ b/Python/customer-support/README.md @@ -35,6 +35,19 @@ uv run support-ingest fixtures/tickets/*.json uv run support-triage --ticket fixtures/tickets/ticket_001.json ``` +## Debug and visualize the triage agent (optional) + +After you run triage once (Streamlit **Triage Agent** / **Ingest**, or `support-triage`), you can inspect agent runs in the Railtracks UI: + +```bash +cd Python/customer-support +pip install 'railtracks[visual]' +railtracks update +railtracks viz +``` + +Opens the local visualization app so you can debug tool calls, prompts, and structured output from the support triage flow. + ## Environment variables | Variable | Used for | Required when | diff --git a/Python/customer-support/src/agents/triage_agent.py b/Python/customer-support/src/agents/triage_agent.py index 44fd1c3..0fd5984 100644 --- a/Python/customer-support/src/agents/triage_agent.py +++ b/Python/customer-support/src/agents/triage_agent.py @@ -22,7 +22,7 @@ def build_triage_agent(): "OPENAI_API_KEY is not set. Add it to .env or export it before running triage." ) - llm = rt.llm.OpenAILLM("gpt-4o") + llm = rt.llm.OpenAILLM("gpt-5.4") system = """You are an enterprise support triage lead. You receive a single support ticket (JSON). From a29639c82099d2f3feaabb164773b438a669fb5c Mon Sep 17 00:00:00 2001 From: Jaime Bueza Date: Wed, 20 May 2026 22:59:58 -0700 Subject: [PATCH 19/42] Implement chat functionality in TriageService for free-form conversations with the triage agent. Introduce _flow_reply_text helper function to format responses. This enhances user interaction by allowing conversational replies while maintaining ticket context. (#5895) Co-authored-by: Claude Opus 4.7 (1M context) --- .../src/services/triage_service.py | 35 ++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/Python/customer-support/src/services/triage_service.py b/Python/customer-support/src/services/triage_service.py index 6764106..88208f8 100644 --- a/Python/customer-support/src/services/triage_service.py +++ b/Python/customer-support/src/services/triage_service.py @@ -7,10 +7,21 @@ import railtracks as rt from railtracks.built_nodes.concrete.response import StructuredResponse -from customer_support.agents.triage_agent import build_triage_agent +from customer_support.agents.triage_agent import build_triage_agent, build_triage_chat_agent from customer_support.models import SupportTicket, TriageAssessment +def _flow_reply_text(result: object) -> str: + if isinstance(result, StructuredResponse): + structured = result.structured + if hasattr(structured, "model_dump_json"): + return structured.model_dump_json(indent=2) + return str(structured) + if hasattr(result, "content"): + return str(result.content) + return str(result) + + class TriageService: """Run the structured triage agent (tools use TicketRepository internally).""" @@ -46,3 +57,25 @@ async def run_batch(self, tickets: list[SupportTicket]) -> dict[str, TriageAsses for ticket in tickets: results[ticket.id] = await self.run(ticket) return results + + async def chat( + self, + messages: list[dict[str, str]], + *, + queue: list[SupportTicket] | None = None, + ) -> str: + """Free-form chat with the triage agent (same tools, conversational reply).""" + agent_cls = build_triage_chat_agent() + flow = rt.Flow(name="CustomerSupportTriageChat", entry_point=agent_cls) + + parts: list[str] = [] + if queue: + lines = [f"- {t.id} ({t.status}): {t.subject}" for t in queue[:30]] + parts.append("Open/pending queue in Railengine:\n" + "\n".join(lines)) + + transcript = "\n".join(f"{m['role'].upper()}: {m['content']}" for m in messages) + parts.append(f"Conversation:\n{transcript}") + + prompt = "\n\n".join(parts) + "\n\nRespond to the latest USER message." + result = await flow.ainvoke(prompt) + return _flow_reply_text(result) From 732af5065ddac6790996c9304c908b44db335087 Mon Sep 17 00:00:00 2001 From: Jaime Bueza Date: Wed, 20 May 2026 23:00:06 -0700 Subject: [PATCH 20/42] Add chat functionality to Triage Agent interface, allowing users to interact with the triage lead for ticket prioritization and inquiries. Update triage_agent.py to include a new build_triage_chat_agent function utilizing the latest OpenAI model (gpt-5.4). Enhance triage_agent_page.py to support chat interactions, including message display and input handling, improving user experience in managing support tickets. (#5895) Co-authored-by: Claude Opus 4.7 (1M context) --- .../src/agents/triage_agent.py | 30 +++ .../src/pages/triage_agent_page.py | 207 +++++++++++------- 2 files changed, 153 insertions(+), 84 deletions(-) diff --git a/Python/customer-support/src/agents/triage_agent.py b/Python/customer-support/src/agents/triage_agent.py index 0fd5984..d11eac5 100644 --- a/Python/customer-support/src/agents/triage_agent.py +++ b/Python/customer-support/src/agents/triage_agent.py @@ -45,3 +45,33 @@ def build_triage_agent(): system_message=system, output_schema=TriageAssessment, ) + + +def build_triage_chat_agent(): + """Conversational triage lead with the same Railengine tools (no structured output).""" + api_key = os.environ.get("OPENAI_API_KEY", "").strip() + if not api_key: + raise RuntimeError( + "OPENAI_API_KEY is not set. Add it to .env or export it before running triage." + ) + + llm = rt.llm.OpenAILLM("gpt-5.4") + + system = """You are an enterprise support triage lead chatting with a human support manager. + +You help prioritize open and pending tickets, explain customer impact, and suggest next steps. +Use search_similar_tickets, list_recent_tickets, and get_ticket_by_id when you need historical context from Railengine. +Never paste API keys, tokens, or passwords — refer to them only as credentials mentioned in a ticket. +Be concise and actionable. When comparing tickets, state priority rationale clearly (P1–P4 style). +""" + + return rt.agent_node( + "Support Triage Chat Agent", + tool_nodes=( + search_similar_tickets, + list_recent_tickets, + get_ticket_by_id, + ), + llm=llm, + system_message=system, + ) diff --git a/Python/customer-support/src/pages/triage_agent_page.py b/Python/customer-support/src/pages/triage_agent_page.py index 1bb50b4..3e2d8a1 100644 --- a/Python/customer-support/src/pages/triage_agent_page.py +++ b/Python/customer-support/src/pages/triage_agent_page.py @@ -19,11 +19,12 @@ _QUEUE_KEY = "triage_agent_queue" _RESULTS_KEY = "triage_agent_results" +_CHAT_KEY = "triage_agent_chat" st.title("Triage Agent") st.caption( - "Load **open** and **pending** tickets from Railengine, then run the Railtracks agent " - "to prioritize the queue and explain why each ticket needs attention." + "Chat with the triage lead on the left; load and run structured triage on **open** and **pending** " + "tickets on the right." ) render_env_metrics() @@ -36,93 +37,131 @@ st.session_state[_QUEUE_KEY] = [] if _RESULTS_KEY not in st.session_state: st.session_state[_RESULTS_KEY] = {} +if _CHAT_KEY not in st.session_state: + st.session_state[_CHAT_KEY] = [] if not list_ready: st.warning("Set **ENGINE_PAT** and **ENGINE_ID** to load the triage queue.") if not env["OPENAI_API_KEY"]: st.warning("Set **OPENAI_API_KEY** to run the triage agent.") -toolbar = st.columns([2, 2, 4]) -load_queue = toolbar[0].button("Load queue") -triage_all = toolbar[1].button( - "Triage queue", - disabled=not triage_ready or len(st.session_state[_QUEUE_KEY]) == 0, -) - -if load_queue and list_ready: - try: - with st.spinner("Loading open and pending tickets…"): - st.session_state[_QUEUE_KEY] = asyncio.run(TicketListService().fetch_open_and_pending()) - except Exception: - st.error(traceback.format_exc()) - else: - n = len(st.session_state[_QUEUE_KEY]) - st.success(f"Queue loaded: **{n}** ticket(s) (open + pending).") - -queue: list[SupportTicket] = st.session_state[_QUEUE_KEY] -results: dict[str, TriageAssessment] = st.session_state[_RESULTS_KEY] - -if triage_all and triage_ready and queue: - try: - progress = st.progress(0.0, text="Running triage agent…") - - async def _triage_queue() -> dict[str, TriageAssessment]: - svc = TriageService() - batch: dict[str, TriageAssessment] = {} - total = len(queue) - for i, ticket in enumerate(queue, start=1): - progress.progress(i / total, text=f"Triage {i}/{total}: {ticket.id}") - batch[ticket.id] = await svc.run(ticket) - return batch - - batch = asyncio.run(_triage_queue()) - st.session_state[_RESULTS_KEY] = batch - results = batch - progress.empty() - st.success(f"Triage complete for **{len(batch)}** ticket(s).") - except Exception: - st.error(traceback.format_exc()) - -if queue: - st.subheader("Queue") - st.caption(f"{len(queue)} ticket(s) awaiting triage (open or pending).") - for ticket in queue: - cols = st.columns([5, 1]) - with cols[0]: - st.markdown(f"**{ticket.subject}**") - st.caption(f"`{ticket.id}` · **{ticket.status}** · {ticket.productArea}") - with cols[1]: - if st.button( - "Triage", - key=f"triage_one:{ticket.id}", - disabled=not triage_ready, - use_container_width=True, - ): - try: - with st.spinner(f"Triage {ticket.id}…"): - assessment = asyncio.run(TriageService().run(ticket)) - st.session_state[_RESULTS_KEY][ticket.id] = assessment - st.rerun() - except Exception: - st.error(traceback.format_exc()) -else: - st.info("Click **Load queue** to fetch open and pending tickets from storage.") - -if results: - st.subheader("Triage results") - st.caption("Sorted by priority (P1 first).") - - ticket_by_id = {t.id: t for t in queue} - sorted_ids = sorted( - results.keys(), - key=lambda tid: ( - PRIORITY_ORDER.get(results[tid].priority, 99), - tid, - ), +chat_col, triage_col = st.columns([1, 1], gap="large") + +with chat_col: + st.subheader("Chat with agent") + st.caption("Ask about the queue, priorities, or similar resolved tickets.") + + if st.button("Clear chat", type="secondary"): + st.session_state[_CHAT_KEY] = [] + st.rerun() + + for msg in st.session_state[_CHAT_KEY]: + with st.chat_message(msg["role"]): + st.markdown(msg["content"]) + + queue_snapshot: list[SupportTicket] = st.session_state[_QUEUE_KEY] + if prompt := st.chat_input( + "Ask the triage agent…", + disabled=not triage_ready, + ): + st.session_state[_CHAT_KEY].append({"role": "user", "content": prompt}) + try: + with st.spinner("Agent thinking…"): + reply = asyncio.run( + TriageService().chat( + st.session_state[_CHAT_KEY], + queue=queue_snapshot or None, + ) + ) + st.session_state[_CHAT_KEY].append({"role": "assistant", "content": reply}) + except Exception: + st.session_state[_CHAT_KEY].pop() + st.error(traceback.format_exc()) + else: + st.rerun() + +with triage_col: + toolbar = st.columns([2, 2]) + load_queue = toolbar[0].button("Load queue") + triage_all = toolbar[1].button( + "Triage queue", + disabled=not triage_ready or len(st.session_state[_QUEUE_KEY]) == 0, ) - for tid in sorted_ids: - ticket = ticket_by_id.get(tid) - if ticket is None: - continue - render_triage_assessment(ticket, results[tid]) + if load_queue and list_ready: + try: + with st.spinner("Loading open and pending tickets…"): + st.session_state[_QUEUE_KEY] = asyncio.run(TicketListService().fetch_open_and_pending()) + except Exception: + st.error(traceback.format_exc()) + else: + n = len(st.session_state[_QUEUE_KEY]) + st.success(f"Queue loaded: **{n}** ticket(s) (open + pending).") + + queue: list[SupportTicket] = st.session_state[_QUEUE_KEY] + results: dict[str, TriageAssessment] = st.session_state[_RESULTS_KEY] + + if triage_all and triage_ready and queue: + try: + progress = st.progress(0.0, text="Running triage agent…") + + async def _triage_queue() -> dict[str, TriageAssessment]: + svc = TriageService() + batch: dict[str, TriageAssessment] = {} + total = len(queue) + for i, ticket in enumerate(queue, start=1): + progress.progress(i / total, text=f"Triage {i}/{total}: {ticket.id}") + batch[ticket.id] = await svc.run(ticket) + return batch + + batch = asyncio.run(_triage_queue()) + st.session_state[_RESULTS_KEY] = batch + results = batch + progress.empty() + st.success(f"Triage complete for **{len(batch)}** ticket(s).") + except Exception: + st.error(traceback.format_exc()) + + if queue: + st.subheader("Queue") + st.caption(f"{len(queue)} ticket(s) awaiting triage (open or pending).") + for ticket in queue: + cols = st.columns([5, 1]) + with cols[0]: + st.markdown(f"**{ticket.subject}**") + st.caption(f"`{ticket.id}` · **{ticket.status}** · {ticket.productArea}") + with cols[1]: + if st.button( + "Triage", + key=f"triage_one:{ticket.id}", + disabled=not triage_ready, + use_container_width=True, + ): + try: + with st.spinner(f"Triage {ticket.id}…"): + assessment = asyncio.run(TriageService().run(ticket)) + st.session_state[_RESULTS_KEY][ticket.id] = assessment + st.rerun() + except Exception: + st.error(traceback.format_exc()) + else: + st.info("Click **Load queue** to fetch open and pending tickets from storage.") + + if results: + st.subheader("Triage results") + st.caption("Sorted by priority (P1 first).") + + ticket_by_id = {t.id: t for t in queue} + sorted_ids = sorted( + results.keys(), + key=lambda tid: ( + PRIORITY_ORDER.get(results[tid].priority, 99), + tid, + ), + ) + + for tid in sorted_ids: + ticket = ticket_by_id.get(tid) + if ticket is None: + continue + render_triage_assessment(ticket, results[tid]) From aed2def57c8fdff55b10e4ef6ca5adbaaee7d844 Mon Sep 17 00:00:00 2001 From: Jaime Bueza Date: Mon, 25 May 2026 10:05:40 -0700 Subject: [PATCH 21/42] Refactor ticket status definition in ticket.py for improved readability by formatting TICKET_STATUSES as a multi-line tuple. This change enhances code clarity and maintainability. (#5895) Co-authored-by: Claude Opus 4.7 (1M context) --- Python/customer-support/src/models/ticket.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Python/customer-support/src/models/ticket.py b/Python/customer-support/src/models/ticket.py index d339f69..a692d4b 100644 --- a/Python/customer-support/src/models/ticket.py +++ b/Python/customer-support/src/models/ticket.py @@ -8,7 +8,12 @@ TicketStatus = Literal["pending", "open", "in_progress", "resolved"] -TICKET_STATUSES: tuple[TicketStatus, ...] = ("pending", "open", "in_progress", "resolved") +TICKET_STATUSES: tuple[TicketStatus, ...] = ( + "pending", + "open", + "in_progress", + "resolved", +) KANBAN_COLUMNS: tuple[tuple[str, TicketStatus], ...] = ( ("Pending", "pending"), From 258c7fddccd36457278cdf3816d2cfe8218922b8 Mon Sep 17 00:00:00 2001 From: Jaime Bueza Date: Mon, 25 May 2026 10:05:47 -0700 Subject: [PATCH 22/42] Refactor argument parser formatting in webhook.py for improved readability. Adjusted the description and print statement to multi-line format, enhancing code clarity. (#5895) Co-authored-by: Claude Opus 4.7 (1M context) --- Python/customer-support/src/controllers/webhook.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Python/customer-support/src/controllers/webhook.py b/Python/customer-support/src/controllers/webhook.py index d3f618d..c09ccaa 100644 --- a/Python/customer-support/src/controllers/webhook.py +++ b/Python/customer-support/src/controllers/webhook.py @@ -69,12 +69,16 @@ def do_POST(self) -> None: # noqa: N802 def main() -> None: ensure_dotenv_loaded() - parser = argparse.ArgumentParser(description="Receive Railengine webhook POSTs locally.") + parser = argparse.ArgumentParser( + description="Receive Railengine webhook POSTs locally." + ) parser.add_argument("--host", default="127.0.0.1") parser.add_argument("--port", type=int, default=8765) args = parser.parse_args() httpd = HTTPServer((args.host, args.port), Handler) - print(f"Listening on http://{args.host}:{args.port}/webhook (POST)", file=sys.stderr) + print( + f"Listening on http://{args.host}:{args.port}/webhook (POST)", file=sys.stderr + ) try: httpd.serve_forever() except KeyboardInterrupt: From c140708af79911be76901bd007817fff3c02d077 Mon Sep 17 00:00:00 2001 From: Jaime Bueza Date: Mon, 25 May 2026 10:05:54 -0700 Subject: [PATCH 23/42] Refactor dashboard.py to improve code readability by formatting long strings into multi-line statements. This change enhances clarity in the Streamlit UI components for better maintainability. (#5895) Co-authored-by: Claude Opus 4.7 (1M context) --- Python/customer-support/src/pages/dashboard.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/Python/customer-support/src/pages/dashboard.py b/Python/customer-support/src/pages/dashboard.py index a2771ba..68b5383 100644 --- a/Python/customer-support/src/pages/dashboard.py +++ b/Python/customer-support/src/pages/dashboard.py @@ -20,7 +20,9 @@ _KANBAN_SESSION_KEY = "kanban_tickets" st.title("Dashboard") -st.caption("Kanban sourced from **`list_storage_documents`** · moves persist via **ingest upsert**.") +st.caption( + "Kanban sourced from **`list_storage_documents`** · moves persist via **ingest upsert**." +) render_env_metrics() @@ -37,7 +39,9 @@ ) if not ingest_ready: - st.warning("Set **ENGINE_TOKEN** to change status from card dropdowns (updates use ingest upsert).") + st.warning( + "Set **ENGINE_TOKEN** to change status from card dropdowns (updates use ingest upsert)." + ) toolbar = st.columns([2, 6]) refresh = toolbar[0].button("Refresh board") @@ -45,7 +49,9 @@ if refresh and list_ready: try: with st.spinner("Loading tickets from storage…"): - st.session_state[_KANBAN_SESSION_KEY] = asyncio.run(TicketListService().fetch_all()) + st.session_state[_KANBAN_SESSION_KEY] = asyncio.run( + TicketListService().fetch_all() + ) except Exception: st.error(traceback.format_exc()) else: @@ -78,7 +84,9 @@ with st.spinner("Updating status…"): status_code = asyncio.run(svc.update_status(tk, dest)) if list_ready: - st.session_state[_KANBAN_SESSION_KEY] = asyncio.run(TicketListService().fetch_all()) + st.session_state[_KANBAN_SESSION_KEY] = asyncio.run( + TicketListService().fetch_all() + ) else: st.session_state[_KANBAN_SESSION_KEY] = [ t.model_copy(update={"status": dest}) if t.id == tk.id else t From 1d54a8e964db642586c9834a467b047ec4025246 Mon Sep 17 00:00:00 2001 From: Jaime Bueza Date: Mon, 25 May 2026 10:06:03 -0700 Subject: [PATCH 24/42] Refactor ingest_page.py to improve code readability by formatting long import statements and captions into multi-line statements. This change enhances clarity and maintainability of the Streamlit UI components. (#5895) Co-authored-by: Claude Opus 4.7 (1M context) --- Python/customer-support/src/pages/ingest_page.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/Python/customer-support/src/pages/ingest_page.py b/Python/customer-support/src/pages/ingest_page.py index 4358bec..479e81b 100644 --- a/Python/customer-support/src/pages/ingest_page.py +++ b/Python/customer-support/src/pages/ingest_page.py @@ -12,11 +12,18 @@ from customer_support.models import SupportTicket, TriageAssessment from customer_support.services.ingest_service import IngestService from customer_support.services.triage_service import TriageService -from customer_support.streamlit_common import FIXTURES_DIR, env_ok, fixture_paths, render_env_metrics +from customer_support.streamlit_common import ( + FIXTURES_DIR, + env_ok, + fixture_paths, + render_env_metrics, +) st.title("Ingest") -st.caption("Edit ticket JSON, send to Railengine, or run structured triage with Railtracks.") +st.caption( + "Edit ticket JSON, send to Railengine, or run structured triage with Railtracks." +) render_env_metrics() @@ -71,10 +78,7 @@ ) triage_ready = ( - ticket - and status["ENGINE_PAT"] - and status["ENGINE_ID"] - and status["OPENAI_API_KEY"] + ticket and status["ENGINE_PAT"] and status["ENGINE_ID"] and status["OPENAI_API_KEY"] ) with c2: From e48eae747e95d4eb6264278e7efbc08cf7a1fe46 Mon Sep 17 00:00:00 2001 From: Jaime Bueza Date: Mon, 25 May 2026 10:06:10 -0700 Subject: [PATCH 25/42] Refactor triage_agent_page.py to improve code readability by formatting long statements into multi-line expressions. This change enhances clarity and maintainability of the Streamlit UI components. (#5895) Co-authored-by: Claude Opus 4.7 (1M context) --- .../customer-support/src/pages/triage_agent_page.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/Python/customer-support/src/pages/triage_agent_page.py b/Python/customer-support/src/pages/triage_agent_page.py index 3e2d8a1..bc0f285 100644 --- a/Python/customer-support/src/pages/triage_agent_page.py +++ b/Python/customer-support/src/pages/triage_agent_page.py @@ -91,7 +91,9 @@ if load_queue and list_ready: try: with st.spinner("Loading open and pending tickets…"): - st.session_state[_QUEUE_KEY] = asyncio.run(TicketListService().fetch_open_and_pending()) + st.session_state[_QUEUE_KEY] = asyncio.run( + TicketListService().fetch_open_and_pending() + ) except Exception: st.error(traceback.format_exc()) else: @@ -110,7 +112,9 @@ async def _triage_queue() -> dict[str, TriageAssessment]: batch: dict[str, TriageAssessment] = {} total = len(queue) for i, ticket in enumerate(queue, start=1): - progress.progress(i / total, text=f"Triage {i}/{total}: {ticket.id}") + progress.progress( + i / total, text=f"Triage {i}/{total}: {ticket.id}" + ) batch[ticket.id] = await svc.run(ticket) return batch @@ -129,7 +133,9 @@ async def _triage_queue() -> dict[str, TriageAssessment]: cols = st.columns([5, 1]) with cols[0]: st.markdown(f"**{ticket.subject}**") - st.caption(f"`{ticket.id}` · **{ticket.status}** · {ticket.productArea}") + st.caption( + f"`{ticket.id}` · **{ticket.status}** · {ticket.productArea}" + ) with cols[1]: if st.button( "Triage", From 6b3dccc2c2402fbc0e632790e9d080c163b07d55 Mon Sep 17 00:00:00 2001 From: Jaime Bueza Date: Mon, 25 May 2026 10:06:18 -0700 Subject: [PATCH 26/42] Refactor import statements in __init__.py to improve code readability by formatting them into a multi-line structure. This change enhances clarity and maintainability of the repository layer. (#5895) Co-authored-by: Claude Opus 4.7 (1M context) --- Python/customer-support/src/repositories/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Python/customer-support/src/repositories/__init__.py b/Python/customer-support/src/repositories/__init__.py index d60d8dd..01551f2 100644 --- a/Python/customer-support/src/repositories/__init__.py +++ b/Python/customer-support/src/repositories/__init__.py @@ -1,5 +1,8 @@ """Repository layer.""" -from customer_support.repositories.ticket_repository import TicketRepository, ingest_ticket_with_client +from customer_support.repositories.ticket_repository import ( + TicketRepository, + ingest_ticket_with_client, +) __all__ = ["TicketRepository", "ingest_ticket_with_client"] From afca7653c9bd3455c4530c75a3ddbd91a8a1eac1 Mon Sep 17 00:00:00 2001 From: Jaime Bueza Date: Mon, 25 May 2026 10:06:27 -0700 Subject: [PATCH 27/42] Refactor function definitions across multiple files to improve code readability by formatting long parameter lists into multi-line structures. This change enhances clarity and maintainability of the codebase. (#5895) Co-authored-by: Claude Opus 4.7 (1M context) --- .../src/repositories/ticket_repository.py | 4 +++- .../customer-support/src/services/ticket_list_service.py | 4 +++- Python/customer-support/src/services/triage_service.py | 9 +++++++-- Python/customer-support/src/streamlit_common.py | 8 ++++++-- 4 files changed, 19 insertions(+), 6 deletions(-) diff --git a/Python/customer-support/src/repositories/ticket_repository.py b/Python/customer-support/src/repositories/ticket_repository.py index 3653ba5..519dc31 100644 --- a/Python/customer-support/src/repositories/ticket_repository.py +++ b/Python/customer-support/src/repositories/ticket_repository.py @@ -14,7 +14,9 @@ from customer_support.repositories.mappers import ticket_from_row -async def ingest_ticket_with_client(client: RailengineIngest, ticket: SupportTicket) -> int: +async def ingest_ticket_with_client( + client: RailengineIngest, ticket: SupportTicket +) -> int: """Upsert using an existing ingest client.""" resp = await client.upsert(ticket) return resp.status_code diff --git a/Python/customer-support/src/services/ticket_list_service.py b/Python/customer-support/src/services/ticket_list_service.py index ebd73de..fdd2a4c 100644 --- a/Python/customer-support/src/services/ticket_list_service.py +++ b/Python/customer-support/src/services/ticket_list_service.py @@ -19,7 +19,9 @@ async def fetch_all(self, *, page_size: int = 100) -> list[SupportTicket]: """Full storage snapshot (paginated internally).""" return await self._repo.list_all(page_size=page_size) - async def fetch_open_and_pending(self, *, page_size: int = 100) -> list[SupportTicket]: + async def fetch_open_and_pending( + self, *, page_size: int = 100 + ) -> list[SupportTicket]: """Tickets in ``open`` or ``pending`` status for the triage queue.""" tickets = await self.fetch_all(page_size=page_size) return [t for t in tickets if t.status in ("open", "pending")] diff --git a/Python/customer-support/src/services/triage_service.py b/Python/customer-support/src/services/triage_service.py index 88208f8..d4a24b4 100644 --- a/Python/customer-support/src/services/triage_service.py +++ b/Python/customer-support/src/services/triage_service.py @@ -7,7 +7,10 @@ import railtracks as rt from railtracks.built_nodes.concrete.response import StructuredResponse -from customer_support.agents.triage_agent import build_triage_agent, build_triage_chat_agent +from customer_support.agents.triage_agent import ( + build_triage_agent, + build_triage_chat_agent, +) from customer_support.models import SupportTicket, TriageAssessment @@ -51,7 +54,9 @@ async def run(self, ticket: SupportTicket) -> TriageAssessment: return TriageAssessment.model_validate(result.model_dump()) raise TypeError(f"Unexpected agent result type: {type(result)}") - async def run_batch(self, tickets: list[SupportTicket]) -> dict[str, TriageAssessment]: + async def run_batch( + self, tickets: list[SupportTicket] + ) -> dict[str, TriageAssessment]: """Triage each ticket sequentially (one agent run per ticket).""" results: dict[str, TriageAssessment] = {} for ticket in tickets: diff --git a/Python/customer-support/src/streamlit_common.py b/Python/customer-support/src/streamlit_common.py index 4dc11f2..b9a122d 100644 --- a/Python/customer-support/src/streamlit_common.py +++ b/Python/customer-support/src/streamlit_common.py @@ -45,7 +45,9 @@ def render_env_metrics(*, expanded: bool = False) -> None: cols[i].metric(label=key, value="set" if ok else "missing") -def group_tickets_by_status(tickets: list[SupportTicket]) -> dict[TicketStatus, list[SupportTicket]]: +def group_tickets_by_status( + tickets: list[SupportTicket], +) -> dict[TicketStatus, list[SupportTicket]]: buckets: dict[TicketStatus, list[SupportTicket]] = {s: [] for s in TICKET_STATUSES} for t in tickets: buckets[t.status].append(t) @@ -134,7 +136,9 @@ def render_kanban_ticket_card( return None -def render_triage_assessment(ticket: SupportTicket, assessment: TriageAssessment) -> None: +def render_triage_assessment( + ticket: SupportTicket, assessment: TriageAssessment +) -> None: """Display structured triage output for a single ticket.""" with st.container(border=True): st.markdown(f"**{ticket.subject}**") From 2b1a937a60cd7ca7d1e3b05b91f333a870276839 Mon Sep 17 00:00:00 2001 From: Jaime Bueza Date: Mon, 25 May 2026 10:27:28 -0700 Subject: [PATCH 28/42] Refactor TriageService docstring to improve readability by formatting the return statement details into a multi-line structure. This change enhances clarity and maintainability of the code. (#5895) Co-authored-by: Claude Opus 4.7 (1M context) --- Python/customer-support/src/services/triage_service.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Python/customer-support/src/services/triage_service.py b/Python/customer-support/src/services/triage_service.py index d4a24b4..4a5682e 100644 --- a/Python/customer-support/src/services/triage_service.py +++ b/Python/customer-support/src/services/triage_service.py @@ -40,7 +40,8 @@ async def run(self, ticket: SupportTicket) -> TriageAssessment: 1) Call search_similar_tickets with a query built from subject, body, and productArea. 2) Optionally call list_recent_tickets(status="resolved") for extra historical context. -3) Return a TriageAssessment with priority, category, internal_summary, draft_reply_to_customer, similar_ticket_ids, and reasoning. +3) Return a TriageAssessment with priority, category, internal_summary, + draft_reply_to_customer, similar_ticket_ids, and reasoning. """ result = await flow.ainvoke(prompt) if isinstance(result, StructuredResponse): From ffee5a2c5a89672ebea10eaed7793aaa248c8865 Mon Sep 17 00:00:00 2001 From: Jaime Bueza Date: Mon, 25 May 2026 10:28:24 -0700 Subject: [PATCH 29/42] Update README.md to include a section for Railengine Python examples and refactor a comment in triage_agent.py for improved readability by formatting a long line into a multi-line structure. This enhances clarity and maintainability of the documentation and code. (#5895) Co-authored-by: Claude Opus 4.7 (1M context) --- Python/README.md | 2 ++ Python/customer-support/src/agents/triage_agent.py | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/Python/README.md b/Python/README.md index 4f1ee4a..4bc989a 100644 --- a/Python/README.md +++ b/Python/README.md @@ -1,3 +1,5 @@ +# Railengine Python Examples + These samples use the Railengine Python SDK and [Railtracks](https://github.com/RailtownAI/railtracks). ## Examples diff --git a/Python/customer-support/src/agents/triage_agent.py b/Python/customer-support/src/agents/triage_agent.py index d11eac5..2a18639 100644 --- a/Python/customer-support/src/agents/triage_agent.py +++ b/Python/customer-support/src/agents/triage_agent.py @@ -28,7 +28,8 @@ def build_triage_agent(): Rules: - Use the search tools to find similar RESOLVED tickets before writing a reply. -- Never copy API keys, tokens, or passwords into the draft_reply — refer to them only as "the credential mentioned in the ticket" if needed. +- Never copy API keys, tokens, or passwords into the draft_reply — refer to them only as + "the credential mentioned in the ticket" if needed. - Prioritize customer impact and whether the issue blocks billing, security, or wide outages. - Populate similar_ticket_ids with ids you actually saw from tool results (may be empty if none). - Keep internal_summary factual and concise. From f2f2f06a3e0e3ab973916cb9db5b95ec6813b6a4 Mon Sep 17 00:00:00 2001 From: Jaime Bueza Date: Mon, 25 May 2026 10:56:25 -0700 Subject: [PATCH 30/42] Refactor controller package by updating the __init__.py docstring for clarity and removing unused CLI modules (ingest.py and triage.py). This streamlines the codebase and enhances maintainability. (#5895) Co-authored-by: Claude Opus 4.7 (1M context) --- .../src/controllers/__init__.py | 2 +- .../src/controllers/cli/__init__.py | 0 .../src/controllers/cli/ingest.py | 33 ------------ .../src/controllers/cli/triage.py | 54 ------------------- 4 files changed, 1 insertion(+), 88 deletions(-) delete mode 100644 Python/customer-support/src/controllers/cli/__init__.py delete mode 100644 Python/customer-support/src/controllers/cli/ingest.py delete mode 100644 Python/customer-support/src/controllers/cli/triage.py diff --git a/Python/customer-support/src/controllers/__init__.py b/Python/customer-support/src/controllers/__init__.py index a897677..6f911e8 100644 --- a/Python/customer-support/src/controllers/__init__.py +++ b/Python/customer-support/src/controllers/__init__.py @@ -1 +1 @@ -"""CLI/controller package.""" +"""Optional local controllers (e.g. webhook receiver).""" diff --git a/Python/customer-support/src/controllers/cli/__init__.py b/Python/customer-support/src/controllers/cli/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/Python/customer-support/src/controllers/cli/ingest.py b/Python/customer-support/src/controllers/cli/ingest.py deleted file mode 100644 index 3caf970..0000000 --- a/Python/customer-support/src/controllers/cli/ingest.py +++ /dev/null @@ -1,33 +0,0 @@ -"""CLI: ingest ticket fixtures.""" - -from __future__ import annotations - -import argparse -import asyncio -from pathlib import Path - -from customer_support.config.env import ensure_dotenv_loaded -from customer_support.services.ingest_service import IngestService - - -def main_sync() -> None: - ensure_dotenv_loaded() - parser = argparse.ArgumentParser( - description="Upsert support ticket JSON fixtures into Railengine.", - ) - parser.add_argument( - "files", - nargs="+", - help="Fixture paths (e.g. fixtures/tickets/*.json)", - ) - args = parser.parse_args() - paths = [Path(f) for f in args.files] - asyncio.run(IngestService().ingest_paths(paths)) - - -def main() -> None: - main_sync() - - -if __name__ == "__main__": - main() diff --git a/Python/customer-support/src/controllers/cli/triage.py b/Python/customer-support/src/controllers/cli/triage.py deleted file mode 100644 index 46b426a..0000000 --- a/Python/customer-support/src/controllers/cli/triage.py +++ /dev/null @@ -1,54 +0,0 @@ -"""CLI: run triage on one ticket fixture.""" - -from __future__ import annotations - -import argparse -import asyncio -import json -import sys -from pathlib import Path - -from pydantic import ValidationError - -from customer_support.config.env import ensure_dotenv_loaded -from customer_support.models import SupportTicket -from customer_support.services.triage_service import TriageService - - -def main_sync() -> None: - ensure_dotenv_loaded() - parser = argparse.ArgumentParser( - description="Run the Customer Support Triage agent for one ticket JSON file.", - ) - parser.add_argument( - "--ticket", - type=Path, - required=True, - help="Path to ticket JSON (e.g. fixtures/tickets/ticket_001.json)", - ) - args = parser.parse_args() - - if not args.ticket.is_file(): - print(f"File not found: {args.ticket}", file=sys.stderr) - sys.exit(1) - - try: - raw = json.loads(args.ticket.read_text(encoding="utf-8")) - ticket = SupportTicket.model_validate(raw) - except json.JSONDecodeError as e: - print(f"Invalid ticket JSON syntax: {e}", file=sys.stderr) - sys.exit(1) - except ValidationError as e: - print(f"Invalid ticket JSON: {e}", file=sys.stderr) - sys.exit(1) - - assessment = asyncio.run(TriageService().run(ticket)) - print(json.dumps(assessment.model_dump(), indent=2, ensure_ascii=False)) - - -def main() -> None: - main_sync() - - -if __name__ == "__main__": - main() From 77de4d28d56a92d7422295ac5fe28a180be741db Mon Sep 17 00:00:00 2001 From: Jaime Bueza Date: Mon, 25 May 2026 10:56:40 -0700 Subject: [PATCH 31/42] Remove unused CLI scripts from pyproject.toml and update package structure to enhance maintainability of the customer support module. (#5895) Co-authored-by: Claude Opus 4.7 (1M context) --- Python/customer-support/pyproject.toml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/Python/customer-support/pyproject.toml b/Python/customer-support/pyproject.toml index 4e216d9..54f9791 100644 --- a/Python/customer-support/pyproject.toml +++ b/Python/customer-support/pyproject.toml @@ -18,10 +18,6 @@ dependencies = [ "watchdog>=6.0.0", ] -[project.scripts] -support-ingest = "customer_support.controllers.cli.ingest:main_sync" -support-triage = "customer_support.controllers.cli.triage:main_sync" - # Modules live directly under ``src/``; setuptools maps that tree to ``customer_support.*``. [tool.setuptools] package-dir = { "customer_support" = "src" } @@ -30,7 +26,6 @@ packages = [ "customer_support.agents", "customer_support.config", "customer_support.controllers", - "customer_support.controllers.cli", "customer_support.models", "customer_support.pages", "customer_support.repositories", From 99f1e43694083cd2f2a154eafd89ac3a22e08cf8 Mon Sep 17 00:00:00 2001 From: Jaime Bueza Date: Mon, 25 May 2026 10:56:52 -0700 Subject: [PATCH 32/42] Enhance customer support module by updating README for clearer ingestion instructions, implementing full-width layout in Streamlit, and adding a pipeline stages visualization. Refactor ingest paths method to return results and improve error handling in fixture seeding. This improves usability and maintainability of the application. (#5895) Co-authored-by: Claude Opus 4.7 (1M context) --- Python/customer-support/README.md | 20 +-- .../customer-support/src/pages/ingest_page.py | 19 +++ .../src/repositories/ticket_repository.py | 6 +- .../src/services/ingest_service.py | 4 +- Python/customer-support/src/streamlit_app.py | 2 + .../customer-support/src/streamlit_common.py | 118 ++++++++++++++++++ 6 files changed, 151 insertions(+), 18 deletions(-) diff --git a/Python/customer-support/README.md b/Python/customer-support/README.md index cfe08a6..cba393a 100644 --- a/Python/customer-support/README.md +++ b/Python/customer-support/README.md @@ -23,21 +23,13 @@ uv run streamlit run src/streamlit_app.py ## First demo flow 1. Open **Ingest**, load **`fixtures/tickets/ticket_001.json`**, and click **Ingest to Railengine**. -2. (Optional breadth) Seed the rest: - `uv run support-ingest fixtures/tickets/resolved_*.json fixtures/tickets/pending_001.json fixtures/tickets/ticket_002.json` +2. (Optional breadth) On **Ingest**, use sidebar **Seed all fixtures** to ingest every `fixtures/tickets/*.json`. 3. On **Ingest**, click **Run triage** on a single ticket, or open **Triage Agent** → **Load queue** → **Triage queue** to prioritize all **open** and **pending** tickets. 4. Switch to **Dashboard**, click **Refresh board**, and browse the Kanban. Click a **card subject** to open ticket details in a dialog; change status from the card **dropdown** (**requires `ENGINE_TOKEN`** alongside list credentials). -## CLI (optional) - -```bash -uv run support-ingest fixtures/tickets/*.json -uv run support-triage --ticket fixtures/tickets/ticket_001.json -``` - ## Debug and visualize the triage agent (optional) -After you run triage once (Streamlit **Triage Agent** / **Ingest**, or `support-triage`), you can inspect agent runs in the Railtracks UI: +After you run triage once (**Ingest** or **Triage Agent**), inspect agent runs in the Railtracks UI: ```bash cd Python/customer-support @@ -52,12 +44,12 @@ Opens the local visualization app so you can debug tool calls, prompts, and stru | Variable | Used for | Required when | |----------|-----------|---------------| -| `ENGINE_TOKEN` | Ingest SDK | **Ingest** page · `support-ingest` · **Kanban status** dropdown | +| `ENGINE_TOKEN` | Ingest SDK | **Ingest** page · **Kanban status** dropdown | | `ENGINE_PAT` | Retrieval / list | **Dashboard** / triage tools | | `ENGINE_ID` | Engine routing | **Dashboard** / triage tools | | `OPENAI_API_KEY` | Railtracks LLM | **Run triage** | -A local `.env` next to [`pyproject.toml`](pyproject.toml) is loaded automatically for CLIs and Streamlit. +A local `.env` next to [`pyproject.toml`](pyproject.toml) is loaded automatically for Streamlit and the webhook receiver.
Optional: PII masking @@ -69,14 +61,14 @@ If your engine masks sensitive fields after ingest, compare raw fixtures to stor - `src/models/` — Pydantic shapes (`customer_support.models` at import time) - `src/repositories/` — Railengine / ingest SDK I/O - `src/services/` — ingest, list, triage use cases -- `src/controllers/` — CLI + webhook only +- `src/controllers/` — optional webhook receiver - `src/agents/` — Railtracks agent + tools - `src/pages/` — Streamlit Dashboard, Ingest, and Triage Agent - [`src/streamlit_app.py`](src/streamlit_app.py) — navigation entry (importable as `customer_support.streamlit_app`) ## Local only -Treat Streamlit like the CLI prototypes in this repo: **do not** expose it on the public internet with live credentials unless you add authentication and hardening yourself. +**Do not** expose the Streamlit app on the public internet with live credentials unless you add authentication and hardening yourself. ## Optional: webhook receiver diff --git a/Python/customer-support/src/pages/ingest_page.py b/Python/customer-support/src/pages/ingest_page.py index 479e81b..fa6c67c 100644 --- a/Python/customer-support/src/pages/ingest_page.py +++ b/Python/customer-support/src/pages/ingest_page.py @@ -17,6 +17,7 @@ env_ok, fixture_paths, render_env_metrics, + render_pipeline_stages, ) @@ -25,6 +26,8 @@ "Edit ticket JSON, send to Railengine, or run structured triage with Railtracks." ) +render_pipeline_stages() + render_env_metrics() status = env_ok() @@ -43,6 +46,21 @@ text = (FIXTURES_DIR / pick).read_text(encoding="utf-8") st.session_state.ticket_editor = text st.rerun() + if sidebar.button( + "Seed all fixtures", + type="secondary", + disabled=not status["ENGINE_TOKEN"], + help="Ingest every `fixtures/tickets/*.json` (requires ENGINE_TOKEN).", + ): + try: + with st.spinner(f"Ingesting {len(fixtures)} fixture(s)…"): + results = asyncio.run(IngestService().ingest_paths(fixtures)) + st.sidebar.success( + f"Seeded **{len(results)}** file(s): " + + ", ".join(f"`{name}` ({code})" for name, code in results) + ) + except Exception: + st.sidebar.error(traceback.format_exc()) sidebar.caption( f"`fixtures/` path: `{FIXTURES_DIR}`" @@ -54,6 +72,7 @@ "Ticket JSON (`SupportTicket` schema)", height=340, key="ticket_editor", + width="stretch", ) c1, c2 = st.columns(2) diff --git a/Python/customer-support/src/repositories/ticket_repository.py b/Python/customer-support/src/repositories/ticket_repository.py index 519dc31..8371eec 100644 --- a/Python/customer-support/src/repositories/ticket_repository.py +++ b/Python/customer-support/src/repositories/ticket_repository.py @@ -29,14 +29,16 @@ async def upsert(self, ticket: SupportTicket) -> int: async with RailengineIngest(model=SupportTicket) as client: return await ingest_ticket_with_client(client, ticket) - async def ingest_paths(self, paths: list[Path]) -> None: + async def ingest_paths(self, paths: list[Path]) -> list[tuple[str, int]]: """Batch ingest preserving a single ingest session.""" + results: list[tuple[str, int]] = [] async with RailengineIngest(model=SupportTicket) as client: for path in paths: raw = json.loads(path.read_text(encoding="utf-8")) ticket = SupportTicket.model_validate(raw) status = await ingest_ticket_with_client(client, ticket) - print(f"Ingested {path.name} -> HTTP {status}") + results.append((path.name, status)) + return results async def list_page(self, page_number: int = 1, page_size: int = 100) -> TicketPage: capped = max(1, min(int(page_size), 100)) diff --git a/Python/customer-support/src/services/ingest_service.py b/Python/customer-support/src/services/ingest_service.py index e90d344..9e00280 100644 --- a/Python/customer-support/src/services/ingest_service.py +++ b/Python/customer-support/src/services/ingest_service.py @@ -23,6 +23,6 @@ async def update_status(self, ticket: SupportTicket, status: TicketStatus) -> in updated = ticket.model_copy(update={"status": status}) return await self._repo.upsert(updated) - async def ingest_paths(self, paths: list[Path]) -> None: + async def ingest_paths(self, paths: list[Path]) -> list[tuple[str, int]]: """Batch-ingest fixture files (single ingest session).""" - await self._repo.ingest_paths(paths) + return await self._repo.ingest_paths(paths) diff --git a/Python/customer-support/src/streamlit_app.py b/Python/customer-support/src/streamlit_app.py index 1e33dad..14af671 100644 --- a/Python/customer-support/src/streamlit_app.py +++ b/Python/customer-support/src/streamlit_app.py @@ -5,11 +5,13 @@ import streamlit as st from customer_support.config.env import ensure_dotenv_loaded +from customer_support.streamlit_common import use_full_width_layout def main() -> None: ensure_dotenv_loaded() st.set_page_config(page_title="Support Triage", layout="wide") + use_full_width_layout() pg = st.navigation( [ st.Page("pages/dashboard.py", title="Dashboard", icon="🗂️", default=True), diff --git a/Python/customer-support/src/streamlit_common.py b/Python/customer-support/src/streamlit_common.py index b9a122d..90cbe72 100644 --- a/Python/customer-support/src/streamlit_common.py +++ b/Python/customer-support/src/streamlit_common.py @@ -36,6 +36,124 @@ def fixture_paths() -> list[Path]: return sorted(FIXTURES_DIR.glob("*.json")) +_PIPELINE_STAGES_HTML = """ + + +""" + + +_FULL_WIDTH_CSS = """ + +""" + + +def use_full_width_layout() -> None: + """Expand main content to full viewport width (call once from app entry).""" + st.markdown(_FULL_WIDTH_CSS, unsafe_allow_html=True) + + +def render_pipeline_stages() -> None: + """Railengine pipeline overview (HTML; works without Mermaid support).""" + st.markdown("#### Pipeline stages") + st.html(_PIPELINE_STAGES_HTML, width="stretch") + st.caption( + "This page sends JSON through **Ingest**. Triage agents search **Embedding** and " + "**Indexing**; the dashboard reads **Hot Storage**." + ) + + def render_env_metrics(*, expanded: bool = False) -> None: """Env flags in a collapsed expander (values never shown).""" with st.expander("Environment variables", expanded=expanded): From c12b552095a794f6b056d6e11b31021bbe2f0b15 Mon Sep 17 00:00:00 2001 From: Jaime Bueza Date: Mon, 25 May 2026 11:25:56 -0700 Subject: [PATCH 33/42] Refactor customer support module by replacing the Triage Agent page with a new Agent page, updating README for clearer instructions, and enhancing the pyproject.toml dependencies. This improves usability and maintainability of the application. (#5895) Co-authored-by: Claude Opus 4.7 (1M context) --- Python/customer-support/README.md | 9 +- .../assets/logo-railengine.png | Bin 0 -> 3213 bytes Python/customer-support/pyproject.toml | 2 +- .../customer-support/src/pages/agent_page.py | 243 ++++++++++++++++++ .../customer-support/src/pages/dashboard.py | 6 +- .../customer-support/src/pages/ingest_page.py | 5 +- .../src/pages/triage_agent_page.py | 173 ------------- Python/customer-support/src/streamlit_app.py | 7 +- .../customer-support/src/streamlit_common.py | 111 ++++++-- 9 files changed, 353 insertions(+), 203 deletions(-) create mode 100644 Python/customer-support/assets/logo-railengine.png create mode 100644 Python/customer-support/src/pages/agent_page.py delete mode 100644 Python/customer-support/src/pages/triage_agent_page.py diff --git a/Python/customer-support/README.md b/Python/customer-support/README.md index cba393a..c40911b 100644 --- a/Python/customer-support/README.md +++ b/Python/customer-support/README.md @@ -24,20 +24,21 @@ uv run streamlit run src/streamlit_app.py 1. Open **Ingest**, load **`fixtures/tickets/ticket_001.json`**, and click **Ingest to Railengine**. 2. (Optional breadth) On **Ingest**, use sidebar **Seed all fixtures** to ingest every `fixtures/tickets/*.json`. -3. On **Ingest**, click **Run triage** on a single ticket, or open **Triage Agent** → **Load queue** → **Triage queue** to prioritize all **open** and **pending** tickets. +3. On **Ingest**, click **Run triage** on a single ticket, or open **Agent** → **Load queue** → **Triage all** for open and pending tickets. 4. Switch to **Dashboard**, click **Refresh board**, and browse the Kanban. Click a **card subject** to open ticket details in a dialog; change status from the card **dropdown** (**requires `ENGINE_TOKEN`** alongside list credentials). ## Debug and visualize the triage agent (optional) -After you run triage once (**Ingest** or **Triage Agent**), inspect agent runs in the Railtracks UI: +After you run triage once (**Ingest** or **Agent**), inspect agent runs in the Railtracks UI: ```bash cd Python/customer-support -pip install 'railtracks[visual]' railtracks update railtracks viz ``` +(`railtracks[visual]` is included in project dependencies; run `uv sync` if you have not already.) + Opens the local visualization app so you can debug tool calls, prompts, and structured output from the support triage flow. ## Environment variables @@ -63,7 +64,7 @@ If your engine masks sensitive fields after ingest, compare raw fixtures to stor - `src/services/` — ingest, list, triage use cases - `src/controllers/` — optional webhook receiver - `src/agents/` — Railtracks agent + tools -- `src/pages/` — Streamlit Dashboard, Ingest, and Triage Agent +- `src/pages/` — Streamlit Dashboard, Ingest, and Agent - [`src/streamlit_app.py`](src/streamlit_app.py) — navigation entry (importable as `customer_support.streamlit_app`) ## Local only diff --git a/Python/customer-support/assets/logo-railengine.png b/Python/customer-support/assets/logo-railengine.png new file mode 100644 index 0000000000000000000000000000000000000000..773b30cbdaac4eadb9ea0c0889cc847b00f9ad0e GIT binary patch literal 3213 zcmV;8407{{P)=OCyUF5lNSA5Rno@iikDVS}Da^ z5eY%sl7?bMf=DY8L_{bd6bTZ71Sug%5R*z9f{3Icwbquj4M~ZTw8j=2tue)<60F(y zE9>@;^RDmQdGqt$`}Vzs`-OvV&&)mN%*>rRGiPS5P@}If4QRA}-%-D7)TjaZ9#W$Z zFs=1(gZ7Yh$H-&jWqgb@A1~vZFF;vd5nK9+r}=f~=r`0(y+#cmYNy_3sGWN6d758$ zj($V!)cXw6!ddzXP2gVOK42~|3%CauY^C1-t^ywc9{~RX&I6xMwxLB~`jCFZbYMB~ zO<*aou*FNkXTT}oPrwP__bpyu!!}ekENPmjDTQb9P2K>o9(V~D112h_>%bA<7CimU?1}4PF6#hbQ^dc z80}FV*m}^UV*uCzT%&glVbV?DN#F}zEO!(pyJZ#wf1~#e;G&Db>aLYO0=5R}h7V~1 zyO1HG*OeYa&(NMy+lG}Yco@9g4_D>ec&QCC19-(!=zga+ku^_QXC2G)W~_=vfHQqi zxkc#`aDNx;XHuQ|{8oWdE8%Tm6R@+uvo^X7tN=D5Z++_Xp8!j$tr|DF)iU#e|BzPj z*MU{arY7)Wq70M9fvw7}1`+{yEm6)@;HyT`h$#25AgvO;$`!==DBj z4I)v3@ko2mB9oM|4&27`W-LmvL(ElRT|pf$Ba2EK?Jg*D1@WPqBNf(HM-MW)P8n>B zEYSPFf<)O({I+Iq`wUDd2^GK3iRcbacG< zWt{pD;gcb@8BP(J0qiF}ypc^Lo<*jxRoE~up#K_jOsQv1{lP$m{IQ^16N^X-RGM^= z>O$v70=m{!p`>Vo90(Zqa0Rxlq?+?t*O4f3S%f0Z&y^AGJGq$gbeiUYthz@WvTp!i zBtMSZPI>!y3z1$nz9yYtTvnnzMLS>dLq8V zu|)9Z;D1w&Vjmx4c|%A=+hm`^A0=LiJM3{0@kc4Pn7n5LWuKcUPxA^p_0<8Rwj|0k zX|F@>aXR-RY%~ZQcF5RY(zf-1vKQS_=;$FS*d8z{HNvVf2MK$aGT)(d_?m-Mz*FSj~nt1M%ox_ANaQ#PA16H zD78`__N0B_N%J*6$dL3EOP-@D-*RLP3$@O%f7{~KY5i#O`X_pbW=U61Yz-%-E+?#MG5u*Jvw$fH}&;$r0i62;W(ij-Sa zO;6fqiLx|YH>7STnKK-DG=^3F8;(2!6u0xLCvAFBxka3L2A{$**W+B3H(UKa>&T<= z5B1x%h;!+RfiK8exL(UNh_5huh7}qXO`fageNE+iA)xmi0f6LBlFvth@}aqng>BK!6XCP`9fm{uFpm{ry8nKAW!CasM8NOn^@l9AwTwF;g=>aK`hj$7j6#nm{d;|Ek zMYhiGo2i7byhR`j(K|>bm3hfb|hch1yx%kz8+ zW+H2ivW)?q(`lPbU!>p`?RtrwvE`h(o;u!1Bz>C1#0WC2^gTc3dA?W3A_6WN!oL;# z7uu1EWGeecg=Q0>BSKRV8}M%}yi$ddA`O$R9efvD#;}j;4dhnq3D2{=qSHvU+2w@a z2|T~R^K8(mn?KA4e7uZ8XKnWw${U2Ye|t^i$ZX9lZ|Kxy>Qy=$&zmuZ76yzQL$+4+ zGf(zGrH4+m?=@_067C&a3)W#r4=UqJ8pu^uZNbO&{^>f~4n~r#eN?T;4>El1cbWU|QsC3uiJRiLnTPdk1>(n)`iTH}x zLOr3L9i-FA>NmMT=hubwcdSN@v@3><$Bgu|w08>!N&D>J`{YCaX1(;>sq4Q-h}!|Y zNdBWU4J07>7*csxk+n#sV}V76{@UBHB|STI*Py+fdm4EW78|;kMc6!nOc)jg>Yy*2 zx~5fxSHlklWHiZN5!+;uq0V5>k)#}LQ6 z82`b$3=L$6+JcGi_#cUnY)m;+x9)(Om7qEWjl~- z166(0|5(8~h~{=BeX%;3TeWV`ea$guM%umU+;v5fA0F8uqzFS{iBF1q1E zV!jWu2XW}zk=1&Ub5v#NV`O_^D>7j?6p=q>qEHImXpqo~{T!Wp(@pZTsBZk%6z-y;I1$buKRN&Q4eJY@Ch(1Bx5PgQ)srQ~& zJM})}B{}t~c{L(YS6rW=0.2.1", "rail-engine-ingest>=0.2.1", - "railtracks>=1.3.0", + "railtracks[visual]>=1.3.0", "pydantic>=2.0", "python-dotenv>=1.0.0", "streamlit>=1.57.0", diff --git a/Python/customer-support/src/pages/agent_page.py b/Python/customer-support/src/pages/agent_page.py new file mode 100644 index 0000000..0e539de --- /dev/null +++ b/Python/customer-support/src/pages/agent_page.py @@ -0,0 +1,243 @@ +"""Streamlit page: Customer Support Agent (chat + structured triage).""" + +from __future__ import annotations + +import asyncio +import traceback + +import streamlit as st + +from customer_support.models import SupportTicket, TriageAssessment +from customer_support.repositories import TicketRepository +from customer_support.services.ticket_list_service import TicketListService +from customer_support.services.triage_service import TriageService +from customer_support.streamlit_common import ( + PRIORITY_ORDER, + TICKET_ID_PATTERN, + env_ok, + render_chat_message_with_ticket_links, + render_page_brand, + render_ticket_subject_button, + render_triage_assessment, +) + +_QUEUE_KEY = "agent_queue" +_RESULTS_KEY = "agent_results" +_CHAT_KEY = "agent_chat" +_TICKET_CACHE_KEY = "agent_ticket_cache" + +# (button label, prompt sent to the agent) +_EXAMPLE_CHAT_PROMPTS: tuple[tuple[str, str], ...] = ( + ( + "Which ticket first?", + "Which open or pending ticket should we handle first, and why?", + ), + ( + "Similar billing issues", + "Search for similar resolved tickets related to billing portal or invoice errors.", + ), + ( + "Summarize the queue", + "Summarize all open and pending tickets in the queue by customer impact and urgency.", + ), + ( + "Highest-impact next steps", + "What are the recommended next steps for the highest-impact open ticket?", + ), +) + + +def _ticket_lookup_base() -> dict[str, SupportTicket]: + """Queue plus any tickets resolved from prior chat replies.""" + lookup: dict[str, SupportTicket] = dict( + st.session_state.get(_TICKET_CACHE_KEY, {}) + ) + for ticket in st.session_state.get(_QUEUE_KEY, []): + lookup[ticket.id] = ticket + return lookup + + +async def _resolve_tickets_mentioned_in_text( + content: str, *, list_ready: bool +) -> dict[str, SupportTicket]: + lookup = _ticket_lookup_base() + for tid in dict.fromkeys(TICKET_ID_PATTERN.findall(content)): + if tid in lookup or not list_ready: + continue + repo = TicketRepository() + for ticket in await repo.query_jsonpath_tickets(f"$.id:{tid}"): + if ticket.id == tid: + lookup[tid] = ticket + break + st.session_state[_TICKET_CACHE_KEY] = lookup + return lookup + + +def _submit_chat_turn( + prompt: str, queue_snapshot: list[SupportTicket] | None +) -> None: + st.session_state[_CHAT_KEY].append({"role": "user", "content": prompt}) + try: + with st.spinner("Agent thinking…"): + reply = asyncio.run( + TriageService().chat( + st.session_state[_CHAT_KEY], + queue=queue_snapshot or None, + ) + ) + st.session_state[_CHAT_KEY].append({"role": "assistant", "content": reply}) + except Exception: + st.session_state[_CHAT_KEY].pop() + st.error(traceback.format_exc()) + else: + st.rerun() + + +render_page_brand() + +st.title("Customer Support Agent") +st.caption( + "Ask questions about your customer support tickets (queue, priorities, similar resolved " + "cases) or **Load queue** and **Triage all** to run structured triage on open and pending tickets." +) + +env = env_ok() +list_ready = env["ENGINE_PAT"] and env["ENGINE_ID"] +triage_ready = list_ready and env["OPENAI_API_KEY"] + +if _QUEUE_KEY not in st.session_state: + st.session_state[_QUEUE_KEY] = [] +if _RESULTS_KEY not in st.session_state: + st.session_state[_RESULTS_KEY] = {} +if _CHAT_KEY not in st.session_state: + st.session_state[_CHAT_KEY] = [] + +if not list_ready: + st.warning("Set **ENGINE_PAT** and **ENGINE_ID** to load the triage queue.") +if not env["OPENAI_API_KEY"]: + st.warning("Set **OPENAI_API_KEY** to run the triage agent.") + +chat_col, triage_col = st.columns([1, 1], gap="large") + +with chat_col: + st.subheader("Chat with agent") + st.caption("Ask about the queue, priorities, or similar resolved tickets.") + + if st.button("Clear chat", type="secondary"): + st.session_state[_CHAT_KEY] = [] + st.rerun() + + queue_snapshot: list[SupportTicket] = st.session_state[_QUEUE_KEY] + + for i, msg in enumerate(st.session_state[_CHAT_KEY]): + with st.chat_message(msg["role"]): + if msg["role"] == "assistant": + tickets_by_id = asyncio.run( + _resolve_tickets_mentioned_in_text( + msg["content"], list_ready=list_ready + ) + ) + render_chat_message_with_ticket_links( + msg["content"], + tickets_by_id, + message_index=i, + ) + else: + st.markdown(msg["content"]) + + st.caption("Example questions") + ex_cols = st.columns(2) + for idx, (label, example_prompt) in enumerate(_EXAMPLE_CHAT_PROMPTS): + with ex_cols[idx % 2]: + if st.button( + label, + key=f"chat_example:{idx}", + disabled=not triage_ready, + use_container_width=True, + ): + _submit_chat_turn(example_prompt, queue_snapshot) + + if prompt := st.chat_input( + "Ask about your support tickets…", + disabled=not triage_ready, + ): + _submit_chat_turn(prompt, queue_snapshot) + +with triage_col: + toolbar = st.columns([2, 2]) + load_queue = toolbar[0].button("Load queue") + + if load_queue and list_ready: + try: + with st.spinner("Loading open and pending tickets…"): + st.session_state[_QUEUE_KEY] = asyncio.run( + TicketListService().fetch_open_and_pending() + ) + except Exception: + st.error(traceback.format_exc()) + else: + n = len(st.session_state[_QUEUE_KEY]) + st.success(f"Queue loaded: **{n}** ticket(s) (open + pending).") + + queue_loaded = len(st.session_state[_QUEUE_KEY]) > 0 + triage_all = toolbar[1].button( + "Triage all", + disabled=not triage_ready or not queue_loaded, + help="Run structured triage on every ticket in the loaded queue.", + ) + + queue: list[SupportTicket] = st.session_state[_QUEUE_KEY] + results: dict[str, TriageAssessment] = st.session_state[_RESULTS_KEY] + + if triage_all and triage_ready and queue: + try: + progress = st.progress(0.0, text="Running triage agent…") + + async def _triage_all() -> dict[str, TriageAssessment]: + svc = TriageService() + batch: dict[str, TriageAssessment] = {} + total = len(queue) + for i, ticket in enumerate(queue, start=1): + progress.progress( + i / total, text=f"Triage {i}/{total}: {ticket.id}" + ) + batch[ticket.id] = await svc.run(ticket) + return batch + + batch = asyncio.run(_triage_all()) + st.session_state[_RESULTS_KEY] = batch + results = batch + progress.empty() + st.success(f"Triage complete for **{len(batch)}** ticket(s).") + except Exception: + st.error(traceback.format_exc()) + + if queue: + st.subheader("Queue") + st.caption(f"{len(queue)} ticket(s) (open or pending).") + for ticket in queue: + render_ticket_subject_button( + ticket, key=f"queue_view:{ticket.id}", left_align=True + ) + st.caption(f"`{ticket.id}` · **{ticket.status}** · {ticket.productArea}") + else: + st.info("Click **Load queue** to fetch open and pending tickets from storage.") + + if results: + st.subheader("Triage results") + st.caption("Sorted by priority (P1 first).") + + ticket_by_id = {t.id: t for t in queue} + sorted_ids = sorted( + results.keys(), + key=lambda tid: ( + PRIORITY_ORDER.get(results[tid].priority, 99), + tid, + ), + ) + + for tid in sorted_ids: + ticket = ticket_by_id.get(tid) + if ticket is None: + continue + render_triage_assessment(ticket, results[tid]) diff --git a/Python/customer-support/src/pages/dashboard.py b/Python/customer-support/src/pages/dashboard.py index 68b5383..b37e304 100644 --- a/Python/customer-support/src/pages/dashboard.py +++ b/Python/customer-support/src/pages/dashboard.py @@ -13,19 +13,19 @@ from customer_support.streamlit_common import ( env_ok, group_tickets_by_status, - render_env_metrics, render_kanban_ticket_card, + render_page_brand, ) _KANBAN_SESSION_KEY = "kanban_tickets" +render_page_brand() + st.title("Dashboard") st.caption( "Kanban sourced from **`list_storage_documents`** · moves persist via **ingest upsert**." ) -render_env_metrics() - env = env_ok() list_ready = env["ENGINE_PAT"] and env["ENGINE_ID"] ingest_ready = env["ENGINE_TOKEN"] diff --git a/Python/customer-support/src/pages/ingest_page.py b/Python/customer-support/src/pages/ingest_page.py index fa6c67c..98a40e0 100644 --- a/Python/customer-support/src/pages/ingest_page.py +++ b/Python/customer-support/src/pages/ingest_page.py @@ -16,10 +16,11 @@ FIXTURES_DIR, env_ok, fixture_paths, - render_env_metrics, + render_page_brand, render_pipeline_stages, ) +render_page_brand() st.title("Ingest") st.caption( @@ -28,8 +29,6 @@ render_pipeline_stages() -render_env_metrics() - status = env_ok() if "ticket_editor" not in st.session_state: diff --git a/Python/customer-support/src/pages/triage_agent_page.py b/Python/customer-support/src/pages/triage_agent_page.py deleted file mode 100644 index bc0f285..0000000 --- a/Python/customer-support/src/pages/triage_agent_page.py +++ /dev/null @@ -1,173 +0,0 @@ -"""Streamlit page: triage open and pending tickets with the Railtracks agent.""" - -from __future__ import annotations - -import asyncio -import traceback - -import streamlit as st - -from customer_support.models import SupportTicket, TriageAssessment -from customer_support.services.ticket_list_service import TicketListService -from customer_support.services.triage_service import TriageService -from customer_support.streamlit_common import ( - PRIORITY_ORDER, - env_ok, - render_env_metrics, - render_triage_assessment, -) - -_QUEUE_KEY = "triage_agent_queue" -_RESULTS_KEY = "triage_agent_results" -_CHAT_KEY = "triage_agent_chat" - -st.title("Triage Agent") -st.caption( - "Chat with the triage lead on the left; load and run structured triage on **open** and **pending** " - "tickets on the right." -) - -render_env_metrics() - -env = env_ok() -list_ready = env["ENGINE_PAT"] and env["ENGINE_ID"] -triage_ready = list_ready and env["OPENAI_API_KEY"] - -if _QUEUE_KEY not in st.session_state: - st.session_state[_QUEUE_KEY] = [] -if _RESULTS_KEY not in st.session_state: - st.session_state[_RESULTS_KEY] = {} -if _CHAT_KEY not in st.session_state: - st.session_state[_CHAT_KEY] = [] - -if not list_ready: - st.warning("Set **ENGINE_PAT** and **ENGINE_ID** to load the triage queue.") -if not env["OPENAI_API_KEY"]: - st.warning("Set **OPENAI_API_KEY** to run the triage agent.") - -chat_col, triage_col = st.columns([1, 1], gap="large") - -with chat_col: - st.subheader("Chat with agent") - st.caption("Ask about the queue, priorities, or similar resolved tickets.") - - if st.button("Clear chat", type="secondary"): - st.session_state[_CHAT_KEY] = [] - st.rerun() - - for msg in st.session_state[_CHAT_KEY]: - with st.chat_message(msg["role"]): - st.markdown(msg["content"]) - - queue_snapshot: list[SupportTicket] = st.session_state[_QUEUE_KEY] - if prompt := st.chat_input( - "Ask the triage agent…", - disabled=not triage_ready, - ): - st.session_state[_CHAT_KEY].append({"role": "user", "content": prompt}) - try: - with st.spinner("Agent thinking…"): - reply = asyncio.run( - TriageService().chat( - st.session_state[_CHAT_KEY], - queue=queue_snapshot or None, - ) - ) - st.session_state[_CHAT_KEY].append({"role": "assistant", "content": reply}) - except Exception: - st.session_state[_CHAT_KEY].pop() - st.error(traceback.format_exc()) - else: - st.rerun() - -with triage_col: - toolbar = st.columns([2, 2]) - load_queue = toolbar[0].button("Load queue") - triage_all = toolbar[1].button( - "Triage queue", - disabled=not triage_ready or len(st.session_state[_QUEUE_KEY]) == 0, - ) - - if load_queue and list_ready: - try: - with st.spinner("Loading open and pending tickets…"): - st.session_state[_QUEUE_KEY] = asyncio.run( - TicketListService().fetch_open_and_pending() - ) - except Exception: - st.error(traceback.format_exc()) - else: - n = len(st.session_state[_QUEUE_KEY]) - st.success(f"Queue loaded: **{n}** ticket(s) (open + pending).") - - queue: list[SupportTicket] = st.session_state[_QUEUE_KEY] - results: dict[str, TriageAssessment] = st.session_state[_RESULTS_KEY] - - if triage_all and triage_ready and queue: - try: - progress = st.progress(0.0, text="Running triage agent…") - - async def _triage_queue() -> dict[str, TriageAssessment]: - svc = TriageService() - batch: dict[str, TriageAssessment] = {} - total = len(queue) - for i, ticket in enumerate(queue, start=1): - progress.progress( - i / total, text=f"Triage {i}/{total}: {ticket.id}" - ) - batch[ticket.id] = await svc.run(ticket) - return batch - - batch = asyncio.run(_triage_queue()) - st.session_state[_RESULTS_KEY] = batch - results = batch - progress.empty() - st.success(f"Triage complete for **{len(batch)}** ticket(s).") - except Exception: - st.error(traceback.format_exc()) - - if queue: - st.subheader("Queue") - st.caption(f"{len(queue)} ticket(s) awaiting triage (open or pending).") - for ticket in queue: - cols = st.columns([5, 1]) - with cols[0]: - st.markdown(f"**{ticket.subject}**") - st.caption( - f"`{ticket.id}` · **{ticket.status}** · {ticket.productArea}" - ) - with cols[1]: - if st.button( - "Triage", - key=f"triage_one:{ticket.id}", - disabled=not triage_ready, - use_container_width=True, - ): - try: - with st.spinner(f"Triage {ticket.id}…"): - assessment = asyncio.run(TriageService().run(ticket)) - st.session_state[_RESULTS_KEY][ticket.id] = assessment - st.rerun() - except Exception: - st.error(traceback.format_exc()) - else: - st.info("Click **Load queue** to fetch open and pending tickets from storage.") - - if results: - st.subheader("Triage results") - st.caption("Sorted by priority (P1 first).") - - ticket_by_id = {t.id: t for t in queue} - sorted_ids = sorted( - results.keys(), - key=lambda tid: ( - PRIORITY_ORDER.get(results[tid].priority, 99), - tid, - ), - ) - - for tid in sorted_ids: - ticket = ticket_by_id.get(tid) - if ticket is None: - continue - render_triage_assessment(ticket, results[tid]) diff --git a/Python/customer-support/src/streamlit_app.py b/Python/customer-support/src/streamlit_app.py index 14af671..c5c939d 100644 --- a/Python/customer-support/src/streamlit_app.py +++ b/Python/customer-support/src/streamlit_app.py @@ -1,22 +1,23 @@ -"""Streamlit multipage entry: Dashboard, Ingest, Triage Agent.""" +"""Streamlit multipage entry: Dashboard, Ingest, Agent.""" from __future__ import annotations import streamlit as st from customer_support.config.env import ensure_dotenv_loaded -from customer_support.streamlit_common import use_full_width_layout +from customer_support.streamlit_common import render_config_button, use_full_width_layout def main() -> None: ensure_dotenv_loaded() st.set_page_config(page_title="Support Triage", layout="wide") use_full_width_layout() + render_config_button() pg = st.navigation( [ st.Page("pages/dashboard.py", title="Dashboard", icon="🗂️", default=True), st.Page("pages/ingest_page.py", title="Ingest", icon="📥"), - st.Page("pages/triage_agent_page.py", title="Triage Agent", icon="🤖"), + st.Page("pages/agent_page.py", title="Agent", icon="🤖"), ] ) pg.run() diff --git a/Python/customer-support/src/streamlit_common.py b/Python/customer-support/src/streamlit_common.py index 90cbe72..9881fe0 100644 --- a/Python/customer-support/src/streamlit_common.py +++ b/Python/customer-support/src/streamlit_common.py @@ -2,7 +2,9 @@ from __future__ import annotations +import base64 import os +import re from pathlib import Path import streamlit as st @@ -17,9 +19,12 @@ PROJECT_ROOT = Path(__file__).resolve().parents[1] FIXTURES_DIR = PROJECT_ROOT / "fixtures" / "tickets" +BRAND_LOGO_PATH = PROJECT_ROOT / "assets" / "logo-railengine.png" PRIORITY_ORDER = {"p1": 0, "p2": 1, "p3": 2, "p4": 3} +TICKET_ID_PATTERN = re.compile(r"\b(ticket-[a-zA-Z0-9-]+)\b") + def env_ok() -> dict[str, bool]: return { @@ -144,6 +149,19 @@ def use_full_width_layout() -> None: st.markdown(_FULL_WIDTH_CSS, unsafe_allow_html=True) +def render_page_brand() -> None: + """Clickable Railengine logo at the top of the main content area.""" + if not BRAND_LOGO_PATH.is_file(): + return + b64 = base64.b64encode(BRAND_LOGO_PATH.read_bytes()).decode() + st.markdown( + f'' + f'Railengine', + unsafe_allow_html=True, + ) + + def render_pipeline_stages() -> None: """Railengine pipeline overview (HTML; works without Mermaid support).""" st.markdown("#### Pipeline stages") @@ -154,13 +172,22 @@ def render_pipeline_stages() -> None: ) -def render_env_metrics(*, expanded: bool = False) -> None: - """Env flags in a collapsed expander (values never shown).""" - with st.expander("Environment variables", expanded=expanded): - status = env_ok() - cols = st.columns(4) - for i, (key, ok) in enumerate(status.items()): - cols[i].metric(label=key, value="set" if ok else "missing") +@st.dialog("Config") +def show_env_config_dialog() -> None: + """Modal: which env vars are set (values never shown).""" + st.caption("Loaded from `.env` next to `pyproject.toml`. Secret values are not displayed.") + status = env_ok() + cols = st.columns(4) + for i, (key, ok) in enumerate(status.items()): + cols[i].metric(label=key, value="set" if ok else "missing") + + +def render_config_button() -> None: + """Top-right **Config** button opens the env status dialog.""" + _, btn_col = st.columns([11, 1]) + with btn_col: + if st.button("Config", type="secondary", use_container_width=True): + show_env_config_dialog() def group_tickets_by_status( @@ -213,6 +240,63 @@ def show_ticket_details_dialog(ticket: SupportTicket) -> None: st.json(ticket.model_dump()) +_SUBJECT_BUTTON_LEFT_ALIGN_CSS = """ + +""" + + +def render_chat_message_with_ticket_links( + content: str, + tickets_by_id: dict[str, SupportTicket], + *, + message_index: int, +) -> None: + """Render assistant text; link ticket ids mentioned in the response.""" + st.markdown(content) + mentioned = list(dict.fromkeys(TICKET_ID_PATTERN.findall(content))) + linked = [tid for tid in mentioned if tid in tickets_by_id] + if not linked: + return + st.caption("Tickets in this reply (click subject for details):") + for tid in linked: + render_ticket_subject_button( + tickets_by_id[tid], + key=f"chat_msg:{message_index}:{tid}", + left_align=True, + ) + + +def render_ticket_subject_button( + ticket: SupportTicket, *, key: str, left_align: bool = False +) -> None: + """Tertiary button on the ticket subject; opens ``show_ticket_details_dialog``.""" + if left_align and not st.session_state.get("_subject_button_left_align_css"): + st.session_state["_subject_button_left_align_css"] = True + st.markdown(_SUBJECT_BUTTON_LEFT_ALIGN_CSS, unsafe_allow_html=True) + + subj = ticket.subject[:100] + ("…" if len(ticket.subject) > 100 else "") + if st.button( + subj, + key=key, + use_container_width=True, + type="tertiary", + ): + show_ticket_details_dialog(ticket) + + def render_kanban_ticket_card( ticket: SupportTicket, *, @@ -227,14 +311,7 @@ def render_kanban_ticket_card( index = next(i for i, (_, s) in enumerate(KANBAN_COLUMNS) if s == ticket.status) with st.container(border=True): - subj = ticket.subject[:100] + ("…" if len(ticket.subject) > 100 else "") - if st.button( - subj, - key=f"view:{ticket.id}", - use_container_width=True, - type="tertiary", - ): - show_ticket_details_dialog(ticket) + render_ticket_subject_button(ticket, key=f"view:{ticket.id}") tags = ", ".join(ticket.tags[:6]) st.caption(f"`{ticket.id}` · _{ticket.productArea}_ · {ticket.createdAt[:10]}") if tags: @@ -259,7 +336,9 @@ def render_triage_assessment( ) -> None: """Display structured triage output for a single ticket.""" with st.container(border=True): - st.markdown(f"**{ticket.subject}**") + render_ticket_subject_button( + ticket, key=f"triage_result_view:{ticket.id}", left_align=True + ) st.caption(f"`{ticket.id}` · queue status: **{ticket.status}**") m1, m2 = st.columns(2) m1.metric("Priority", assessment.priority.upper()) From b1f99a390d63ab5e7cd9253a1d72d32740c33929 Mon Sep 17 00:00:00 2001 From: Jaime Bueza Date: Mon, 25 May 2026 11:33:00 -0700 Subject: [PATCH 34/42] Refactor Streamlit application by removing the render_config_button function, introducing a new render_app_toolbar function for improved UI consistency, and updating button labels with icons for better user experience. This enhances the maintainability and usability of the customer support module. (#5895) Co-authored-by: Claude Opus 4.7 (1M context) --- .../customer-support/src/pages/agent_page.py | 24 ++++++------- .../customer-support/src/pages/dashboard.py | 21 ++++++------ .../customer-support/src/pages/ingest_page.py | 12 ++++--- Python/customer-support/src/streamlit_app.py | 3 +- .../customer-support/src/streamlit_common.py | 34 ++++++++++++++----- 5 files changed, 54 insertions(+), 40 deletions(-) diff --git a/Python/customer-support/src/pages/agent_page.py b/Python/customer-support/src/pages/agent_page.py index 0e539de..9faadfb 100644 --- a/Python/customer-support/src/pages/agent_page.py +++ b/Python/customer-support/src/pages/agent_page.py @@ -15,6 +15,7 @@ PRIORITY_ORDER, TICKET_ID_PATTERN, env_ok, + render_app_toolbar, render_chat_message_with_ticket_links, render_page_brand, render_ticket_subject_button, @@ -29,19 +30,19 @@ # (button label, prompt sent to the agent) _EXAMPLE_CHAT_PROMPTS: tuple[tuple[str, str], ...] = ( ( - "Which ticket first?", + "🎯 Which ticket first?", "Which open or pending ticket should we handle first, and why?", ), ( - "Similar billing issues", + "💳 Similar billing issues", "Search for similar resolved tickets related to billing portal or invoice errors.", ), ( - "Summarize the queue", + "📋 Summarize the queue", "Summarize all open and pending tickets in the queue by customer impact and urgency.", ), ( - "Highest-impact next steps", + "⚡ Highest-impact next steps", "What are the recommended next steps for the highest-impact open ticket?", ), ) @@ -49,9 +50,7 @@ def _ticket_lookup_base() -> dict[str, SupportTicket]: """Queue plus any tickets resolved from prior chat replies.""" - lookup: dict[str, SupportTicket] = dict( - st.session_state.get(_TICKET_CACHE_KEY, {}) - ) + lookup: dict[str, SupportTicket] = dict(st.session_state.get(_TICKET_CACHE_KEY, {})) for ticket in st.session_state.get(_QUEUE_KEY, []): lookup[ticket.id] = ticket return lookup @@ -73,9 +72,7 @@ async def _resolve_tickets_mentioned_in_text( return lookup -def _submit_chat_turn( - prompt: str, queue_snapshot: list[SupportTicket] | None -) -> None: +def _submit_chat_turn(prompt: str, queue_snapshot: list[SupportTicket] | None) -> None: st.session_state[_CHAT_KEY].append({"role": "user", "content": prompt}) try: with st.spinner("Agent thinking…"): @@ -94,6 +91,7 @@ def _submit_chat_turn( render_page_brand() +render_app_toolbar() st.title("Customer Support Agent") st.caption( @@ -123,7 +121,7 @@ def _submit_chat_turn( st.subheader("Chat with agent") st.caption("Ask about the queue, priorities, or similar resolved tickets.") - if st.button("Clear chat", type="secondary"): + if st.button("🗑️ Clear chat", type="secondary"): st.session_state[_CHAT_KEY] = [] st.rerun() @@ -165,7 +163,7 @@ def _submit_chat_turn( with triage_col: toolbar = st.columns([2, 2]) - load_queue = toolbar[0].button("Load queue") + load_queue = toolbar[0].button("📋 Load queue") if load_queue and list_ready: try: @@ -181,7 +179,7 @@ def _submit_chat_turn( queue_loaded = len(st.session_state[_QUEUE_KEY]) > 0 triage_all = toolbar[1].button( - "Triage all", + "🤖 Triage all", disabled=not triage_ready or not queue_loaded, help="Run structured triage on every ticket in the loaded queue.", ) diff --git a/Python/customer-support/src/pages/dashboard.py b/Python/customer-support/src/pages/dashboard.py index b37e304..6a7f673 100644 --- a/Python/customer-support/src/pages/dashboard.py +++ b/Python/customer-support/src/pages/dashboard.py @@ -13,19 +13,18 @@ from customer_support.streamlit_common import ( env_ok, group_tickets_by_status, + render_app_toolbar, render_kanban_ticket_card, render_page_brand, ) _KANBAN_SESSION_KEY = "kanban_tickets" +_KANBAN_INITIAL_LOAD_DONE = "kanban_initial_load_done" render_page_brand() +refresh = render_app_toolbar(show_refresh_board=True) st.title("Dashboard") -st.caption( - "Kanban sourced from **`list_storage_documents`** · moves persist via **ingest upsert**." -) - env = env_ok() list_ready = env["ENGINE_PAT"] and env["ENGINE_ID"] ingest_ready = env["ENGINE_TOKEN"] @@ -43,19 +42,21 @@ "Set **ENGINE_TOKEN** to change status from card dropdowns (updates use ingest upsert)." ) -toolbar = st.columns([2, 6]) -refresh = toolbar[0].button("Refresh board") - -if refresh and list_ready: +should_load = list_ready and ( + refresh or not st.session_state.get(_KANBAN_INITIAL_LOAD_DONE) +) +if should_load: try: with st.spinner("Loading tickets from storage…"): st.session_state[_KANBAN_SESSION_KEY] = asyncio.run( TicketListService().fetch_all() ) + st.session_state[_KANBAN_INITIAL_LOAD_DONE] = True except Exception: st.error(traceback.format_exc()) else: - st.success(f"Loaded **{len(st.session_state[_KANBAN_SESSION_KEY])}** tickets.") + if refresh: + st.success(f"Loaded **{len(st.session_state[_KANBAN_SESSION_KEY])}** tickets.") tickets: list[SupportTicket] = st.session_state[_KANBAN_SESSION_KEY] buckets = group_tickets_by_status(tickets) @@ -111,5 +112,3 @@ for t in tickets ] st.dataframe(tf, hide_index=True, use_container_width=True) -elif list_ready: - st.info("Click **Refresh board** to load tickets.") diff --git a/Python/customer-support/src/pages/ingest_page.py b/Python/customer-support/src/pages/ingest_page.py index 98a40e0..8f4090d 100644 --- a/Python/customer-support/src/pages/ingest_page.py +++ b/Python/customer-support/src/pages/ingest_page.py @@ -16,11 +16,13 @@ FIXTURES_DIR, env_ok, fixture_paths, + render_app_toolbar, render_page_brand, render_pipeline_stages, ) render_page_brand() +render_app_toolbar() st.title("Ingest") st.caption( @@ -41,12 +43,12 @@ if fixture_names: pick = sidebar.selectbox("Pick fixture file", fixture_names) - if sidebar.button("Load into editor", type="secondary"): + if sidebar.button("📄 Load into editor", type="secondary"): text = (FIXTURES_DIR / pick).read_text(encoding="utf-8") st.session_state.ticket_editor = text st.rerun() if sidebar.button( - "Seed all fixtures", + "🌱 Seed all fixtures", type="secondary", disabled=not status["ENGINE_TOKEN"], help="Ingest every `fixtures/tickets/*.json` (requires ENGINE_TOKEN).", @@ -90,7 +92,7 @@ with c1: do_ingest = st.button( - "Ingest to Railengine", + "📥 Ingest to Railengine", disabled=not (ticket and ingest_ready), help="Requires ENGINE_TOKEN.", ) @@ -101,7 +103,7 @@ with c2: do_triage = st.button( - "Run triage", + "🤖 Run triage", disabled=not triage_ready, help="Requires ENGINE_PAT, ENGINE_ID, and OPENAI_API_KEY.", ) @@ -142,7 +144,7 @@ raw = assessment.model_dump() st.download_button( - label="Download TriageAssessment JSON", + label="⬇️ Download TriageAssessment JSON", file_name=f"triage-{ticket.id}.json", mime="application/json", data=json.dumps(raw, indent=2, ensure_ascii=False).encode("utf-8"), diff --git a/Python/customer-support/src/streamlit_app.py b/Python/customer-support/src/streamlit_app.py index c5c939d..1676f93 100644 --- a/Python/customer-support/src/streamlit_app.py +++ b/Python/customer-support/src/streamlit_app.py @@ -5,14 +5,13 @@ import streamlit as st from customer_support.config.env import ensure_dotenv_loaded -from customer_support.streamlit_common import render_config_button, use_full_width_layout +from customer_support.streamlit_common import use_full_width_layout def main() -> None: ensure_dotenv_loaded() st.set_page_config(page_title="Support Triage", layout="wide") use_full_width_layout() - render_config_button() pg = st.navigation( [ st.Page("pages/dashboard.py", title="Dashboard", icon="🗂️", default=True), diff --git a/Python/customer-support/src/streamlit_common.py b/Python/customer-support/src/streamlit_common.py index 9881fe0..e123386 100644 --- a/Python/customer-support/src/streamlit_common.py +++ b/Python/customer-support/src/streamlit_common.py @@ -155,9 +155,10 @@ def render_page_brand() -> None: return b64 = base64.b64encode(BRAND_LOGO_PATH.read_bytes()).decode() st.markdown( - f'' + f'' f'Railengine', + f'style="display:inline-block;vertical-align:middle;"/>', unsafe_allow_html=True, ) @@ -175,20 +176,35 @@ def render_pipeline_stages() -> None: @st.dialog("Config") def show_env_config_dialog() -> None: """Modal: which env vars are set (values never shown).""" - st.caption("Loaded from `.env` next to `pyproject.toml`. Secret values are not displayed.") + st.caption( + "Loaded from `.env` next to `pyproject.toml`. Secret values are not displayed." + ) status = env_ok() cols = st.columns(4) for i, (key, ok) in enumerate(status.items()): cols[i].metric(label=key, value="set" if ok else "missing") -def render_config_button() -> None: - """Top-right **Config** button opens the env status dialog.""" - _, btn_col = st.columns([11, 1]) - with btn_col: - if st.button("Config", type="secondary", use_container_width=True): +def render_app_toolbar(*, show_refresh_board: bool = False) -> bool: + """Top-right toolbar: optional **Refresh board** + **Config** (same row).""" + refresh_clicked = False + if show_refresh_board: + _, refresh_col, config_col = st.columns([9, 1, 1]) + with refresh_col: + refresh_clicked = st.button( + "🔄 Refresh", + type="secondary", + use_container_width=True, + ) + else: + _, config_col = st.columns([11, 1]) + + with config_col: + if st.button("⚙️ Config", type="secondary", use_container_width=True): show_env_config_dialog() + return refresh_clicked + def group_tickets_by_status( tickets: list[SupportTicket], @@ -289,7 +305,7 @@ def render_ticket_subject_button( subj = ticket.subject[:100] + ("…" if len(ticket.subject) > 100 else "") if st.button( - subj, + f"🎫 {subj}", key=key, use_container_width=True, type="tertiary", From 7319769ed78a94b394744e7233db3b01d99405f9 Mon Sep 17 00:00:00 2001 From: Jaime Bueza Date: Mon, 25 May 2026 11:37:23 -0700 Subject: [PATCH 35/42] Refactor Streamlit UI components by removing left alignment logic from the render_ticket_subject_button function and updating its usage in the agent page and triage assessment. This enhances code clarity and maintains consistent button styling across the application. (#5895) Co-authored-by: Claude Opus 4.7 (1M context) --- .../customer-support/src/pages/agent_page.py | 4 +- .../customer-support/src/streamlit_common.py | 59 +++++++++---------- 2 files changed, 30 insertions(+), 33 deletions(-) diff --git a/Python/customer-support/src/pages/agent_page.py b/Python/customer-support/src/pages/agent_page.py index 9faadfb..182f98d 100644 --- a/Python/customer-support/src/pages/agent_page.py +++ b/Python/customer-support/src/pages/agent_page.py @@ -214,9 +214,7 @@ async def _triage_all() -> dict[str, TriageAssessment]: st.subheader("Queue") st.caption(f"{len(queue)} ticket(s) (open or pending).") for ticket in queue: - render_ticket_subject_button( - ticket, key=f"queue_view:{ticket.id}", left_align=True - ) + render_ticket_subject_button(ticket, key=f"queue_view:{ticket.id}") st.caption(f"`{ticket.id}` · **{ticket.status}** · {ticket.productArea}") else: st.info("Click **Load queue** to fetch open and pending tickets from storage.") diff --git a/Python/customer-support/src/streamlit_common.py b/Python/customer-support/src/streamlit_common.py index e123386..9349e4f 100644 --- a/Python/customer-support/src/streamlit_common.py +++ b/Python/customer-support/src/streamlit_common.py @@ -140,6 +140,32 @@ def fixture_paths() -> list[Path]: div[data-testid="stHtml"] iframe { width: 100% !important; } + /* Ticket subject buttons (tertiary): full width, left-aligned label */ + section.main .element-container:has(button[data-testid="baseButton-tertiary"]) { + width: 100%; + align-self: stretch; + } + section.main div[data-testid="stButton"]:has(button[data-testid="baseButton-tertiary"]) { + width: 100%; + display: flex; + justify-content: flex-start; + align-self: stretch; + } + section.main button[data-testid="baseButton-tertiary"] { + width: 100% !important; + max-width: 100% !important; + text-align: left !important; + justify-content: flex-start !important; + padding-left: 0 !important; + } + section.main button[data-testid="baseButton-tertiary"] > div { + justify-content: flex-start !important; + width: 100%; + } + section.main button[data-testid="baseButton-tertiary"] p { + text-align: left !important; + width: 100%; + } """ @@ -256,24 +282,6 @@ def show_ticket_details_dialog(ticket: SupportTicket) -> None: st.json(ticket.model_dump()) -_SUBJECT_BUTTON_LEFT_ALIGN_CSS = """ - -""" - - def render_chat_message_with_ticket_links( content: str, tickets_by_id: dict[str, SupportTicket], @@ -291,23 +299,16 @@ def render_chat_message_with_ticket_links( render_ticket_subject_button( tickets_by_id[tid], key=f"chat_msg:{message_index}:{tid}", - left_align=True, ) -def render_ticket_subject_button( - ticket: SupportTicket, *, key: str, left_align: bool = False -) -> None: +def render_ticket_subject_button(ticket: SupportTicket, *, key: str) -> None: """Tertiary button on the ticket subject; opens ``show_ticket_details_dialog``.""" - if left_align and not st.session_state.get("_subject_button_left_align_css"): - st.session_state["_subject_button_left_align_css"] = True - st.markdown(_SUBJECT_BUTTON_LEFT_ALIGN_CSS, unsafe_allow_html=True) - subj = ticket.subject[:100] + ("…" if len(ticket.subject) > 100 else "") if st.button( f"🎫 {subj}", key=key, - use_container_width=True, + width="stretch", type="tertiary", ): show_ticket_details_dialog(ticket) @@ -352,9 +353,7 @@ def render_triage_assessment( ) -> None: """Display structured triage output for a single ticket.""" with st.container(border=True): - render_ticket_subject_button( - ticket, key=f"triage_result_view:{ticket.id}", left_align=True - ) + render_ticket_subject_button(ticket, key=f"triage_result_view:{ticket.id}") st.caption(f"`{ticket.id}` · queue status: **{ticket.status}**") m1, m2 = st.columns(2) m1.metric("Priority", assessment.priority.upper()) From ad935a48ac00301842bd825475d090411ceb112d Mon Sep 17 00:00:00 2001 From: Jaime Bueza Date: Mon, 25 May 2026 11:53:09 -0700 Subject: [PATCH 36/42] Implement search functionality in the customer support module by adding a new Search page and SearchService for querying the Railengine index. Update README for clearer instructions on using the search feature, and enhance existing pages to reflect the new functionality. This improves usability and maintainability of the application. (#5895) Co-authored-by: Claude Opus 4.7 (1M context) --- Python/customer-support/README.md | 9 +- .../customer-support/src/pages/dashboard.py | 15 -- .../customer-support/src/pages/ingest_page.py | 2 +- .../customer-support/src/pages/search_page.py | 155 ++++++++++++++++++ .../customer-support/src/services/__init__.py | 3 +- .../src/services/search_service.py | 17 ++ Python/customer-support/src/streamlit_app.py | 3 +- .../customer-support/src/streamlit_common.py | 2 +- 8 files changed, 183 insertions(+), 23 deletions(-) create mode 100644 Python/customer-support/src/pages/search_page.py create mode 100644 Python/customer-support/src/services/search_service.py diff --git a/Python/customer-support/README.md b/Python/customer-support/README.md index c40911b..6fe43e0 100644 --- a/Python/customer-support/README.md +++ b/Python/customer-support/README.md @@ -26,6 +26,7 @@ uv run streamlit run src/streamlit_app.py 2. (Optional breadth) On **Ingest**, use sidebar **Seed all fixtures** to ingest every `fixtures/tickets/*.json`. 3. On **Ingest**, click **Run triage** on a single ticket, or open **Agent** → **Load queue** → **Triage all** for open and pending tickets. 4. Switch to **Dashboard**, click **Refresh board**, and browse the Kanban. Click a **card subject** to open ticket details in a dialog; change status from the card **dropdown** (**requires `ENGINE_TOKEN`** alongside list credentials). +5. Open **Search**, enter keywords (e.g. `billing invoice`), and click **🔍 Search** to query the Railengine keyword index. Use the table and subject links for full ticket details. ## Debug and visualize the triage agent (optional) @@ -46,8 +47,8 @@ Opens the local visualization app so you can debug tool calls, prompts, and stru | Variable | Used for | Required when | |----------|-----------|---------------| | `ENGINE_TOKEN` | Ingest SDK | **Ingest** page · **Kanban status** dropdown | -| `ENGINE_PAT` | Retrieval / list | **Dashboard** / triage tools | -| `ENGINE_ID` | Engine routing | **Dashboard** / triage tools | +| `ENGINE_PAT` | Retrieval / list / search | **Dashboard**, **Search**, triage tools | +| `ENGINE_ID` | Engine routing | **Dashboard**, **Search**, triage tools | | `OPENAI_API_KEY` | Railtracks LLM | **Run triage** | A local `.env` next to [`pyproject.toml`](pyproject.toml) is loaded automatically for Streamlit and the webhook receiver. @@ -61,10 +62,10 @@ If your engine masks sensitive fields after ingest, compare raw fixtures to stor - `src/models/` — Pydantic shapes (`customer_support.models` at import time) - `src/repositories/` — Railengine / ingest SDK I/O -- `src/services/` — ingest, list, triage use cases +- `src/services/` — ingest, list, search, triage use cases - `src/controllers/` — optional webhook receiver - `src/agents/` — Railtracks agent + tools -- `src/pages/` — Streamlit Dashboard, Ingest, and Agent +- `src/pages/` — Streamlit Dashboard, Search, Ingest, and Agent - [`src/streamlit_app.py`](src/streamlit_app.py) — navigation entry (importable as `customer_support.streamlit_app`) ## Local only diff --git a/Python/customer-support/src/pages/dashboard.py b/Python/customer-support/src/pages/dashboard.py index 6a7f673..8766e63 100644 --- a/Python/customer-support/src/pages/dashboard.py +++ b/Python/customer-support/src/pages/dashboard.py @@ -97,18 +97,3 @@ st.rerun() except Exception: st.error(traceback.format_exc()) - -if tickets: - with st.expander("Table view"): - tf = [ - { - "id": t.id, - "subject": t.subject[:140] + ("…" if len(t.subject) > 140 else ""), - "status": t.status, - "tags": ", ".join(t.tags), - "productArea": t.productArea, - "createdAt": t.createdAt, - } - for t in tickets - ] - st.dataframe(tf, hide_index=True, use_container_width=True) diff --git a/Python/customer-support/src/pages/ingest_page.py b/Python/customer-support/src/pages/ingest_page.py index 8f4090d..c9120ce 100644 --- a/Python/customer-support/src/pages/ingest_page.py +++ b/Python/customer-support/src/pages/ingest_page.py @@ -26,7 +26,7 @@ st.title("Ingest") st.caption( - "Edit ticket JSON, send to Railengine, or run structured triage with Railtracks." + "Edit ticket JSON, send to Railengine, or run structured triage with Railtracks. The Railengine is configured to do PII masking, vector embeddings, and full text search." ) render_pipeline_stages() diff --git a/Python/customer-support/src/pages/search_page.py b/Python/customer-support/src/pages/search_page.py new file mode 100644 index 0000000..a8df613 --- /dev/null +++ b/Python/customer-support/src/pages/search_page.py @@ -0,0 +1,155 @@ +"""Streamlit page: Railengine index search.""" + +from __future__ import annotations + +import asyncio +import traceback +from typing import Any + +import streamlit as st + +from customer_support.models import SupportTicket +from customer_support.services.search_service import SearchService +from customer_support.streamlit_common import ( + env_ok, + render_app_toolbar, + render_page_brand, + show_ticket_details_dialog, +) + +_SEARCH_RESULTS_KEY = "search_results" +_SEARCH_DF_KEY = "search_results_df" +_DEFAULT_LIMIT = 50 + + +def _trunc_subject(subject: str, *, max_len: int = 140) -> str: + return subject[:max_len] + ("…" if len(subject) > max_len else "") + + +def _tickets_to_table_rows(tickets: list[SupportTicket]) -> list[dict[str, str]]: + return [ + { + "id": t.id, + "subject": _trunc_subject(t.subject), + "status": t.status, + "tags": ", ".join(t.tags), + "productArea": t.productArea, + "createdAt": t.createdAt, + } + for t in tickets + ] + + +def _selected_row_index(df_key: str) -> int | None: + """Row index from ``st.dataframe`` selection state (dict or widget state object).""" + state: Any = st.session_state.get(df_key) + if state is None: + return None + selection = ( + state.get("selection") + if isinstance(state, dict) + else getattr(state, "selection", None) + ) + if selection is None: + return None + rows = ( + selection.get("rows") + if isinstance(selection, dict) + else getattr(selection, "rows", None) + ) + if not rows: + return None + return int(rows[0]) + + +def _open_details_for_selected_row() -> None: + """``on_select`` callback: open ticket dialog for the newly selected row.""" + idx = _selected_row_index(_SEARCH_DF_KEY) + if idx is None: + return + tickets: list[SupportTicket] = st.session_state.get(_SEARCH_RESULTS_KEY, []) + if 0 <= idx < len(tickets): + show_ticket_details_dialog(tickets[idx]) + + +render_page_brand() +render_app_toolbar() + +st.title("Search") +st.caption( + "Find tickets in Railengine using the **keyword index** (`search_index`). " + "Use subject keywords, product area, or phrases from ticket bodies." +) + +env = env_ok() +search_ready = env["ENGINE_PAT"] and env["ENGINE_ID"] + +if not search_ready: + st.warning("Set **ENGINE_PAT** and **ENGINE_ID** to search Railengine.") + +if _SEARCH_RESULTS_KEY not in st.session_state: + st.session_state[_SEARCH_RESULTS_KEY] = [] + +with st.form("search_form", clear_on_submit=False): + query = st.text_input( + "Search query", + placeholder="e.g. billing portal invoice 500", + disabled=not search_ready, + ) + limit = st.slider( + "Max results", + min_value=5, + max_value=100, + value=_DEFAULT_LIMIT, + step=5, + disabled=not search_ready, + ) + run_search = st.form_submit_button( + "🔍 Search", + disabled=not search_ready, + type="primary", + ) + +if run_search and query.strip(): + try: + with st.spinner("Searching Railengine index…"): + st.session_state[_SEARCH_RESULTS_KEY] = asyncio.run( + SearchService().search_index(query.strip(), limit=limit) + ) + if _SEARCH_DF_KEY in st.session_state: + del st.session_state[_SEARCH_DF_KEY] + except Exception: + st.error(traceback.format_exc()) + else: + n = len(st.session_state[_SEARCH_RESULTS_KEY]) + st.success(f"Found **{n}** ticket(s) for `{query.strip()}`.") + +results: list[SupportTicket] = st.session_state[_SEARCH_RESULTS_KEY] + +if results: + st.subheader("Results") + st.caption( + "Select a row in the table (checkbox on the left) to open **Ticket details**. " + "You can also use **📋 Details** after selecting a row." + ) + st.dataframe( + _tickets_to_table_rows(results), + hide_index=True, + width="stretch", + on_select=_open_details_for_selected_row, + selection_mode="single-row", + key=_SEARCH_DF_KEY, + ) + selected_idx = _selected_row_index(_SEARCH_DF_KEY) + if st.button( + "📋 Details", + disabled=selected_idx is None, + type="secondary", + ): + if selected_idx is not None and 0 <= selected_idx < len(results): + show_ticket_details_dialog(results[selected_idx]) +elif search_ready and not run_search: + st.info( + "Enter a query and press **Enter** or click **🔍 Search** " + "to query the Railengine index." + ) diff --git a/Python/customer-support/src/services/__init__.py b/Python/customer-support/src/services/__init__.py index b169b7e..2aa8da7 100644 --- a/Python/customer-support/src/services/__init__.py +++ b/Python/customer-support/src/services/__init__.py @@ -1,7 +1,8 @@ """Application services.""" from customer_support.services.ingest_service import IngestService +from customer_support.services.search_service import SearchService from customer_support.services.ticket_list_service import TicketListService from customer_support.services.triage_service import TriageService -__all__ = ["IngestService", "TicketListService", "TriageService"] +__all__ = ["IngestService", "SearchService", "TicketListService", "TriageService"] diff --git a/Python/customer-support/src/services/search_service.py b/Python/customer-support/src/services/search_service.py new file mode 100644 index 0000000..955a6c4 --- /dev/null +++ b/Python/customer-support/src/services/search_service.py @@ -0,0 +1,17 @@ +"""Railengine index search orchestration.""" + +from __future__ import annotations + +from customer_support.models import SupportTicket +from customer_support.repositories import TicketRepository + + +class SearchService: + """Expose ``search_index`` for keyword retrieval.""" + + def __init__(self, repository: TicketRepository | None = None) -> None: + self._repo = repository or TicketRepository() + + async def search_index(self, query: str, *, limit: int = 50) -> list[SupportTicket]: + capped = max(1, min(int(limit), 100)) + return await self._repo.search_index_hits(query, capped) diff --git a/Python/customer-support/src/streamlit_app.py b/Python/customer-support/src/streamlit_app.py index 1676f93..b650279 100644 --- a/Python/customer-support/src/streamlit_app.py +++ b/Python/customer-support/src/streamlit_app.py @@ -1,4 +1,4 @@ -"""Streamlit multipage entry: Dashboard, Ingest, Agent.""" +"""Streamlit multipage entry: Dashboard, Search, Ingest, Agent.""" from __future__ import annotations @@ -15,6 +15,7 @@ def main() -> None: pg = st.navigation( [ st.Page("pages/dashboard.py", title="Dashboard", icon="🗂️", default=True), + st.Page("pages/search_page.py", title="Search", icon="🔍"), st.Page("pages/ingest_page.py", title="Ingest", icon="📥"), st.Page("pages/agent_page.py", title="Agent", icon="🤖"), ] diff --git a/Python/customer-support/src/streamlit_common.py b/Python/customer-support/src/streamlit_common.py index 9349e4f..d2bd23d 100644 --- a/Python/customer-support/src/streamlit_common.py +++ b/Python/customer-support/src/streamlit_common.py @@ -195,7 +195,7 @@ def render_pipeline_stages() -> None: st.html(_PIPELINE_STAGES_HTML, width="stretch") st.caption( "This page sends JSON through **Ingest**. Triage agents search **Embedding** and " - "**Indexing**; the dashboard reads **Hot Storage**." + "**Indexing**; the dashboard reads **Hot Storage**; **Search** queries the index." ) From 9903f5f8eb7c29b2e018b76cba67fef84bb63a6d Mon Sep 17 00:00:00 2001 From: Jaime Bueza Date: Mon, 25 May 2026 11:57:51 -0700 Subject: [PATCH 37/42] Refactor ticket handling in the customer support module by removing the mappers file and updating the TicketRepository to directly parse tickets from storage. This enhances code clarity and maintainability by streamlining the ticket retrieval process. (#5895) Co-authored-by: Claude Opus 4.7 (1M context) --- Python/customer-support/src/agents/tools.py | 13 ++- .../src/repositories/mappers.py | 71 ---------------- .../src/repositories/ticket_repository.py | 83 ++++++++++--------- 3 files changed, 50 insertions(+), 117 deletions(-) delete mode 100644 Python/customer-support/src/repositories/mappers.py diff --git a/Python/customer-support/src/agents/tools.py b/Python/customer-support/src/agents/tools.py index bf7cf91..898c05a 100644 --- a/Python/customer-support/src/agents/tools.py +++ b/Python/customer-support/src/agents/tools.py @@ -9,7 +9,6 @@ from customer_support.models.ticket import TICKET_STATUSES, SupportTicket from customer_support.repositories import TicketRepository -from customer_support.repositories.mappers import ticket_from_row def _ticket_to_brief(t: SupportTicket) -> dict[str, Any]: @@ -82,9 +81,8 @@ async def list_recent_tickets(status: str = "resolved", limit: int = 15) -> str: if len(collected) < cap: max_scan = 400 seen_ids = {t.id for t in collected} - async for row in repo.iter_raw_storage_pages(page_size=100, max_docs=max_scan): - t = ticket_from_row(row) - if not t or t.status != want: + async for t in repo.iter_storage_tickets(page_size=100, max_docs=max_scan): + if t.status != want: continue if t.id in seen_ids: continue @@ -116,9 +114,8 @@ async def get_ticket_by_id(ticket_id: str) -> str: if t.id == tid: return json.dumps(_ticket_to_brief(t), ensure_ascii=False, indent=2) - async for row in repo.iter_raw_storage_pages(page_size=100, max_docs=500): - t_row = ticket_from_row(row) - if t_row and t_row.id == tid: - return json.dumps(_ticket_to_brief(t_row), ensure_ascii=False, indent=2) + async for t in repo.iter_storage_tickets(page_size=100, max_docs=500): + if t.id == tid: + return json.dumps(_ticket_to_brief(t), ensure_ascii=False, indent=2) return json.dumps({"error": f"ticket not found: {tid}"}, indent=2) diff --git a/Python/customer-support/src/repositories/mappers.py b/Python/customer-support/src/repositories/mappers.py deleted file mode 100644 index a5a9596..0000000 --- a/Python/customer-support/src/repositories/mappers.py +++ /dev/null @@ -1,71 +0,0 @@ -"""Map Railengine retrieval rows/documents -> SupportTicket models.""" - -from __future__ import annotations - -import json -from typing import Any - -from pydantic import BaseModel - -from customer_support.models.ticket import SupportTicket - - -def _parse_json_if_string(raw: Any) -> Any: - if raw is None: - return None - if isinstance(raw, str): - try: - return json.loads(raw) - except json.JSONDecodeError: - return None - return raw - - -def row_as_dict(row: Any) -> dict[str, Any] | None: - """Coerce SDK hits (dict or Pydantic, e.g. vector rows) into a plain dict.""" - if row is None: - return None - if isinstance(row, dict): - return row - if isinstance(row, BaseModel): - return row.model_dump(by_alias=True) - return None - - -def body_from_row(row: Any) -> dict[str, Any] | None: - """Extract the user document object from a storage or search row.""" - d = row_as_dict(row) - if not d: - return None - raw = d.get("Body") or d.get("body") or d.get("content") or d.get("Content") - parsed = _parse_json_if_string(raw) - if isinstance(parsed, dict): - return parsed - if "id" in d and "subject" in d: - return dict(d) - return None - - -def ticket_from_row(row: Any) -> SupportTicket | None: - """Parse a storage/search hit into SupportTicket if possible.""" - if isinstance(row, SupportTicket): - return row - body = body_from_row(row) - if not body: - return None - try: - return SupportTicket.model_validate(body) - except Exception: - return None - - -def row_preview(row: Any, max_len: int = 400) -> str: - """Short string for logging tool results.""" - t = ticket_from_row(row) - if t: - return f"{t.id} | {t.status} | {t.subject[:80]}" - body = body_from_row(row) - if body: - s = json.dumps(body, ensure_ascii=False)[:max_len] - return s + ("…" if len(s) == max_len else "") - return str(row)[:max_len] diff --git a/Python/customer-support/src/repositories/ticket_repository.py b/Python/customer-support/src/repositories/ticket_repository.py index 8371eec..9ac703c 100644 --- a/Python/customer-support/src/repositories/ticket_repository.py +++ b/Python/customer-support/src/repositories/ticket_repository.py @@ -11,7 +11,27 @@ from railtown.engine.ingest import RailengineIngest from customer_support.models import SupportTicket, TicketPage -from customer_support.repositories.mappers import ticket_from_row + + +def _as_ticket(item: Any) -> SupportTicket | None: + """Coerce SDK hits to ``SupportTicket`` (model instances or fallback dict parse).""" + if isinstance(item, SupportTicket): + return item + if isinstance(item, dict): + try: + return SupportTicket.model_validate(item) + except Exception: + return None + return None + + +def _collect_tickets(items: list[Any]) -> list[SupportTicket]: + out: list[SupportTicket] = [] + for item in items: + t = _as_ticket(item) + if t is not None: + out.append(t) + return out async def ingest_ticket_with_client( @@ -43,18 +63,13 @@ async def ingest_paths(self, paths: list[Path]) -> list[tuple[str, int]]: async def list_page(self, page_number: int = 1, page_size: int = 100) -> TicketPage: capped = max(1, min(int(page_size), 100)) ps = capped - async with Railengine() as client: + async with Railengine(model=SupportTicket) as client: page = await client.list_storage_documents( page_number=page_number, page_size=ps, - raw=True, ) - tickets: list[SupportTicket] = [] - for row in page.items: - t = ticket_from_row(row) - if t: - tickets.append(t) + tickets = _collect_tickets(page.items) return TicketPage( items=tickets, @@ -68,18 +83,14 @@ async def list_all(self, *, page_size: int = 100) -> list[SupportTicket]: """Walk every storage page in one Railengine session; return parsed tickets.""" capped = max(1, min(int(page_size), 100)) out: list[SupportTicket] = [] - async with Railengine() as client: + async with Railengine(model=SupportTicket) as client: pn = 1 while True: page = await client.list_storage_documents( page_number=pn, page_size=capped, - raw=True, ) - for row in page.items: - t = ticket_from_row(row) - if t: - out.append(t) + out.extend(_collect_tickets(page.items)) total_pages = getattr(page, "total_pages", 0) or 0 if total_pages < 1 or pn >= total_pages: break @@ -88,11 +99,11 @@ async def list_all(self, *, page_size: int = 100) -> list[SupportTicket]: async def search_index_hits(self, query: str, limit: int) -> list[SupportTicket]: out: list[SupportTicket] = [] - async with Railengine() as client: - result = await client.search_index(query={"search": query}, raw=True) - for row in result.items: - t = ticket_from_row(row) - if t: + async with Railengine(model=SupportTicket) as client: + result = await client.search_index(query={"search": query}, raw=False) + for item in result.items: + t = _as_ticket(item) + if t is not None: out.append(t) if len(out) >= limit: break @@ -100,48 +111,44 @@ async def search_index_hits(self, query: str, limit: int) -> list[SupportTicket] async def search_vector_hits(self, query: str, limit: int) -> list[SupportTicket]: out: list[SupportTicket] = [] - async with Railengine() as client: + async with Railengine(model=SupportTicket) as client: items = await client.search_vector_store( vector_store="VectorStore1", query=query, top=limit, ) - for row in items: - t = ticket_from_row(row) - if t: + for item in items: + t = _as_ticket(item) + if t is not None: out.append(t) if len(out) >= limit: break return out async def query_jsonpath_tickets(self, jq: str) -> list[SupportTicket]: - async with Railengine() as client: + async with Railengine(model=SupportTicket) as client: page = await client.query_storage_by_jsonpath(json_path_query=jq) - collected: list[SupportTicket] = [] - for row in page.items: - t = ticket_from_row(row) - if t: - collected.append(t) - return collected - - async def iter_raw_storage_pages( + return _collect_tickets(page.items) + + async def iter_storage_tickets( self, *, page_size: int, max_docs: int, - ) -> AsyncIterator[Any]: - """Yield raw rows until ``max_docs`` or pages exhausted (single session).""" - async with Railengine() as client: + ) -> AsyncIterator[SupportTicket]: + """Yield parsed tickets from storage pages until ``max_docs`` or pages exhausted.""" + async with Railengine(model=SupportTicket) as client: scanned = 0 pn = 1 while scanned < max_docs: page = await client.list_storage_documents( page_number=pn, page_size=page_size, - raw=True, ) - for row in page.items: - yield row + for item in page.items: + t = _as_ticket(item) + if t is not None: + yield t scanned += 1 if scanned >= max_docs: return From 7cde569232ec4517794e446da67a6a4aecdf1938 Mon Sep 17 00:00:00 2001 From: Jaime Bueza Date: Mon, 25 May 2026 12:04:03 -0700 Subject: [PATCH 38/42] Refactor string formatting in dashboard and ingest page for improved readability. This enhances code clarity by breaking long lines into multiple lines. (#5895) Co-authored-by: Claude Opus 4.7 (1M context) --- Python/customer-support/src/pages/dashboard.py | 4 +++- Python/customer-support/src/pages/ingest_page.py | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/Python/customer-support/src/pages/dashboard.py b/Python/customer-support/src/pages/dashboard.py index 8766e63..c6dfc6f 100644 --- a/Python/customer-support/src/pages/dashboard.py +++ b/Python/customer-support/src/pages/dashboard.py @@ -56,7 +56,9 @@ st.error(traceback.format_exc()) else: if refresh: - st.success(f"Loaded **{len(st.session_state[_KANBAN_SESSION_KEY])}** tickets.") + st.success( + f"Loaded **{len(st.session_state[_KANBAN_SESSION_KEY])}** tickets." + ) tickets: list[SupportTicket] = st.session_state[_KANBAN_SESSION_KEY] buckets = group_tickets_by_status(tickets) diff --git a/Python/customer-support/src/pages/ingest_page.py b/Python/customer-support/src/pages/ingest_page.py index c9120ce..eb2997b 100644 --- a/Python/customer-support/src/pages/ingest_page.py +++ b/Python/customer-support/src/pages/ingest_page.py @@ -26,7 +26,8 @@ st.title("Ingest") st.caption( - "Edit ticket JSON, send to Railengine, or run structured triage with Railtracks. The Railengine is configured to do PII masking, vector embeddings, and full text search." + "Edit ticket JSON, send to Railengine, or run structured triage with Railtracks. " + "The Railengine is configured to do PII masking, vector embeddings, and full text search." ) render_pipeline_stages() From 92febcaeb5df5d409b072648270fbd6dbfdd7f57 Mon Sep 17 00:00:00 2001 From: Jaime Bueza Date: Mon, 25 May 2026 12:55:48 -0700 Subject: [PATCH 39/42] Remove .gitignore file from customer support module as it is no longer needed. This simplifies the project structure and enhances maintainability. (#5895) Co-authored-by: Claude Opus 4.7 (1M context) --- Python/customer-support/.gitignore | 1 - 1 file changed, 1 deletion(-) delete mode 100644 Python/customer-support/.gitignore diff --git a/Python/customer-support/.gitignore b/Python/customer-support/.gitignore deleted file mode 100644 index d1c298e..0000000 --- a/Python/customer-support/.gitignore +++ /dev/null @@ -1 +0,0 @@ -.railtracks From 182bfe1b2e3c4e2c9d2e24c48453d37348bb1313 Mon Sep 17 00:00:00 2001 From: Jaime Bueza Date: Mon, 25 May 2026 12:57:14 -0700 Subject: [PATCH 40/42] Update README to clarify schema selection process for engine creation in customer support module. This improves user guidance for setting up the support ticket ingestion system. (#5895) Co-authored-by: Claude Opus 4.7 (1M context) --- Python/customer-support/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Python/customer-support/README.md b/Python/customer-support/README.md index 6fe43e0..71be1c1 100644 --- a/Python/customer-support/README.md +++ b/Python/customer-support/README.md @@ -5,7 +5,7 @@ Demo stack: ingest support tickets into [Railengine](https://railengine.ai/), se ## Before you start - A [Railengine](https://railengine.ai/) account plus a **new engine** configured with the sample schema in [`engine-schema.json`](engine-schema.json). -- Paste that schema into your engine schema editor so documents match **`SupportTicket`**. +- Select that schema into your engine creation modal so documents match **`SupportTicket`**. - Allowed ticket **`status`** values when ingesting vs. validating in-app: **`pending`**, **`open`**, **`in_progress`**, **`resolved`** — update long-lived engine/schema rules if yours differ before re-ingesting fixtures. - Enable **Index** plus **VectorStore1** on fields such as `subject`, `body`, and `tags` in the Railengine console so search tools get useful hits beyond raw storage scans. From c9db1fe54f2b1da2ed30a2c30e88de86e5fbcbee Mon Sep 17 00:00:00 2001 From: Jaime Bueza Date: Mon, 25 May 2026 12:59:19 -0700 Subject: [PATCH 41/42] Update README to enhance clarity on search functionality and project structure in the customer support module. This improves user guidance for navigating the application and understanding the code organization. (#5895) Co-authored-by: Claude Opus 4.7 (1M context) --- Python/customer-support/README.md | 36 ++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/Python/customer-support/README.md b/Python/customer-support/README.md index 71be1c1..3dcb687 100644 --- a/Python/customer-support/README.md +++ b/Python/customer-support/README.md @@ -26,7 +26,7 @@ uv run streamlit run src/streamlit_app.py 2. (Optional breadth) On **Ingest**, use sidebar **Seed all fixtures** to ingest every `fixtures/tickets/*.json`. 3. On **Ingest**, click **Run triage** on a single ticket, or open **Agent** → **Load queue** → **Triage all** for open and pending tickets. 4. Switch to **Dashboard**, click **Refresh board**, and browse the Kanban. Click a **card subject** to open ticket details in a dialog; change status from the card **dropdown** (**requires `ENGINE_TOKEN`** alongside list credentials). -5. Open **Search**, enter keywords (e.g. `billing invoice`), and click **🔍 Search** to query the Railengine keyword index. Use the table and subject links for full ticket details. +5. Open **Search**, enter keywords (e.g. `billing invoice`), and press **Enter** or **🔍 Search** to query the keyword index. Select a row (or **📋 Details**) to open the ticket dialog. ## Debug and visualize the triage agent (optional) @@ -60,13 +60,33 @@ If your engine masks sensitive fields after ingest, compare raw fixtures to stor ## Project layout -- `src/models/` — Pydantic shapes (`customer_support.models` at import time) -- `src/repositories/` — Railengine / ingest SDK I/O -- `src/services/` — ingest, list, search, triage use cases -- `src/controllers/` — optional webhook receiver -- `src/agents/` — Railtracks agent + tools -- `src/pages/` — Streamlit Dashboard, Search, Ingest, and Agent -- [`src/streamlit_app.py`](src/streamlit_app.py) — navigation entry (importable as `customer_support.streamlit_app`) +Code is organized in layers so UI, business logic, and Railengine I/O stay separate. Imports use the **`customer_support`** package (`pyproject.toml` maps `src/` to that name). + +``` +pages / controllers → services → repositories → rail-engine / rail-engine-ingest + ↓ + models +agents (tools) → services or repositories +``` + +| Path | Role | Examples | +|------|------|----------| +| [`src/models/`](src/models/) | Pydantic domain types and constants | `SupportTicket`, `TriageAssessment`, `TicketPage` | +| [`src/repositories/`](src/repositories/) | SDK calls only (`Railengine`, `RailengineIngest` with `model=SupportTicket`) | `TicketRepository` — list, search, ingest, JSONPath | +| [`src/services/`](src/services/) | Use-case orchestration; pages call these, not the SDK | `IngestService`, `TicketListService`, `SearchService`, `TriageService` | +| [`src/agents/`](src/agents/) | Railtracks structured agent, chat agent, and tool nodes | `triage_agent.py`, `tools.py` (`search_similar_tickets`, …) | +| [`src/pages/`](src/pages/) | Streamlit screens (thin UI + session state) | `dashboard.py`, `search_page.py`, `ingest_page.py`, `agent_page.py` | +| [`src/controllers/`](src/controllers/) | Optional non-UI entry points | [`webhook.py`](src/controllers/webhook.py) — local publishing smoke test | +| [`src/config/`](src/config/) | Environment bootstrap | `ensure_dotenv_loaded()` loads `.env` next to `pyproject.toml` | +| [`src/streamlit_app.py`](src/streamlit_app.py) | App entry: `st.navigation` for Dashboard / Search / Ingest / Agent | `uv run streamlit run src/streamlit_app.py` | +| [`src/streamlit_common.py`](src/streamlit_common.py) | Shared UI (brand, toolbar, Kanban cards, ticket dialog, pipeline diagram) | Used across pages | + +**Repo root (besides `src/`)** + +- [`engine-schema.json`](engine-schema.json) — paste into the Railengine console when creating the engine +- [`fixtures/tickets/`](fixtures/tickets/) — sample `SupportTicket` JSON for ingest demos +- [`assets/`](assets/) — Streamlit branding (e.g. logo) +- [`.env.example`](.env.example) — required env var template ## Local only From 9abefbba775e973365b801525c8577441206e41e Mon Sep 17 00:00:00 2001 From: Jaime Bueza Date: Mon, 25 May 2026 13:24:59 -0700 Subject: [PATCH 42/42] Update README to correct terminology from "Repo root" to "Repository root" and enhance clarity in the project structure section. This improves user understanding of the directory layout. (#5895) Co-authored-by: Claude Opus 4.7 (1M context) --- Python/customer-support/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Python/customer-support/README.md b/Python/customer-support/README.md index 3dcb687..8d4fd79 100644 --- a/Python/customer-support/README.md +++ b/Python/customer-support/README.md @@ -62,7 +62,7 @@ If your engine masks sensitive fields after ingest, compare raw fixtures to stor Code is organized in layers so UI, business logic, and Railengine I/O stay separate. Imports use the **`customer_support`** package (`pyproject.toml` maps `src/` to that name). -``` +```text pages / controllers → services → repositories → rail-engine / rail-engine-ingest ↓ models @@ -81,7 +81,7 @@ agents (tools) → services or repositories | [`src/streamlit_app.py`](src/streamlit_app.py) | App entry: `st.navigation` for Dashboard / Search / Ingest / Agent | `uv run streamlit run src/streamlit_app.py` | | [`src/streamlit_common.py`](src/streamlit_common.py) | Shared UI (brand, toolbar, Kanban cards, ticket dialog, pipeline diagram) | Used across pages | -**Repo root (besides `src/`)** +**Repository root (besides `src/`)** - [`engine-schema.json`](engine-schema.json) — paste into the Railengine console when creating the engine - [`fixtures/tickets/`](fixtures/tickets/) — sample `SupportTicket` JSON for ingest demos