Skip to content

Commit f1b57bc

Browse files
committed
Merge branch 'main' of github.com:KostasCherv/cortex
2 parents 9f160ad + fd6d5ea commit f1b57bc

20 files changed

Lines changed: 834 additions & 3 deletions

.github/workflows/ci.yml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,16 @@ jobs:
3434
SUPABASE_SECRET_KEY: dummy-secret-key-for-ci
3535
SUPABASE_JWKS_URL: https://example.com/auth/v1/.well-known/jwks.json
3636
run: uv run python -m pytest tests/ -q
37+
- name: AI regression gate
38+
if: always()
39+
run: uv run python -m src.evals.regression_gate --output reports/ai-regression-${{ github.sha }}.json
40+
- name: Upload AI regression score
41+
if: always()
42+
uses: actions/upload-artifact@v4
43+
with:
44+
name: ai-regression-${{ github.sha }}
45+
path: reports/ai-regression-${{ github.sha }}.json
46+
if-no-files-found: error
3747

3848
ui:
3949
runs-on: ubuntu-latest
@@ -56,6 +66,20 @@ jobs:
5666
run: npx tsc --noEmit -p tsconfig.app.json
5767
- name: Test
5868
run: npx vitest run
69+
- name: Install Playwright browser
70+
run: npx playwright install --with-deps chromium
71+
- name: Browser smoke test
72+
run: npx playwright test
73+
- name: Upload Playwright failure artifacts
74+
if: failure()
75+
uses: actions/upload-artifact@v4
76+
with:
77+
name: playwright-failure-artifacts
78+
path: |
79+
ui/playwright-report/
80+
ui/test-results/
81+
if-no-files-found: ignore
82+
retention-days: 14
5983

6084
docker:
6185
runs-on: ubuntu-latest

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ ui/node_modules
2323
ui/dist
2424
ui/.vercel
2525
.DS_Store
26+
ui/playwright-report/
27+
ui/test-results/
2628
supabase/.temp
2729
ui/.impeccable-live.json
2830
ui/index.html
@@ -40,4 +42,4 @@ scripts/finetune/router-gguf/
4042
# dev-session artifacts
4143
.deepeval/
4244
.archon/
43-
test_results_artifact.txt
45+
test_results_artifact.txt

Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,6 @@ ENV PYTHONUNBUFFERED=1 \
2020
EXPOSE 8080
2121

2222
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
23-
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health')"
23+
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/ready')"
2424

2525
CMD ["uvicorn", "src.api.endpoints:app", "--host", "0.0.0.0", "--port", "8080"]

README.md

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,16 @@ Key production settings in `service.yaml`:
247247
- `CORS_ORIGINS` must be a JSON array string: `'["https://your-app.vercel.app"]'`
248248
- `LANGSMITH_TRACING=true` with `LANGSMITH_API_KEY` secret
249249

250+
Production health checks use separate endpoints:
251+
252+
- `GET /health` is a process-only liveness check and never contacts dependencies.
253+
- `GET /ready` checks LLM configuration plus Supabase, Neo4j, and optional Redis with
254+
per-dependency timeouts. It returns `503` when a critical dependency is not ready and `200`
255+
with `status: degraded` when only an optional dependency such as Redis is unavailable.
256+
- Cloud Run startup and readiness probes use `/ready`; its liveness probe uses `/health`.
257+
- `READINESS_REQUIRE_SUPABASE=true` and `READINESS_REQUIRE_NEO4J=true` make those production
258+
dependencies critical. Both default to `false` so local development can run with them disabled.
259+
250260
### Frontend — Vercel
251261

252262
```bash
@@ -406,8 +416,20 @@ Used for generation-level observability, user scoring, and evaluation datasets.
406416
uv run pytest -v
407417
uv run ruff check src
408418
uv run mypy src
419+
420+
cd ui
421+
npm run lint
422+
npm test
423+
npx playwright install chromium # first run only
424+
npm run test:e2e
409425
```
410426

427+
The Playwright smoke journey uses local fixtures—no Google, Supabase, or model
428+
credentials are required. It restores an authenticated browser session, creates a
429+
research session, consumes paced SSE progress/report events, and verifies the
430+
completed report. CI retains the HTML report, trace, screenshot, and video when
431+
the journey fails.
432+
411433
## Local benchmarking
412434

413435
Cortex includes a local-first k6 harness in [`load-tests/`](load-tests/README.md) for bottleneck discovery before production-like validation.
@@ -451,6 +473,25 @@ Local benchmark numbers are not production-capacity claims. Use them to find bot
451473

452474
## Model evaluation
453475

476+
### Fast AI regression gate
477+
478+
Every pull request runs a credential-free AI contract gate over 20 versioned cases in
479+
`src/evals/ai_regression_set.json`. The cases protect deterministic production boundaries:
480+
validated chat-router decisions, RAG/tool citation provenance, and progressive finance-tool
481+
selection and call planning. CI requires 100% overall and in every category, then uploads a
482+
commit-keyed JSON score artifact for comparison and audit.
483+
484+
Run the same gate locally:
485+
486+
```bash
487+
uv run python -m src.evals.regression_gate
488+
```
489+
490+
Add a uniquely named case to the JSON dataset whenever one of these boundaries gains behavior
491+
or a production failure needs a permanent regression case. Keep PR cases deterministic and free
492+
of provider credentials. The model-backed DeepEval comparison below remains the appropriate
493+
manual evaluation for semantic generation quality; it is intentionally not duplicated in CI.
494+
454495
Requires the `evals` optional extra (`uv sync --extra evals`) for `pandas` and `deepeval`.
455496

456497
The repo includes a standalone summarize-only comparison script at `src/evals/model_comparison.py`.

cloudrun/service.yaml

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,25 @@ spec:
2121
limits:
2222
memory: 1Gi
2323
cpu: "1"
24+
startupProbe:
25+
httpGet:
26+
path: /ready
27+
port: 8080
28+
periodSeconds: 2
29+
timeoutSeconds: 5
30+
failureThreshold: 30
31+
livenessProbe:
32+
httpGet:
33+
path: /health
34+
port: 8080
35+
periodSeconds: 30
36+
timeoutSeconds: 2
37+
readinessProbe:
38+
httpGet:
39+
path: /ready
40+
port: 8080
41+
periodSeconds: 10
42+
timeoutSeconds: 5
2443
env:
2544
# --- LLM ---
2645
- name: LLM_PROVIDER
@@ -67,6 +86,10 @@ spec:
6786
key: latest
6887
- name: SUPABASE_JWKS_URL
6988
value: "https://oukmugleriangmcdkocn.supabase.co/auth/v1/.well-known/jwks.json"
89+
- name: READINESS_REQUIRE_SUPABASE
90+
value: "true"
91+
- name: READINESS_REQUIRE_NEO4J
92+
value: "true"
7093
# --- Inngest ---
7194
- name: INNGEST_EVENT_KEY
7295
valueFrom:

docs/env-vars-production.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ All secrets should be stored in **Google Secret Manager** and referenced via `va
2828
| `SUPABASE_SECRET_KEY` | yes | `sb_secret_...` from Dashboard → Settings → API Keys (`SUPABASE_SERVICE_ROLE_KEY` still accepted) |
2929
| `SUPABASE_JWKS_URL` | no | `https://<project>.supabase.co/auth/v1/.well-known/jwks.json` |
3030
| `SUPABASE_JWT_SECRET` | yes | |
31+
| `READINESS_REQUIRE_SUPABASE` | no | Set to `true` in production; `/ready` returns `503` if Supabase is missing or unavailable |
32+
| `READINESS_REQUIRE_NEO4J` | no | Set to `true` in production; `/ready` returns `503` if Neo4j is missing or unavailable |
33+
| `READINESS_TIMEOUT_SECONDS` | no | Per-dependency probe timeout; defaults to `2.0` seconds |
3134
| `INNGEST_EVENT_KEY` | yes | From Inngest dashboard → Keys |
3235
| `INNGEST_SIGNING_KEY` | yes | From Inngest dashboard → Keys. **Must not be empty in prod.** |
3336
| `REDIS_URL` | yes | Upstash `rediss://` URL |

src/api/endpoints.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
inngest_client,
3939
)
4040
from src.storage import ensure_rag_storage_ready
41+
from src.readiness import run_readiness_checks
4142
from src.api.routers.billing import router as billing_router
4243
from src.api.routers.internal import router as internal_router
4344
from src.api.routers.memory import router as memory_router
@@ -203,6 +204,17 @@ class HealthResponse(BaseModel):
203204
version: str
204205

205206

207+
class ReadinessCheckResponse(BaseModel):
208+
status: str
209+
critical: bool
210+
211+
212+
class ReadinessResponse(BaseModel):
213+
status: str
214+
version: str
215+
checks: dict[str, ReadinessCheckResponse]
216+
217+
206218
# ---------------------------------------------------------------------------
207219
# Exception handlers
208220
# ---------------------------------------------------------------------------
@@ -224,3 +236,24 @@ async def health():
224236
return HealthResponse(status="ok", version="0.1.0")
225237

226238

239+
@app.get(
240+
"/ready",
241+
response_model=ReadinessResponse,
242+
responses={503: {"model": ReadinessResponse}},
243+
tags=["Meta"],
244+
)
245+
async def readiness() -> JSONResponse:
246+
"""Report configuration and bounded live dependency readiness checks."""
247+
checks = await run_readiness_checks()
248+
unready = any(check.critical and check.status != "ok" for check in checks.values())
249+
degraded = any(check.status == "unavailable" for check in checks.values())
250+
status = "unready" if unready else "degraded" if degraded else "ready"
251+
payload = ReadinessResponse(
252+
status=status,
253+
version=app.version,
254+
checks={
255+
name: ReadinessCheckResponse(status=check.status, critical=check.critical)
256+
for name, check in checks.items()
257+
},
258+
)
259+
return JSONResponse(status_code=503 if unready else 200, content=payload.model_dump())

src/config.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,19 @@ def parse_cors_origins(cls, v: object) -> object:
218218
sentry_dsn: str = Field(
219219
default="", description="Sentry DSN for error tracking; empty disables it"
220220
)
221+
readiness_timeout_seconds: float = Field(
222+
default=2.0,
223+
gt=0,
224+
description="Maximum duration of each live dependency readiness check.",
225+
)
226+
readiness_require_supabase: bool = Field(
227+
default=False,
228+
description="Return unready when Supabase is missing or unreachable.",
229+
)
230+
readiness_require_neo4j: bool = Field(
231+
default=False,
232+
description="Return unready when Neo4j is missing or unreachable.",
233+
)
221234

222235
# Supabase
223236
supabase_url: str = Field(default="", description="Supabase project URL")

src/evals/ai_regression_set.json

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{
2+
"version": 1,
3+
"cases": [
4+
{"id":"router-direct","category":"router","response":{"action":"answer_direct","reason":"Stable knowledge is sufficient"},"expected":{"valid":true,"action":"answer_direct","query":"","symbols":[]}},
5+
{"id":"router-rag","category":"router","response":{"action":"answer_from_rag","reason":"The attached handbook contains the answer"},"expected":{"valid":true,"action":"answer_from_rag","query":"","symbols":[]}},
6+
{"id":"router-web","category":"router","response":{"action":"web_search","reason":"Current information is required","query":"latest Swiss inflation rate"},"expected":{"valid":true,"action":"web_search","query":"latest Swiss inflation rate","symbols":[]}},
7+
{"id":"router-price","category":"router","response":{"action":"asset_price","reason":"The user requested live prices","symbols":["BTC","ETH"],"currency":"USD"},"expected":{"valid":true,"action":"asset_price","query":"","symbols":["BTC","ETH"]}},
8+
{"id":"router-finance-discovery","category":"router","response":{"action":"search_finance_tools","reason":"A specialist finance operation is needed","query":"company fundamentals lookup"},"expected":{"valid":true,"action":"search_finance_tools","query":"company fundamentals lookup","symbols":[]}},
9+
{"id":"router-clarify","category":"router","response":{"action":"ask_clarifying","reason":"The requested company is ambiguous"},"expected":{"valid":true,"action":"ask_clarifying","query":"","symbols":[]}},
10+
{"id":"router-web-missing-query","category":"router","response":{"action":"web_search","reason":"Fresh data is needed"},"expected":{"valid":false}},
11+
{"id":"router-price-normalizes-symbols","category":"router","response":{"action":"asset_price","reason":"A quote is requested","symbols":" AAPL, MSFT "},"expected":{"valid":true,"action":"asset_price","query":"","symbols":["AAPL","MSFT"]}},
12+
13+
{"id":"citation-rag","category":"citation","input":{"rag_chunks":[{"source_title":"handbook.pdf","source_url":"","chunk_id":"rag-1","text":"Refunds are available for 30 days."}],"loop_citations":[],"web_used":false,"rag_context_text":"[source:handbook.pdf chunk:rag-1]"},"expected":{"chunk_ids":["rag-1"],"source_types":["rag"]}},
14+
{"id":"citation-web-wins","category":"citation","input":{"rag_chunks":[{"source_title":"old.pdf","chunk_id":"rag-old","text":"Old figure"}],"loop_citations":[{"source_title":"Statistics office","source_url":"https://example.com/current","chunk_id":"web-1","text":"Current figure","source_type":"web"}],"web_used":true,"rag_context_text":"old"},"expected":{"chunk_ids":["web-1"],"source_types":["web"]}},
15+
{"id":"citation-tool-evidence-wins","category":"citation","input":{"rag_chunks":[{"source_title":"notes.pdf","chunk_id":"rag-2","text":"Notes"}],"loop_citations":[{"source_title":"Wikipedia","source_url":"https://en.wikipedia.org/wiki/AI","chunk_id":"wiki-1","text":"AI","source_type":"wikipedia"}],"web_used":false,"rag_context_text":"notes"},"expected":{"chunk_ids":["wiki-1"],"source_types":["wikipedia"]}},
16+
{"id":"citation-nonweb-tool-merges-rag","category":"citation","input":{"rag_chunks":[{"source_title":"brief.pdf","chunk_id":"rag-3","text":"Brief"}],"loop_citations":[{"source_title":"Internal tool","source_url":null,"chunk_id":"tool-1","text":"Result","source_type":"tool"}],"web_used":false,"rag_context_text":"brief"},"expected":{"chunk_ids":["tool-1","rag-3"],"source_types":["tool","rag"]}},
17+
{"id":"citation-context-fallback","category":"citation","input":{"rag_chunks":[],"loop_citations":[],"web_used":false,"rag_context_text":"Workspace context without structured chunks"},"expected":{"chunk_ids":["workspace-context-fallback"],"source_types":["rag_fallback"]}},
18+
{"id":"citation-no-evidence","category":"citation","input":{"rag_chunks":[],"loop_citations":[],"web_used":false,"rag_context_text":""},"expected":{"chunk_ids":[],"source_types":[]}},
19+
{"id":"citation-no-false-rag-fallback-after-web","category":"citation","input":{"rag_chunks":[{"source_title":"local.pdf","chunk_id":"rag-4","text":"Local"}],"loop_citations":[],"web_used":true,"rag_context_text":"Local"},"expected":{"chunk_ids":[],"source_types":[]}},
20+
21+
{"id":"tool-selection-valid","category":"tool_selection","stage":"selection","response":{"tool_name":"get_company_profile","reason":"The tool returns company fundamentals"},"expected":{"valid":true,"tool_name":"get_company_profile"}},
22+
{"id":"tool-selection-blank-name","category":"tool_selection","stage":"selection","response":{"tool_name":" ","reason":"No usable tool"},"expected":{"valid":false}},
23+
{"id":"tool-plan-call","category":"tool_selection","stage":"plan","response":{"should_call":true,"reason":"All required arguments are present","arguments":{"symbol":"NVDA"}},"expected":{"valid":true,"should_call":true,"arguments":{"symbol":"NVDA"}}},
24+
{"id":"tool-plan-clarify","category":"tool_selection","stage":"plan","response":{"should_call":false,"reason":"Ticker is missing","clarifying_question":"Which company or ticker do you mean?"},"expected":{"valid":true,"should_call":false,"arguments":{}}},
25+
{"id":"tool-plan-invalid-no-question","category":"tool_selection","stage":"plan","response":{"should_call":false,"reason":"Ticker is missing"},"expected":{"valid":false}}
26+
]
27+
}

0 commit comments

Comments
 (0)