Skip to content

ci(github): add Actions workflow for backend + frontend - #6

Merged
elkaix merged 69 commits into
mainfrom
ci/github-actions
Apr 27, 2026
Merged

elkaix merged 69 commits into
mainfrom
ci/github-actions

Conversation

@elkaix

@elkaix elkaix commented Apr 27, 2026

Copy link
Copy Markdown
Owner

Summary

Adds the first GitHub Actions workflow for the repo. The repo had no CI before this PR.

  • Backend job: Python 3.12 on Ubuntu, runs the full pytest suite (279 tests) with coverage. Caches pip and Hugging Face models so BgeEmbedder / cross-encoder weights aren't re-downloaded on every run.
  • Frontend job: Node 20 on Ubuntu, runs `npm ci` + `npm run lint` + `npm run build`.
  • Triggers: push to `main` and PRs targeting `main` or any `feature/**` branch (so stacked PRs like Phase 2 also get checked).
  • Concurrency control: superseded runs on the same ref are cancelled to save runner minutes.
  • Read-only token: `permissions: contents: read` — no write actions.
  • Job timeouts: 20 min backend, 10 min frontend.

Test plan

  • Full pytest suite passes locally with no API keys / no telemetry endpoint set (279 passed)
  • Frontend `npm run lint` clean
  • Frontend `npm run build` succeeds
  • Reviewer: this PR's own CI run completes green

elkaix added 30 commits April 26, 2026 20:54
Introduces six typed models (EvalQuestion, EvalResult, AggregatedMetric,
RunMetadata, MetricDelta, CompareResult) that form the data contract layer
for the RAG evaluation harness. Each model carries field-level constraints
(score range [0,1], positive n, tz-aware timestamps) and educational
docstrings explaining design decisions and pipeline position.

30 tests cover validation paths for all six schemas.
Pure-function implementations of the three standard IR metrics with
binary relevance. Empty gold set returns NaN (undefined, not zero)
so aggregators can skip those rows per-metric. 20 tests cover perfect,
partial, zero, truncation, and empty-gold cases for all three functions.
Introduces six Pydantic v2 models that serve as the typed contract
layer between all eval harness components: EvalQuestion (frozen gold
set), EvalResult (per-question output), AggregatedMetric, RunMetadata
(provenance), MetricDelta, and CompareResult.
Implements src/eval/metrics/generation.py with:
- context_recall: set-overlap / |gold|, NaN on empty gold
- answer_correctness: mean of embedding cosine + LLM factual-match judge
- judge_faithfulness / judge_answer_relevancy / judge_context_precision:
  thin wrappers over src/evaluation.py returning (score, details_dict)

Also installs sentence-transformers (transitive chromadb dep) for the
all-MiniLM-L6-v2 embedder used by answer_correctness. All 10 tests pass.
Add the ML Papers v1 dev-set scaffolding: corpus manifest (SHA-256-pinned
PDFs), empty questions.jsonl, labeling guide (eval contract), dataset
loader with SHA verification, and 8 passing tests.
Implements five functions (compute_run_id, save_run, load_run,
list_runs, delete_run) that persist and retrieve eval run artifacts as
plain JSON/JSONL files under EVAL_RUNS_DIR. Env-overridable root dir
enables isolated tmp-path testing. Path-traversal guard on delete_run
validates run_id before any filesystem call.

All 10 unit tests pass.
Extract token counting and tiktoken fallback logic into a dedicated
_telemetry.py module to bring pipeline_factory.py under the 250-line
ceiling.

Changes:
  - New src/eval/_telemetry.py (60 lines): count_tokens function + tiktoken
    import with word-count fallback. Kept separate to signal it's internal
    to the eval package (leading underscore).
  - Updated src/eval/pipeline_factory.py (334 lines, was 375):
    - Remove tiktoken import + _tiktoken_warned global
    - Remove _count_tokens helper
    - Import count_tokens from _telemetry
    - Update call sites (count_tokens instead of _count_tokens)

Public API unchanged: build_pipeline() and EvalPipeline are still
exported from pipeline_factory and can be imported by callers.

All 3 tests still pass.
Implements aggregate() which turns list[EvalResult] into
list[AggregatedMetric] with bootstrap CIs. Produces one row per
(metric, dataset) pair plus a combined row (dataset=None). Skips
combos with <3 samples and emits warning strings. Errored results
are excluded before grouping.
Implement compare_runs() which loads two eval runs, validates their
eval_set_versions match, pairs per-question scores by question_id, and
runs a paired permutation test per (metric, dataset) combo to produce
MetricDelta objects with p-values and significance flags. Also picks
top-10 per-question regressions/wins by |delta| on the headline metric.
Implements four subcommands (run/list/show/compare) driven by argparse.
Supports EVAL_LLM_OVERRIDE_DUMMY and EVAL_SQUAD_PATH env vars for
test injection without touching production code paths.
Introduces RunRegistry backed by a threading.Lock-guarded dict to track
in-flight eval runs across queued/running/completed/failed states.
Supports incremental progress updates, TTL-based eviction of finished
runs, and never evicts active runs regardless of age.
elkaix and others added 28 commits April 27, 2026 00:16
…metry

Add query_with_telemetry() sibling and stream_query telemetry event:

- query_with_telemetry() wraps retrieve + generate each in an OTel span
  (rag.retrieve / rag.generate via get_tracer()), times both phases with
  time.perf_counter(), counts prompt and completion tokens via count_tokens(),
  and computes cost_usd(). Returns (result_dict, StageTelemetry).

- query() becomes a thin wrapper that calls query_with_telemetry() and
  discards the telemetry — existing tests and call sites are unaffected.

- stream_query() gains retrieve and generate spans and yields a final
  ("telemetry", StageTelemetry.model_dump()) event AFTER ("done", ...).
  All existing event shapes (status, reasoning, token, done) are unchanged.
  The empty-results early-return branch also emits a zero telemetry event
  so the route layer always receives one.

Option A (sibling) chosen over modifying query() in-place because existing
tests assert dict access on query()'s return value; changing to a tuple
would break them with no benefit — the route layer (Task 5) calls the
new sibling directly.
POST /api/query now calls query_with_telemetry() instead of query() and
returns a `telemetry` field (StageTelemetry) alongside the existing answer,
sources, confidence, and latency_ms fields.

The WebSocket /api/chat handler gains an explicit branch for the
("telemetry", dict) event emitted by stream_query() after done, forwarding
it as {"type": "telemetry", "content": {...}} so clients can render
per-stage timing and cost without polling.

QueryResponse in models.py is extended with an Optional[StageTelemetry]
field (defaults to None for backward compatibility). StageTelemetry is
imported once at module level via models.py to keep the routes clean.

Nine route-layer tests added in tests/test_api_query_telemetry.py using
mocked backends — isolating serialization from real LLM / ChromaDB I/O.
Add TelemetryPayload interface and WsTelemetryMessage to types.ts,
extend ChatMessage with an optional telemetry field, and wire up a
new "telemetry" case in the useChat WebSocket handler so per-request
timing and cost data is attached to the assistant message for the
upcoming TelemetryFooter component.
Renders a muted xs one-liner (Retrieve · Generate · Tokens · Cost) below
every assistant bubble that carries a telemetry payload. A hover tooltip
surfaces the full prompt/completion token breakdown. Uses the existing
@base-ui/react Tooltip primitive (already wrapped in ui/tooltip.tsx and
provided at App root) with the render-prop pattern to avoid an implicit
button role on a purely informational element.
…rofile

Call init_observability(otlp_endpoint=os.getenv("OTLP_ENDPOINT")) at the
end of the FastAPI lifespan startup sequence so OTel tracing is configured
once at server boot. The function is fail-quiet — if Phoenix is unreachable
it logs a warning and spans become no-ops; no env var gate needed.

Add a profile-gated `phoenix` service (arizephoenix/phoenix:latest, port
6006) to docker-compose.yml so bare `docker compose up` is unaffected.

Document the --profile observability workflow in README Quick Start.
Add two new top-level sections to Architecture.md covering the eval
harness (src/eval/ module table, API endpoints, run directory layout)
and observability (OTel spans, StageTelemetry payload, Phoenix profile).
…t warnings

The two .map() calls inside TelemetryFooter wrapped each iteration in a
short-form `<>...</>` fragment. Short-form fragments cannot accept keys,
so React logged "Each child in a list should have a unique 'key' prop"
under TooltipTrigger. Caught while UI-smoke-testing the chat telemetry
footer in compose. Switching to keyed `<Fragment key={...}>` removes the
warning without changing the rendered DOM.
…unDialog

When /api/eval/configs returned [], the dialog rendered a single-option
disabled native <select> reading "No configs available". On macOS the
native dropdown opens to show that one option with a checkmark over the
trigger, producing a ghosted/duplicated text effect that looks like a
rendering bug. With no choosable configs, the select adds no value —
swapping it for a dashed-border hint that points users at configs/eval/
makes the empty state explicit and removes the visual artifact.
…es eval artifacts

The api container had no view of host-side eval YAML configs or run
directories, so /api/eval/configs and /api/eval/runs both returned empty
even after a host-CLI run. Adding bind mounts for ./configs and ./eval_runs
lets the host CLI and the in-container API share artifacts without copying.

Also adds configs/eval/baseline_squad_only.yaml — a convenience baseline
that targets only squad_v2_dev_200 for runs taken before ml_papers_v1 is
hand-labeled.
…ainst the page top

The /eval list view rendered its toolbar (filter + Compare + New Run) and
table directly against the top of <main> while sibling eval routes
(CompareView, RunDetail) wrap their content in p-6. Adding the same p-6
wrapper around the body restores consistent breathing room across all three
eval routes.
Design spec for Phase 2 of the RAG portfolio: a 10-run layered ablation
matrix targeting 7 architectural levers (semantic chunking, BGE embedder,
BM25-hybrid retrieval, cross-encoder reranking, LLM query rewriting,
answer-model sweep, refusal handling) on top of the Phase 1 SQuAD-200
baseline. Spec covers schema extensions, factory contract, tier configs,
testing strategy, and a 2-PR delivery sequence (extensions then
experiments + writeup).
Resolves seven issues raised in review:

- H1: Drop tier 2a (semantic chunking). The SQuAD ingest path
  bypasses the chunker entirely (each question's gold context is one
  Chroma document), so toggling chunker.strategy can't produce
  measurable lift. Lever deferred to Phase 3 (ml_papers_v1).
  Matrix shrinks to 9 YAMLs / 8 distinct runs.

- H2: Correct the factory references. The repo has
  src/eval/pipeline_factory.py::build_pipeline(config, dataset_name, ...)
  and src/eval/runner.py — not the src/eval/runner/ subpackage layout
  the spec assumed. Updated module map, factory contract code samples,
  and PR-A commit list.

- H3: Specify BGE wiring. BgeEmbedder is a Chroma EmbeddingFunction
  adapter installed on the collection at creation time inside
  build_pipeline. Auto-embedding via the collection function then
  works through ChromaVectorStore unchanged. Each run rebuilds the
  collection so dimension mixing is impossible.

- M4: Make the $5 ceiling enforceable. Add EvalResult.cost_breakdown
  covering generator + judge + rewriter call sites; aggregator and
  cost.json sum all three; new EvalCfg.spend_ceiling_usd causes the
  runner to abort if cumulative cost exceeds it.

- M5: Reframe tier 2f from a layered chain link to a parallel
  three-way answer-model comparison on the full 2g stack. Tier 2g
  pins generator.model to gpt-5-mini so the chain's model variable
  stays constant.

- M6: Archive run artifacts to docs/phase2/runs/ (tracked) via a
  new src/eval/cli.py archive subcommand. eval_runs/ stays
  gitignored; only metrics.json, cost.json, metadata.json,
  config.yaml are committed in PR-B. The full questions.jsonl SHA
  is recorded in metadata.json for verification.

- L7: Replace the RRF unit-test inputs (which produced tied scores)
  with asymmetric inputs A=[a,b,c,d], B=[d,a] yielding fused order
  a, d, b, c with all four scores distinct.
Per-task TDD plan for the two-PR Phase 2 delivery (extensions + experiments):

PR-A — 10 tasks landing 5 new pipeline modules (BgeEmbedder,
BM25HybridRetriever, CrossEncoderReranker, QueryRewriter, RefusalHandler),
the schema extension, the cost-ledger refactor (covering generator + judge
+ rewriter), the spend-ceiling guardrail, the cli archive subcommand, the
factory wiring, and the 9 tier YAMLs under configs/eval/phase2/.

PR-B — 10 tasks executing 8 distinct evals against SQuAD-200, archiving the
small artifacts to docs/phase2/runs/ via the new archive command, generating
7 pairwise compare reports under docs/phase2/compare/, and shipping
docs/PHASE2_RESULTS.md with chart, paired-significance table, winning stack,
findings, and cost ledger.

Each task lists exact file paths, complete code blocks for every step
(test → fail → impl → pass → commit), the matching pytest commands, and
the expected output. Self-review confirms full spec coverage, no
placeholders in code-producing steps, and type consistency across tasks.
…ling

Add EmbedderCfg, HybridCfg, RerankerCfg, QueryRewriterCfg, and RefusalHandlerCfg
sub-configs to PipelineCfg; add spend_ceiling_usd to EvalCfg. All new fields
default to off/None so existing baseline YAML configs load unchanged. All
sub-configs use extra="forbid" to catch typos at validation time.
- Add cost_breakdown field to EvalResult with a model_validator that
  backfills {generator: cost_usd, judge: 0, rewriter: 0} for Phase 1
  records so JSON round-trips remain backward-compatible.
- Extend aggregate_costs() to return generator_total_usd,
  judge_total_usd, and rewriter_total_usd alongside the existing
  total_usd and mean_usd_per_query keys.
- Add LLMHandler.generate_with_usage() which wraps generate() and
  returns (text, prompt_tokens, completion_tokens) using the existing
  count_tokens telemetry helper — no changes to existing generate()
  callers.
- Wire spend_ceiling_usd enforcement into EvalRunner.run(): aborts
  with RuntimeError when cumulative cost_usd exceeds the ceiling.
Implements BAAI/bge-small-en-v1.5 as a pluggable Chroma EmbeddingFunction
(Phase 2 lever 2b). The adapter caches the SentenceTransformer model on the
instance and lazy-imports it so module-level imports stay cheap.

Note: test_returns_384_dim_vectors accepts (float, np.floating) rather than
float alone — chromadb 1.5.8's EmbeddingFunction.__init_subclass__ wraps
every __call__ with normalize_embeddings(), which always converts scalars to
numpy.float32 regardless of what the adapter returns.
Implements Phase 2 lever 2c: sparse BM25 + dense Chroma retrieval fused
via Reciprocal Rank Fusion (rrf_k=60 default). Adds rank-bm25==0.2.2
dependency. Two tests cover the RRF math in isolation and the end-to-end
hybrid retrieval path.
Implements src/eval/transforms/QueryRewriter: takes a query string,
calls any generate_with_usage-compatible LLM handler to produce alternative
phrasings, deduplicates (original always first), caps at max_expansions,
and returns (queries, cost_usd, prompt_tokens, completion_tokens). Falls
back to [query] pass-through when model=None or LLM returns non-JSON.
Wires into src.eval.pricing.cost_usd for accurate per-call cost tracking.
Implements Phase 2 lever 2g: a deterministic short-circuit that returns
configured no-answer text when top-1 retrieval similarity falls below a
threshold, improving refusal_correctness on SQuAD v2 unanswerable questions.
EvalPipeline gains four new optional fields (hybrid_retriever, reranker,
rewriter, refusal_handler) and a layered query() method that runs each
lever as a no-op when disabled — preserving full backward compatibility
with Phase 1 callers and tests.

build_pipeline() now:
- Selects the Chroma EmbeddingFunction via _build_embedding_function
  (lever 2b: chroma_default vs bge_small_en_v1_5)
- Builds reranker, rewriter, refusal_handler eagerly at construction time
- Defers hybrid_retriever to _ingest_squad / _ingest_ml_papers because
  BM25HybridRetriever needs the full chunk corpus, which only exists
  post-ingest

Design decision: the parametrized factory test checks cfg.pipeline.hybrid.enabled
(the config flag) rather than pipeline.hybrid_retriever (a runtime field that
is always None before ingest). This correctly reflects that the YAML was parsed
and routed; the runtime field is tested indirectly via the refusal smoke test
which exercises query() on an empty index.

New test file: tests/test_eval_pipeline_factory_phase2.py (7 tests, all pass).
New YAML fixtures: tests/fixtures/phase2_configs/{baseline,b,c,d,e,g}.yaml.
New corpus fixtures: tests/fixtures/phase2_corpus/{d1,d2,d3}.txt.
Moves existing Phase 2 YAML fixtures from tests/fixtures/phase2_configs/
to configs/eval/phase2/ so production eval configs live outside the test
tree and can be bind-mounted by the api container.

Adds three tier-2f answer-model variant configs (gpt-5-mini, gpt-4.1-mini,
claude-haiku-4-5) and a smoke test (test_every_phase2_yaml_loads) that
validates all 9 YAMLs in the directory against EvalConfig.

All 279 tests pass.
…extensions

feat(eval): pipeline extensions for Phase 2 RAG quality matrix
@elkaix
elkaix merged commit 461d79a into main Apr 27, 2026
2 checks passed
@elkaix
elkaix deleted the ci/github-actions branch April 27, 2026 23:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant