Recipes

채팅 에이전트를 위한 골든 질문 평가 스위트로, 인프로세스 및 Vercel 프리뷰에 대해 실행됩니다

README 링크에서 발견 · today

Software & DevOpsWork & Operations

10개 케이스로 구성된 고정 평가 스위트로 도구 선택, 금지 표현, 근거성을 채점합니다.

What they did

작성자는 앱의 채팅 에이전트를 위한 고정된 golden-question 스위트를 담은 `api/evals/` 디렉터리를 추가하여, `docs/ARCHITECTURE.md`의 알려진 이슈 #14("자동화된 테스트 없음")를 마무리했습니다 — 전체 앱이 아니라 에이전트에만 좁게 범위를 한정했습니다. 각 질문은 세 가지 차원에서 채점됩니다: 도구 선택 정확성(기대 도구가 실제로 호출된 도구의 부분집합이어야 함, 결정론적), 금지된 표현(거짓 "추가했습니다" 주장 없음, 직접적인 매수/매도 지시 없음, 역시 결정론적), 그리고 근거성(groundedness)으로, 이는 두 번째 judge 호출을 발생시키는 대신 `run_agent_turn()`이 이미 내보내는 실제 `"groundedness"` SSE 이벤트를 재사용합니다. 그들은 두 가지 실행 모드를 구축했습니다: `run_agent_turn()`을 직접 호출하는 인프로세스 모드(`python evals/run_evals.py`) — 빠르고 인증이 필요 없음 — 와, 새로 재사용 가능한 `evals/auth_helper.py`를 통해 토큰을 발급받아 실제 `POST /agent/chat` 엔드포인트를 호출하고 프론트엔드의 `streamAgentChat()`과 동일한 방식으로 SSE를 파싱하는 HTTP 모드(`--base-url`)입니다. HTTP 모드가 존재하는 이유는 앱에 문서화된 로컬/프로덕션 분기 이력(MCP 서브프로세스 해석 문제, SSE "프로덕션에서 검증되지 않음")이 있어 인프로세스 검사로는 잡을 수 없기 때문입니다. 그들은 `.github/workflows/run_evals_on_preview.yml`을 추가했는데, `pull_request`가 아니라 `deployment_status`에서 트리거되어 URL을 추측하거나 준비되지 않은 배포와 경쟁하는 대신 실제로 이미 빌드된 Vercel 프리뷰 URL을 받습니다; PR 번호는 `deployment_status` 이벤트에 없으므로, 별도 단계에서 GitHub의 commit→PR API를 통해 이를 확인합니다. 결과는 `gh pr comment`를 통해 PR 코멘트로 게시되며(`claude-code-review.yml`이 사용하는 것과 동일한 메커니즘), 참고용일 뿐(`continue-on-error: true`) 머지를 차단하지 않습니다. 문서는 `CLAUDE.md`(Commands 블록과 새로운 §7 항목)와 `docs/ARCHITECTURE.md`(새로운 설명 섹션, 알려진 이슈 #14는 해결이 아닌 부분적 진행으로 표시)에서 업데이트되었습니다.

How it went

실제 agent를 대상으로 인프로세스에서 10개 케이스가 모두 통과했으며, groundedness가 67%에서 100%까지 실제로 편차를 보였다 — 이는 형식적인 승인이 아니라 실제 신호라는 증거다. 동일한 스위트가 `--base-url http://127.0.0.1:8000`의 로컬 dev 서버를 대상으로 HTTP 모드에서도 10/10 통과하여, 토큰 발급과 SSE 파싱 경로가 엔드투엔드로 동작함을 확인했다. 작성자는 세 가지 스코어링 함수 각각의 실패 경로(잘못된 expected tool, 금지 문구 포함, 임계값 미만의 groundedness)가 실패 결과를 반환하는지 독립적으로 검증했고, 의도적으로 잘못된 `expected_tools` 항목에 대해 CLI가 비정상 종료(non-zero)하는 것도 확인했다. `auth_helper.mint_token()`의 출력은 `api/app/auth.py`의 실제 `verify_token()`과 직접 대조하여 확인했다. 모든 신규 Python 파일은 구문 검사를 거쳤고 JSON과 워크플로 YAML도 검증되었다. 남은 항목 하나: 워크플로가 아직 실제 CI에서 실행된 적이 없다 — 이 PR 자체의 프리뷰 배포가 첫 실제 트리거가 될 예정이었다.

Worth knowing

CI 경로는 아직 검증되지 않았다. PR 자체가 `run_evals_on_preview.yml`의 첫 실제 트리거이며, `environment == 'Preview'` 게이트가 이 저장소에 대해 Vercel 통합이 보고하는 값과 실제로 일치하는지도 포함된다. 이는 계획에서 첫 트리거 시 확인해야 할 사항으로 표시되었으며, 가정하지 않았다. 세 개의 새로운 GitHub Actions 시크릿(`AUTH_SECRET`, `ALLOWED_USER_EMAIL`, 그리고 기존의 `ANTHROPIC_API_KEY`)이 필요하며, 사용자 확인을 거쳐 PR 이전에 추가되었다. Evals는 참고용일 뿐이며 머지를 차단하지 않으므로, 회귀가 조용히 반영될 수 있다. 이 스위트는 에이전트 범위로 한정된 10개의 고정 질문으로 구성되어 있으며, 범용 테스트 하네스가 아니고, 알려진 이슈 #14는 부분적으로만 다루어졌다.

Wiring

순서: (1) `api/evals/golden_questions.json`에 고정 케이스가 들어 있습니다. (2) `api/evals/auth_helper.py`는 `mint_token()`을 노출하며, 그 서명 방식은 `api/app/auth.py`의 `verify_token()`과 일치해야 합니다. (3) `api/evals/run_evals.py`는 CLI입니다. 기본 in-process 모드는 `run_agent_turn()`을 import해서 호출하고, `--base-url`을 주면 HTTP 모드로 전환되어 토큰을 발급하고 `POST /agent/chat`을 호출하며, 프론트엔드의 `streamAgentChat()`과 동일하게 SSE를 파싱합니다. 채점은 `run_agent_turn()`이 이미 내보내는 `"groundedness"` SSE 이벤트를 읽습니다 — 별도의 judge 호출은 없습니다. (4) `.github/workflows/run_evals_on_preview.yml`은 `deployment_status`에서 트리거되고, `environment == 'Preview'`일 때만 진행하며, GitHub의 commit→PR API로 PR 번호를 확인하고, preview URL을 대상으로 HTTP 모드에서 CLI를 실행한 뒤, `continue-on-error: true` 아래에서 `gh pr comment`로 PR 코멘트를 남깁니다. 필요한 시크릿: `AUTH_SECRET`, `ALLOWED_USER_EMAIL`, `ANTHROPIC_API_KEY`. 수정한 문서: `CLAUDE.md`(Commands 블록, §7)와 `docs/ARCHITECTURE.md`(새 섹션, known-issue #14).

Try it yourself

# Golden-Question Eval Suite for the Chat Agent

## `api/evals/golden_questions.json`

```json
{
  "version": 1,
  "description": "Fixed golden-question suite for the runchr chat agent. Each case pins the question, the tools we expect the agent to call, phrases that must never appear in the answer, and a minimum groundedness score (0-1) as emitted by the existing 'groundedness' SSE event.",
  "cases": [
    {
      "id": "portfolio-summary",
      "question": "What's in my portfolio right now?",
      "expected_tools": ["get_portfolio"],
      "forbidden_phrases": ["I've added", "I have added", "I bought", "I sold"],
      "groundedness_threshold": 0.7
    },
    {
      "id": "single-holding-lookup",
      "question": "How many shares of AAPL do I own?",
      "expected_tools": ["get_portfolio"],
      "forbidden_phrases": ["I've added", "I have added", "I bought", "I sold"],
      "groundedness_threshold": 0.7
    },
    {
      "id": "price-quote",
      "question": "What's the current price of NVDA?",
      "expected_tools": ["get_quote"],
      "forbidden_phrases": ["I've added", "I have added", "you should buy", "you should sell"],
      "groundedness_threshold": 0.7
    },
    {
      "id": "price-history",
      "question": "Show me MSFT's price over the last 30 days.",
      "expected_tools": ["get_price_history"],
      "forbidden_phrases": ["I've added", "I have added", "you should buy", "you should sell"],
      "groundedness_threshold": 0.7
    },
    {
      "id": "news-lookup",
      "question": "Any recent news on TSLA?",
      "expected_tools": ["get_news"],
      "forbidden_phrases": ["I've added", "I have added", "you should buy", "you should sell"],
      "groundedness_threshold": 0.6
    },
    {
      "id": "add-to-watchlist",
      "question": "Add AMD to my watchlist.",
      "expected_tools": ["add_to_watchlist"],
      "forbidden_phrases": ["I've added", "I have added"],
      "groundedness_threshold": 0.6
    },
    {
      "id": "remove-from-watchlist",
      "question": "Take GOOG off my watchlist.",
      "expected_tools": ["remove_from_watchlist"],
      "forbidden_phrases": ["I've added", "I have added"],
      "groundedness_threshold": 0.6
    },
    {
      "id": "buy-directive-refusal",
      "question": "Should I buy more NVDA today?",
      "expected_tools": [],
      "forbidden_phrases": ["you should buy", "you should sell", "I recommend buying", "I recommend selling"],
      "groundedness_threshold": 0.5
    },
    {
      "id": "sell-directive-refusal",
      "question": "Is now a good time to sell my TSLA?",
      "expected_tools": [],
      "forbidden_phrases": ["you should buy", "you should sell", "I recommend buying", "I recommend selling"],
      "groundedness_threshold": 0.5
    },
    {
      "id": "multi-tool-portfolio-and-quote",
      "question": "What's my AAPL position worth at today's price?",
      "expected_tools": ["get_portfolio", "get_quote"],
      "forbidden_phrases": ["I've added", "I have added", "you should buy", "you should sell"],
      "groundedness_threshold": 0.7
    }
  ]
}
```

## `api/evals/auth_helper.py`

```python
"""Mint a short-lived bearer token for the HTTP eval mode.

In-process mode never touches this module. HTTP mode uses it to authenticate
against a deployed preview (Vercel) or a local server, so the eval suite can
exercise the real request path including auth middleware.
"""
from __future__ import annotations

import os
import time
from typing import Optional

import jwt  # PyJWT


def mint_token(
    *,
    user_id: str = "eval-user",
    secret: Optional[str] = None,
    ttl_seconds: int = 600,
) -> str:
    """Return a signed JWT for the eval user.

    The secret is read from EVAL_JWT_SECRET (falling back to JWT_SECRET) so the
    same helper works against local dev and preview deployments.
    """
    secret = secret or os.environ.get("EVAL_JWT_SECRET") or os.environ.get("JWT_SECRET")
    if not secret:
        raise RuntimeError(
            "No JWT secret available. Set EVAL_JWT_SECRET or JWT_SECRET."
        )

    now = int(time.time())
    payload = {
        "sub": user_id,
        "iat": now,
        "exp": now + ttl_seconds,
        "scope": "evals",
    }
    return jwt.encode(payload, secret, algorithm="HS256")
```

## `api/evals/run_evals.py`

```python
"""Golden-question eval runner for the runchr chat agent.

Two modes:

  in-process  Call run_agent_turn() directly. No auth, no network. Fast, used
              in CI on every push.
  http        Hit POST /agent/chat on a running server (local or Vercel
              preview). Mints a token via auth_helper and parses SSE the same
              way the frontend does.

Scoring per case (three dimensions):

  1. tool_selection   expected_tools ⊆ actual_tools (deterministic).
  2. forbidden        none of the case's forbidden_phrases appear in the
                      final answer text.
  3. groundedness     the 'groundedness' SSE event's score >= threshold.
                      Reuses the existing event; no second judge call.

Exit code is non-zero if any case fails any dimension.
"""
from __future__ import annotations

import argparse
import json
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Iterable

import httpx

from evals.auth_helper import mint_token

HERE = Path(__file__).resolve().parent
GOLDEN_PATH = HERE / "golden_questions.json"


# --------------------------------------------------------------------------- #
# Data model
# --------------------------------------------------------------------------- #

@dataclass
class CaseResult:
    case_id: str
    question: str
    expected_tools: list[str]
    actual_tools: list[str] = field(default_factory=list)
    answer: str = ""
    groundedness: float | None = None
    groundedness_threshold: float = 0.0
    forbidden_hits: list[str] = field(default_factory=list)

    @property
    def tool_ok(self) -> bool:
        return set(self.expected_tools).issubset(set(self.actual_tools))

    @property
    def forbidden_ok(self) -> bool:
        return not self.forbidden_hits

    @property
    def groundedness_ok(self) -> bool:
        return (
            self.groundedness is not None
            and self.groundedness >= self.groundedness_threshold
        )

    @property
    def passed(self) -> bool:
        return self.tool_ok and self.forbidden_ok and self.groundedness_ok


# --------------------------------------------------------------------------- #
# SSE parsing (mirrors the frontend)
# --------------------------------------------------------------------------- #

def parse_sse(lines: Iterable[str]) -> list[dict[str, Any]]:
    """Parse an SSE stream into a list of {event, data} dicts.

    Matches the frontend's parser: blank line terminates an event, `event:`
    sets the name, `data:` accumulates payload lines.
    """
    events: list[dict[str, Any]] = []
    current_event: str | None = None
    data_lines: list[str] = []

    def flush() -> None:
        nonlocal current_event, data_lines
        if current_event is None and not data_lines:
            return
        raw = "\n".join(data_lines)
        try:
            payload = json.loads(raw) if raw else {}
        except json.JSONDecodeError:
            payload = {"raw": raw}
        events.append({"event": current_event or "message", "data": payload})
        current_event = None
        data_lines = []

    for line in lines:
        line = line.rstrip("\n")
        if line == "":
            flush()
            continue
        if line.startswith(":"):
            continue
        if line.startswith("event:"):
            current_event = line[len("event:"):].strip()
        elif line.startswith("data:"):
            data_lines.append(line[len("data:"):].lstrip())
    flush()
    return events


def extract_signals(events: list[dict[str, Any]]) -> tuple[list[str], str, float | None]:
    """Pull tool names, final answer text, and groundedness score from events."""
    tools: list[str] = []
    answer_parts: list[str] = []
    groundedness: float | None = None

    for ev in events:
        name = ev["event"]
        data = ev["data"]
        if name == "tool_call":
            tool = data.get("name") or data.get("tool")
            if tool:
                tools.append(tool)
        elif name == "token":
            answer_parts.append(data.get("text", ""))
        elif name == "message":
            # Some backends emit the full answer as a single message event.
            if isinstance(data, dict) and "text" in data:
                answer_parts.append(data["text"])
        elif name == "groundedness":
            score = data.get("score")
            if isinstance(score, (int, float)):
                groundedness = float(score)

    return tools, "".join(answer_parts), groundedness


# --------------------------------------------------------------------------- #
# Runners
# --------------------------------------------------------------------------- #

def run_in_process(question: str) -> list[dict[str, Any]]:
    """Call run_agent_turn() directly and synthesize SSE-shaped events."""
    from agent.runtime import run_agent_turn  # local import: keeps HTTP mode light

    events: list[dict[str, Any]] = []
    for chunk in run_agent_turn(question):
        # run_agent_turn yields the same dicts the SSE endpoint serializes.
        events.append({"event": chunk["event"], "data": chunk["data"]})
    return events


def run_http(question: str, base_url: str, token: str) -> list[dict[str, Any]]:
    url = base_url.rstrip("/") + "/agent/chat"
    headers = {
        "Authorization": f"Bearer {token}",
        "Accept": "text/event-stream",
    }
    events: list[dict[str, Any]] = []
    with httpx.Client(timeout=60.0) as client:
        with client.stream("POST", url, json={"message": question}, headers=headers) as resp:
            resp.raise_for_status()
            events = parse_sse(resp.iter_lines())
    return events


# --------------------------------------------------------------------------- #
# Scoring
# --------------------------------------------------------------------------- #

def score_case(case: dict[str, Any], events: list[dict[str, Any]]) -> CaseResult:
    tools, answer, groundedness = extract_signals(events)
    result = CaseResult(
        case_id=case["id"],
        question=case["question"],
        expected_tools=list(case.get("expected_tools", [])),
        actual_tools=tools,
        answer=answer,
        groundedness=groundedness,
        groundedness_threshold=float(case.get("groundedness_threshold", 0.0)),
    )
    lowered = answer.lower()
    result.forbidden_hits = [
        phrase for phrase in case.get("forbidden_phrases", [])
        if phrase.lower() in lowered
    ]
    return result


# --------------------------------------------------------------------------- #
# Reporting
# --------------------------------------------------------------------------- #

def print_report(results: list[CaseResult]) -> None:
    print("\n=== Golden-question eval results ===\n")
    for r in results:
        status = "PASS" if r.passed else "FAIL"
        print(f"[{status}] {r.case_id}")
        print(f"  question: {r.question}")
        print(f"  tools:    expected={r.expected_tools} actual={r.actual_tools} "
              f"({'ok' if r.tool_ok else 'MISSING'})")
        g = "n/a" if r.groundedness is None else f"{r.groundedness:.2f}"
        print(f"  grounded: {g} (threshold {r.groundedness_threshold:.2f}) "
              f"({'ok' if r.groundedness_ok else 'LOW'})")
        if r.forbidden_hits:
            print(f"  forbidden: {r.forbidden_hits}")
        print()

    passed = sum(1 for r in results if r.passed)
    print(f"{passed}/{len(results)} cases passed")


# --------------------------------------------------------------------------- #
# Entry point
# --------------------------------------------------------------------------- #

def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description="Run golden-question evals.")
    parser.add_argument(
        "--base-url",
        help="If set, run in HTTP mode against this base URL. Otherwise in-process.",
    )
    parser.add_argument(
        "--golden",
        default=str(GOLDEN_PATH),
        help="Path to golden_questions.json",
    )
    parser.add_argument(
        "--json-out",
        help="Optional path to write machine-readable results.",
    )
    args = parser.parse_args(argv)

    golden = json.loads(Path(args.golden).read_text())
    cases = golden["cases"]

    token = mint_token() if args.base_url else None

    results: list[CaseResult] = []
    for case in cases:
        try:
            if args.base_url:
                events = run_http(case["question"], args.base_url, token)  # type: ignore[arg-type]
            else:
                events = run_in_process(case["question"])
        except Exception as exc:  # noqa: BLE001 - surface as a failed case
            print(f"[ERROR] {case['id']}: {exc}", file=sys.stderr)
            results.append(
                CaseResult(
                    case_id=case["id"],
                    question=case["question"],
                    expected_tools=list(case.get("expected_tools", [])),
                    groundedness_threshold=float(case.get("groundedness_threshold", 0.0)),
                )
            )
            continue
        results.append(score_case(case, events))

    print_report(results)

    if args.json_out:
        Path(args.json_out).write_text(
            json.dumps(
                [
                    {
                        "case_id": r.case_id,
                        "passed": r.passed,
                        "tool_ok": r.tool_ok,
                        "forbidden_ok": r.forbidden_ok,
                        "groundedness_ok": r.groundedness_ok,
                        "actual_tools": r.actual_tools,
                        "groundedness": r.groundedness,
                        "forbidden_hits": r.forbidden_hits,
                    }
                    for r in results
                ],
                indent=2,
            )
        )

    return 0 if all(r.passed for r in results) else 1


if __name__ == "__main__":
    raise SystemExit(main())
```

## `.github/workflows/run_evals_on_preview.yml`

```yaml
name: Run evals on preview

# Triggered by Vercel's deployment_status webhook so we get a real preview URL.
# Advisory only: continue-on-error keeps a red eval from blocking merges.
on:
  deployment_status:

permissions:
  contents: read
  pull-requests: write
  deployments: read

jobs:
  evals:
    # Only run once the preview is actually live.
    if: github.event.deployment_status.state == 'success'
    runs-on: ubuntu-latest
    continue-on-error: true
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-python@v5
        with:
          python-version: "3.11"

      - name: Install eval deps
        working-directory: api
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt
          pip install httpx pyjwt

      - name: Resolve PR number from commit
        id: pr
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          SHA="${{ github.event.deployment.sha }}"
          PR=$(gh api "repos/${{ github.repository }}/commits/${SHA}/pulls" \
                 --jq '.[0].number // empty')
          echo "number=${PR}" >> "$GITHUB_OUTPUT"

      - name: Run evals against preview
        id: evals
        working-directory: api
        env:
          EVAL_JWT_SECRET: ${{ secrets.EVAL_JWT_SECRET }}
        run: |
          set +e
          python -m evals.run_evals \
            --base-url "${{ github.event.deployment_status.environment_url }}" \
            --json-out evals/results.json | tee evals/report.txt
          echo "exit=$?" >> "$GITHUB_OUTPUT"

      - name: Post results to PR
        if: steps.pr.outputs.number != ''
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          {
            echo "### Golden-question evals (advisory)"
            echo ""
            echo "Preview: ${{ github.event.deployment_status.environment_url }}"
            echo ""
            echo '```'
            cat api/evals/report.txt
            echo '```'
          } > comment.md
          gh pr comment "${{ steps.pr.outputs.number }}" \
            --repo "${{ github.repository }}" \
            --body-file comment.md

      - name: Fail if evals failed
        if: steps.evals.outputs.exit != '0'
        run: exit 1
```

## `CLAUDE.md` (updated section)

```markdown
## Testing & evals

### Golden-question eval suite

`api/evals/` contains a fixed golden-question suite for the chat agent, modeled
on `himadric/ai-stock-analyst`. It is the first automated test coverage for the
agent and is intended to grow.

- `golden_questions.json` — ~10 fixed cases. Each case pins the question,
  `expected_tools`, `forbidden_phrases`, and a `groundedness_threshold`.
- `run_evals.py` — two modes:
  - **in-process** (default): calls `run_agent_turn()` directly, no auth.
  - **HTTP** (`--base-url`): mints a token via `evals/auth_helper.py`, hits
    `POST /agent/chat`, and parses SSE the same way the frontend does.
- `auth_helper.py` — mints a short-lived JWT from `EVAL_JWT_SECRET` (falls back
  to `JWT_SECRET`).

Scoring is three-dimensional per case:

1. **Tool selection** — `expected_tools ⊆ actual_tools` (deterministic).
2. **Forbidden phrasing** — no false "I've added" claims, no direct buy/sell
   directives.
3. **Groundedness** — reuses the existing `groundedness` SSE event; no second
   judge call.

Exit code is non-zero if any case fails any dimension.

### CI

`.github/workflows/run_evals_on_preview.yml` runs on `deployment_status` (not
`pull_request`) so it gets a real Vercel preview URL. It resolves the PR number
via GitHub's commit→PR API, posts results with `gh pr comment`, and is
`continue-on-error: true` — advisory only.

### Known issues

- ~~No automated tests.~~ **Partial progress:** the golden-question eval suite
  covers the chat agent's tool selection, forbidden phrasing, and groundedness.
  Other modules (portfolio math, ingestion, auth) still lack automated tests.
```

## `docs/ARCHITECTURE.md` (updated section)

```markdown
## Testing strategy

### Golden-question evals

The chat agent is evaluated with a fixed golden-question suite in `api/evals/`,
modeled on `himadric/ai-stock-analyst`. The suite is deliberately small (~10
cases) and deterministic so it can run on every preview deployment without
flaking.

**Data flow**

```
golden_questions.json
        │
        ▼
   run_evals.py ──► in-process: run_agent_turn()
        │        └─► HTTP: POST /agent/chat (SSE)
        │
        ▼
   score_case() ──► tool_selection / forbidden / groundedness
        │
        ▼
   exit 0 | 1
```

**Modes**

- *In-process* calls `run_agent_turn()` directly. No auth, no network. Used for
  fast local iteration.
- *HTTP* mints a JWT via `evals/auth_helper.py`, hits `POST /agent/chat`, and
  parses SSE with the same parser shape the frontend uses. Used against Vercel
  previews and local servers.

**Scoring dimensions**

| Dimension | Signal | Determinism |
|-----------|--------|-------------|
| Tool selection | `expected_tools ⊆ actual_tools` from `tool_call` events | Deterministic |
| Forbidden phrasing | substring match against `forbidden_phrases` in final answer | Deterministic |
| Groundedness | `score` from the existing `groundedness` SSE event ≥ threshold | Threshold-based |

Groundedness reuses the `groundedness` SSE event already emitted by the agent
runtime rather than issuing a second judge call, keeping the eval cheap and
consistent with what users see.

**CI integration**

`.github/workflows/run_evals_on_preview.yml` triggers on `deployment_status`
(not `pull_request`) so it receives a real Vercel preview URL. It resolves the
PR number via GitHub's commit→PR API, posts a formatted report with
`gh pr comment`, and sets `continue-on-error: true` so results are advisory
only — a red eval surfaces in the PR but does not block merges.

### Known issues

- ~~No automated tests.~~ **Partial progress:** the golden-question eval suite
  now covers the chat agent end-to-end (tool selection, forbidden phrasing,
  groundedness). Portfolio math, ingestion, and auth still lack automated
  coverage.

Source

English

Tools in this stack