Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
9360d2c
add Python/daily-insight: railtracks-powered status-page insight demo
mbrailtown Jun 12, 2026
d578dc2
let DailyInsightService offload to a Railtracks agent via DailyInsigh…
mbrailtown Jun 12, 2026
f37cbd0
attach bearer token to the agent POST when DailyInsight:AgentBearerTo…
mbrailtown Jun 12, 2026
a0d4764
add Dockerfile and switch to provider-neutral LLM_API_KEY / LLM_MODEL…
mbrailtown Jun 12, 2026
9e3d049
wire railtownai observability + global exception handler
mbrailtown Jun 12, 2026
0466964
upload Railtracks session payload to Conductr after each insight run
mbrailtown Jun 12, 2026
c23b474
add POST /evaluate: generate fresh insight runs and score them with R…
mbrailtown Jun 12, 2026
95bd382
pin railtracks to commit 4e4ed57 for ToolUseEvaluator single-session fix
mbrailtown Jun 12, 2026
19ec35d
add subdirectory=packages/railtracks hint to the railtracks git pin
mbrailtown Jun 12, 2026
f778dc9
wire agent_run_id: evaluate historical sessions fetched from Conductr
mbrailtown Jun 12, 2026
f68b330
rename /evaluate endpoint to /evals/run
mbrailtown Jun 12, 2026
fe9f6fa
surface railtownai.upload_agent_evaluation's silent-False failure path
mbrailtown Jun 12, 2026
623a806
upload evaluations via asyncio.to_thread, not the sync payload_callba…
mbrailtown Jun 12, 2026
dce0a65
include agent_run_id in evaluation_name for historical-mode runs
mbrailtown Jun 12, 2026
e966d5f
fix linter errors and supress linter verbose output - ascii art and s…
mbrailtown Jun 15, 2026
fbe9d69
move hadolint DL3008 ignore directive directly above RUN
mbrailtown Jun 15, 2026
36374e2
unpin railtracks: revert to >=1.4.1 now that the fix shipped
mbrailtown Jun 15, 2026
e2163ab
accept agent_run_ids list on /evals/run for batch eval
mbrailtown Jun 19, 2026
2ebc1bc
forbid extras and enforce fresh/historical mode XOR on /evals/run
mbrailtown Jun 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CSharp/Examples/RailenginePoweredStatusPage/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,14 @@ The prompt in `Services/DailyInsightService.cs` is deliberately tuned for short,

If `Anthropic:ApiKey` is not set, the service logs a notice and exits cleanly; the insight card simply doesn't appear.

### Offloading to a Railtracks agent (optional)

Set `DailyInsight:AgentUrl` to the base URL of a service that exposes `POST /insight` returning `{ text, generated_at, error? }` — for example the sibling [`Python/daily-insight`](../../../Python/daily-insight/) project, which runs the same prompt through a Railtracks agent backed by the Railengine Python SDK instead of MCP.

When `AgentUrl` is set, `DailyInsightService` POSTs to `{AgentUrl}/insight` every 24 hours instead of calling Anthropic + MCP inline. `Anthropic:ApiKey` is then optional — the agent owns the LLM call. Leave `AgentUrl` empty (or unset) to keep the inline path.

If the agent endpoint requires bearer authentication, set `DailyInsight:AgentBearerToken` to the token — `DailyInsightService` attaches it as `Authorization: Bearer <token>` on every POST. Leave it blank for an unauthenticated endpoint (e.g. local development on `:8000`).

> **Heads-up for local development:** the 24-hour timer is in-memory only, so each app restart triggers a fresh generation and a corresponding Anthropic API call. If you're iterating on the app you may want to comment out `AddHostedService<DailyInsightService>()` in `Program.cs` until you're ready to test it. Once deployed to a long-running host, restarts are rare and this isn't a concern.

## Configuration
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ public class DailyInsightService : BackgroundService
private readonly string pat;
private readonly string mcpServerBaseUrl;
private readonly string mcpServerName;
private readonly string agentUrl;
private readonly string agentBearerToken;

private static readonly TimeSpan Interval = TimeSpan.FromHours(24);
private static readonly TimeSpan RequestTimeout = TimeSpan.FromMinutes(10);
Expand Down Expand Up @@ -47,21 +49,31 @@ public DailyInsightService(
pat = configuration["RailEngine:PAT"]!;
mcpServerBaseUrl = configuration["RailEngine:McpServerBaseUrl"]!.TrimEnd('/');
mcpServerName = configuration["RailEngine:McpServerName"]!;
agentUrl = (configuration["DailyInsight:AgentUrl"] ?? "").TrimEnd('/');
agentBearerToken = configuration["DailyInsight:AgentBearerToken"] ?? "";
}

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
if (string.IsNullOrEmpty(apiKey))
var useAgent = !string.IsNullOrEmpty(agentUrl);
if (!useAgent && string.IsNullOrEmpty(apiKey))
{
logger.LogInformation("Anthropic:ApiKey not configured; daily insight disabled.");
logger.LogInformation("Neither DailyInsight:AgentUrl nor Anthropic:ApiKey configured; daily insight disabled.");
return;
}

while (!stoppingToken.IsCancellationRequested)
{
try
{
await GenerateAsync(stoppingToken);
if (useAgent)
{
await GenerateFromAgentAsync(stoppingToken);
}
else
{
await GenerateAsync(stoppingToken);
}
state.Error = null;
}
catch (Exception ex)
Expand All @@ -75,6 +87,52 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
}
}

private async Task GenerateFromAgentAsync(CancellationToken ct)
{
logger.LogInformation("Starting daily insight generation via agent at {Url}…", agentUrl);

using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(RequestTimeout);

using var client = httpClientFactory.CreateClient();
client.Timeout = Timeout.InfiniteTimeSpan;

using var request = new HttpRequestMessage(HttpMethod.Post, $"{agentUrl}/insight");
request.Content = new StringContent("{}", Encoding.UTF8, "application/json");
if (!string.IsNullOrEmpty(agentBearerToken))
{
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", agentBearerToken);
}

using var response = await client.SendAsync(request, cts.Token);

if (!response.IsSuccessStatusCode)
{
var errBody = await response.Content.ReadAsStringAsync(cts.Token);
throw new InvalidOperationException($"Agent returned {(int)response.StatusCode}: {errBody}");
}

var body = await response.Content.ReadAsStringAsync(cts.Token);
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;

var text = root.TryGetProperty("text", out var t) ? t.GetString() ?? "" : "";
var generatedAt = root.TryGetProperty("generated_at", out var ga) && ga.ValueKind == JsonValueKind.String
? DateTimeOffset.Parse(ga.GetString()!)
: DateTimeOffset.UtcNow;
var agentError = root.TryGetProperty("error", out var err) && err.ValueKind == JsonValueKind.String
? err.GetString()
: null;

state.Text = text;
state.GeneratedAt = generatedAt;
if (!string.IsNullOrEmpty(agentError))
{
state.Error = agentError;
}
logger.LogInformation("Daily insight generated via agent ({Length} chars)", text.Length);
}

private async Task GenerateAsync(CancellationToken ct)
{
logger.LogInformation("Starting daily insight generation…");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,11 @@
"Anthropic": {
"ApiKey": "[your-anthropic-api-key-here]"
},
"DailyInsight": {
"// AgentUrl": "Optional — when set, posts to {AgentUrl}/insight (e.g. the Python/daily-insight service) instead of calling Anthropic + MCP inline. Leave empty/unset to use the inline path.",
"AgentUrl": "",
"// AgentBearerToken": "Optional — when set, sent as Authorization: Bearer <token> on every POST to {AgentUrl}/insight. Set this when the agent endpoint requires bearer authentication. Leave empty for an unauthenticated endpoint (e.g. local development).",
"AgentBearerToken": ""
},
"AllowedIPs": [ "127.0.0.1", "::1" ]
}
4 changes: 4 additions & 0 deletions CSharp/Examples/RailenginePoweredStatusPage/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,9 @@
"Beta": "mcp-client-2025-04-04",
"Model": "claude-haiku-4-5-20251001"
},
"DailyInsight": {
"AgentUrl": "",
"AgentBearerToken": ""
},
"AllowedIPs": [ "127.0.0.1", "::1" ]
}
20 changes: 20 additions & 0 deletions Python/daily-insight/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Retrieval (PAT + Engine ID for the same engine the status page reads from)
ENGINE_ID="[Your Engine ID]"
ENGINE_PAT="[Your Engine PAT]"

# Optional: override API host (must match the stack ENGINE_PAT targets)
# RAILTOWN_API_URL="https://cndr.railtown.ai/api"

# LLM provider key. The agent uses the provider-neutral name LLM_API_KEY and
# copies it into ANTHROPIC_API_KEY on startup so the Anthropic SDK picks it up.
# (You can also set ANTHROPIC_API_KEY directly if you prefer the SDK-native
# name — the bridge runs in both directions.)
LLM_API_KEY="[Your Anthropic API key]"
# ANTHROPIC_API_KEY="[same key — SDK-native name]"

# Optional: override the default Claude model (defaults to claude-haiku-4-5-20251001 in code)
# LLM_MODEL="claude-haiku-4-5-20251001"

# Optional: Railtown observability. When set, the agent's logs + any unhandled
# exception tracebacks ship to Railtown via the railtownai logging handler.
# RAILTOWN_API_KEY="[Your Railtown API key]"
13 changes: 13 additions & 0 deletions Python/daily-insight/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
FROM python:3.10-slim

WORKDIR /app

# Install the package + deps. Copying pyproject.toml + src separately lets
# Docker cache the dependency install across source-only changes.
COPY pyproject.toml ./
COPY src/ ./src/
RUN pip install --no-cache-dir .

EXPOSE 8000

CMD ["uvicorn", "daily_insight.controllers.api:app", "--host", "0.0.0.0", "--port", "8000"]
147 changes: 147 additions & 0 deletions Python/daily-insight/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
# Daily Insight (Railtracks)

A small Python service that mirrors the **Daily Insight** feature from [`CSharp/Examples/RailenginePoweredStatusPage`](../../CSharp/Examples/RailenginePoweredStatusPage/): given a Railengine of metric records, ask an LLM to produce a one-line-per-metric plain-text summary of recent values.

Where the C# version drives the LLM through an Anthropic MCP server attached as a tool source, this version drives it through [Railtracks](https://github.com/RailtownAI/railtracks) with a single `@rt.function_node` tool that calls the [Railengine Python SDK](https://pypi.org/project/rail-engine/) directly.

## Before you start

- A [Railengine](https://railengine.ai/) engine populated with `MetricRecord`-shaped documents (`metric`, `timestamp`, `value` — see [`MetricRecord`](src/models/metric.py)). The C# status page example produces records in exactly this shape.
- An Anthropic API key with access to `claude-haiku-4-5-20251001` (or another model you set via `LLM_MODEL`).

## Quick start

```bash
cd Python/daily-insight
cp .env.example .env # fill ENGINE_ID, ENGINE_PAT, LLM_API_KEY
uv sync
uv run uvicorn daily_insight.controllers.api:app --reload --port 8000
```

Then:

```bash
# Liveness
curl http://127.0.0.1:8000/health

# Generate a fresh insight
curl -X POST http://127.0.0.1:8000/insight

# Generate fresh insight(s) and evaluate them
curl -X POST http://127.0.0.1:8000/evals/run -H 'Content-Type: application/json' -d '{"sample_size": 1}'
```

`POST /insight` runs the Railtracks agent against your engine and returns:

```json
{
"text": "latency-p95: Latest reading 87.3 ms, trending down over the last 50 samples.\nerror-rate: Holding flat near 2.4 errors/min.\n...",
"generated_at": "2026-06-12T18:42:11.123456+00:00",
"metric_count": 4,
"error": null
}
```

Expect each call to take a few seconds — the agent makes one Anthropic call plus one Railengine GET. The exact latency depends on the model.

## Evaluation

`POST /evals/run` runs Railtracks evaluators against agent sessions and returns the scores. Two modes:

| Mode | Trigger | Cost | What it evaluates |
|---|---|---|---|
| **Fresh** (default) | body omits `agent_run_ids` | ≈ `sample_size · 3` LLM calls | Generates `sample_size` brand-new `/insight` runs (default 1, capped at 5) and scores them. Self-contained smoke test. |
| **Historical** | body sets `agent_run_ids` | ≈ `N · 2` LLM calls (judge only) | Fetches the named past runs from Conductr via [`railtownai.get_agent_runs`](https://pypi.org/project/railtownai/) and scores them as one batch. Replays real production interactions without re-spending generation cost. |

```bash
# Fresh (default)
curl -X POST .../evals/run -H 'Content-Type: application/json' -d '{"sample_size": 1}'

# Historical (one or more runs)
curl -X POST .../evals/run -H 'Content-Type: application/json' \
-d '{"agent_run_ids": ["0466964a-1234-5678-9abc-def012345678"]}'
```

When `RAILTOWN_API_KEY` is set, each `EvaluationResult` also uploads to Conductr via `railtownai.upload_agent_evaluation`.

Three evaluators run per call (see [`agents/evaluations.py`](src/agents/evaluations.py)):

- **`ToolUseEvaluator`** (free, local) — checks the agent's tool call pattern. The system prompt says "use AT MOST 1 tool call to `get_recent_metrics`"; this catches regressions where the model skips it or over-calls.
- **`LLMInferenceEvaluator`** (free, local) — checks the LLM call pattern (latency, tokens, errors).
- **`JudgeEvaluator`** with two custom metrics:
- **`FormatCompliance`** (Compliant / MinorDeviation / MajorDeviation) — does the response follow the strict one-line-per-metric format that the C# card expects to render unchanged?
- **`FactualGrounding`** (FullyGrounded / PartiallyGrounded / Hallucinated) — does every value and trend in the response trace back to the tool output, or is the model inventing things?

Override the judge model via `EVAL_JUDGE_MODEL`; defaults to `LLM_MODEL` or `claude-haiku-4-5-20251001`.

**Historical-mode prerequisites.** The agent fetches sessions from Conductr's platform API, so it needs:

- `CONDUCTR_PROJECT_ID` — already present in deployed environments (the deploy tooling seeds it).
- `CONDUCTR_PROJECT_PAT` — a project-level access token generated in the Conductr UI under *project → Secret Tokens*. Not yet auto-seeded by the deploy tooling; for a deployed agent, set it once with `az keyvault secret set --vault-name <kv> --name CONDUCTR-PROJECT-PAT --value '<token>'` and roll a config-only revision to wire it onto the container.

## Environment variables

| Variable | Required | Purpose |
|---|---|---|
| `ENGINE_ID` | Yes | Railengine engine GUID — same one the C# status page reads from |
| `ENGINE_PAT` | Yes | Railengine PAT used for retrieval |
| `LLM_API_KEY` | Yes | Anthropic API key. Provider-neutral name so the same setting works across agents; on startup the value is copied into `ANTHROPIC_API_KEY` for the Anthropic SDK to pick up. `ANTHROPIC_API_KEY` is also accepted directly if you prefer the SDK-native name. |
| `LLM_MODEL` | No | Override the default Claude model (`claude-haiku-4-5-20251001`) |
| `RAILTOWN_API_KEY` | No | Enables Railtown observability. When set, the agent ships its logs (and any unhandled-exception tracebacks from the global handler) to Railtown via the [`railtownai`](https://pypi.org/project/railtownai/) logging handler. Unset → no observability, agent runs normally. |
| `RAILTOWN_API_URL` | No | Override the Railengine API host (defaults to production) |

Variables are read from `.env` next to `pyproject.toml`, then from the process environment.

## Project layout

```text
controllers (FastAPI) → services → agents (Railtracks) → repositories → rail-engine
models
```

| Path | Role |
|---|---|
| [`src/models/`](src/models/) | Pydantic types — `MetricRecord`, `DailyInsight` |
| [`src/repositories/`](src/repositories/) | [`MetricRepository`](src/repositories/metric_repository.py) — wraps `Railengine.list_storage_documents` |
| [`src/agents/`](src/agents/) | [`tools.py`](src/agents/tools.py) defines the `get_recent_metrics` function node; [`insight_agent.py`](src/agents/insight_agent.py) wires it to `rt.llm.AnthropicLLM` |
| [`src/services/`](src/services/) | [`InsightService`](src/services/insight_service.py) — runs the `rt.Flow` and shapes the response |
| [`src/controllers/`](src/controllers/) | [`api.py`](src/controllers/api.py) — FastAPI app with `/health` and `/insight` |
| [`src/config/`](src/config/) | `.env` loading and required-env validation |

## How this differs from the C# version

The C# [`DailyInsightService`](../../CSharp/Examples/RailenginePoweredStatusPage/Services/DailyInsightService.cs) is a `BackgroundService` that wakes once every 24 hours and posts to `/v1/messages` with the Railengine MCP server (`mcp_servers`) attached, then streams the response. The same prompt and output format are used here.

This Python version:

- Replaces the MCP attachment with a Railtracks `@rt.function_node` tool (`get_recent_metrics`) that calls the Railengine Python SDK directly. The LLM still chooses when to call it, but the schema and execution are local.
- Replaces the 24h `BackgroundService` with an on-demand HTTP endpoint. A scheduler (cron, GitHub Actions, Azure Logic Apps, etc.) can POST `/insight` daily if you want the same cadence.
- Keeps the strict plain-text output rules so the existing C# `Daily Insight` card can render the result unchanged.

## Containerization

A [`Dockerfile`](Dockerfile) is included so the service can be containerized for deployment. The image installs the package via `pyproject.toml` and runs `uvicorn daily_insight.controllers.api:app` on port 8000. Build and run locally with:

```bash
docker build -t daily-insight .
docker run --rm -p 8000:8000 --env-file .env daily-insight
```

If you put the agent behind a reverse proxy that requires bearer auth, the C# caller in [`RailenginePoweredStatusPage`](../../CSharp/Examples/RailenginePoweredStatusPage/) can attach the token via the `DailyInsight:AgentBearerToken` setting — `DailyInsightService.GenerateFromAgentAsync` adds it as `Authorization: Bearer <token>` when non-empty.

## Debug and visualize the agent (optional)

After at least one insight run:

```bash
cd Python/daily-insight
railtracks update
railtracks viz
```

(`railtracks[visual]` is in `pyproject.toml`; run `uv sync` if you have not already.) Opens the local visualization app for inspecting tool calls and prompts.

## Local only

Don't expose this service on the public internet without authentication — `/insight` triggers a paid LLM call and a Railengine read on every invocation.
31 changes: 31 additions & 0 deletions Python/daily-insight/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"

[project]
name = "daily-insight"
version = "0.1.0"
description = "Daily status-page insight demo: Railengine retrieval + Railtracks agent + FastAPI"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"rail-engine>=0.2.1",
"railtracks[visual]>=1.4.1",
"railtownai>=2.0.14",
"pydantic>=2.0",
"python-dotenv>=1.0.0",
"fastapi>=0.110.0",
"uvicorn[standard]>=0.27.0",
]

[tool.setuptools]
package-dir = { "daily_insight" = "src" }
packages = [
"daily_insight",
"daily_insight.agents",
"daily_insight.config",
"daily_insight.controllers",
"daily_insight.models",
"daily_insight.repositories",
"daily_insight.services",
]
3 changes: 3 additions & 0 deletions Python/daily-insight/src/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
"""Daily status-page insight demo: Railengine + Railtracks + FastAPI."""

__version__ = "0.1.0"
3 changes: 3 additions & 0 deletions Python/daily-insight/src/agents/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from daily_insight.agents.insight_agent import build_insight_agent

__all__ = ["build_insight_agent"]
Loading