Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
13 changes: 13 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,16 @@ POSTGRES_DB=insightiq
POSTGRES_USER=insightiq_user
POSTGRES_PASSWORD=insightiq_pass
DATABASE_URL=postgresql://insightiq_user:insightiq_pass@localhost:5432/insightiq

LANGFUSE_PUBLIC_KEY=pk-...
LANGFUSE_SECRET_KEY=sk-...
LANGFUSE_BASE_URL=https://cloud.langfuse.com

DATABASE_URL_RO=postgresql://querymind_ro:...@host/insightiq # read path
STATEMENT_TIMEOUT_MS=5000
MAX_ROW_LIMIT=1000
BUDGET_USD_DAILY=2.00
COST_ALERT_WEBHOOK_URL= # optional Slack webhook for in-app budget alerts
CLAUDE_INPUT_USD_PER_MTOK=3.0
CLAUDE_OUTPUT_USD_PER_MTOK=15.0
CACHE_TTL_SECONDS=900 # 15 min — schema prompt + normalised question cache
14 changes: 13 additions & 1 deletion .github/workflows/cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,9 @@ jobs:
"anthropic-api-key=${{ secrets.ANTHROPIC_API_KEY }}" \
"jwt-secret-key=${{ secrets.JWT_SECRET_KEY }}" \
"google-client-id=${{ secrets.GOOGLE_CLIENT_ID }}" \
"database-url=${{ secrets.NEON_DATABASE_URL }}"
"database-url=${{ secrets.NEON_DATABASE_URL }}" \
"database-url-ro=${{ secrets.NEON_DATABASE_URL_RO }}" \
"langfuse-sk=${{ secrets.LANGFUSE_SECRET_KEY }}"

az containerapp update \
--name "$BACKEND_APP_NAME" \
Expand All @@ -142,6 +144,16 @@ jobs:
"JWT_SECRET_KEY=secretref:jwt-secret-key" \
"GOOGLE_CLIENT_ID=secretref:google-client-id" \
"DATABASE_URL=secretref:database-url" \
"DATABASE_URL_RO=secretref:database-url-ro" \
"LANGFUSE_PUBLIC_KEY=${{ secrets.LANGFUSE_PUBLIC_KEY }}" \
"LANGFUSE_SECRET_KEY=secretref:langfuse-sk" \
"LANGFUSE_BASE_URL=https://cloud.langfuse.com" \
"STATEMENT_TIMEOUT_MS=5000" \
"MAX_ROW_LIMIT=1000" \
"BUDGET_USD_DAILY=2.00" \
"CLAUDE_INPUT_USD_PER_MTOK=3.0" \
"CLAUDE_OUTPUT_USD_PER_MTOK=15.0" \
"CACHE_TTL_SECONDS=900" \
"FRONTEND_URL=${{ secrets.FRONTEND_URL }}" \
"LOG_LEVEL=INFO" \
"MAX_RETRIES=3" \
Expand Down
122 changes: 120 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
#
# Jobs:
# backend-ci — ruff lint, pytest unit tests (no DB, no API)
# sql-eval — only if backend/eval paths changed; smoke 5-call eval + red team
# frontend-ci — TypeScript typecheck, production build
# security — Trivy dependency scan (advisory, never blocks)
# ci-success — gate job that CD depends on
Expand All @@ -27,6 +28,47 @@ concurrency:

jobs:

# ── Detect path changes (skip paid sql-eval on README / frontend-only PRs) ─
changes:
name: "Detect changed paths"
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
outputs:
sql_eval: ${{ steps.paths.outputs.sql_eval }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0

- name: Detect sql-eval paths (git diff — no GitHub API)
id: paths
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
BASE="${{ github.event.pull_request.base.sha }}"
elif [ -n "${{ github.event.before }}" ] && [ "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ]; then
BASE="${{ github.event.before }}"
else
BASE="$(git rev-parse HEAD~1 2>/dev/null || echo "")"
fi

if [ -z "$BASE" ]; then
echo "sql_eval=true" >> "$GITHUB_OUTPUT"
echo "No base ref — running sql-eval"
exit 0
fi

CHANGED="$(git diff --name-only "$BASE" HEAD)"
echo "Changed files:"
echo "$CHANGED"

if echo "$CHANGED" | grep -qE '^(backend/|tests/|database/|pytest\.ini)'; then
echo "sql_eval=true" >> "$GITHUB_OUTPUT"
else
echo "sql_eval=false" >> "$GITHUB_OUTPUT"
fi

# ── Backend: lint + unit tests ───────────────────────────────
backend-ci:
name: "Backend — lint & test"
Expand All @@ -37,6 +79,7 @@ jobs:
# Unit tests don't need a real DB or API key
ANTHROPIC_API_KEY: sk-ant-ci-placeholder-not-real
DATABASE_URL: postgresql://test:test@localhost:5432/test
DATABASE_URL_RO: postgresql://test:test@localhost:5432/test
JWT_SECRET_KEY: ci-test-secret-exactly-32-chars-ok
GOOGLE_CLIENT_ID: ci-test.apps.googleusercontent.com
LOG_LEVEL: WARNING
Expand Down Expand Up @@ -68,7 +111,8 @@ jobs:
../tests/test_sql_agent.py \
../tests/test_auth.py::TestJWT \
../tests/test_auth.py::TestRateLimit \
-v --tb=short -m "not integration" \
../tests/test_eval.py \
-v --tb=short -m "not integration and not eval_live" \
--junitxml=../test-results/unit.xml

- name: Upload test results
Expand All @@ -79,6 +123,73 @@ jobs:
path: test-results/
retention-days: 7

# ── SQL eval + red team (merge gate, path-filtered) ────────────
sql-eval:
name: "SQL evals + guardrail red-team"
needs: changes
if: needs.changes.outputs.sql_eval == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15

services:
postgres:
image: postgres:16
env:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5

env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
DATABASE_URL: postgresql://test:test@localhost:5432/test
DATABASE_URL_RO: postgresql://test:test@localhost:5432/test
JWT_SECRET_KEY: ci-test-secret-exactly-32-chars-ok
GOOGLE_CLIENT_ID: ci-test.apps.googleusercontent.com
LOG_LEVEL: WARNING

steps:
- uses: actions/checkout@v4

- name: Set up Python 3.12
uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
cache-dependency-path: backend/requirements.txt

- name: Install dependencies
run: pip install -r backend/requirements.txt

- name: Install PostgreSQL client
run: sudo apt-get update && sudo apt-get install -y postgresql-client

- name: Load eval fixture database
env:
PGPASSWORD: test
run: psql -h localhost -U test -d test -v ON_ERROR_STOP=1 -f tests/eval/seed.sql

- name: SQL evals + guardrail red-team (5 Claude calls)
run: |
mkdir -p test-results
cd backend
python -m pytest ../tests/eval/ -v -m ci_gate --tb=short \
--junitxml=../test-results/sql-eval.xml

- name: Upload sql-eval results
uses: actions/upload-artifact@v4
if: always()
with:
name: sql-eval-results
path: test-results/sql-eval.xml
retention-days: 7

# ── Frontend: typecheck + build ──────────────────────────────
frontend-ci:
name: "Frontend — typecheck & build"
Expand Down Expand Up @@ -135,15 +246,22 @@ jobs:
ci-success:
name: "CI passed ✓"
runs-on: ubuntu-latest
needs: [backend-ci, frontend-ci]
needs: [backend-ci, changes, sql-eval, frontend-ci]
if: always()
steps:
- name: Verify required jobs passed
run: |
backend="${{ needs.backend-ci.result }}"
sql_eval="${{ needs.sql-eval.result }}"
frontend="${{ needs.frontend-ci.result }}"
echo "backend-ci: $backend"
echo "sql-eval: $sql_eval (skipped when only README/frontend/etc. changed)"
echo "frontend-ci: $frontend"
# skipped sql-eval is OK — path filter excluded this push
if [[ "$sql_eval" != "success" && "$sql_eval" != "skipped" ]]; then
echo "sql-eval failed"
exit 1
fi
if [[ "$backend" != "success" || "$frontend" != "success" ]]; then
echo "CI failed — CD will not run"
exit 1
Expand Down
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,15 @@ Live: https://insightiq-frontend.orangemeadow-25c8b891.westeurope.azurecontainer

**Demo tip:** First visit after idle may take **~30–60s** while the app wakes from scale-to-zero — deliberate idle scaling keeps hosting near **$0**. Open the [frontend](https://insightiq-frontend.orangemeadow-25c8b891.westeurope.azurecontainerapps.io) a minute before a demo; sign-in wakes the backend automatically.


**Production layers:** Langfuse trace per query (SQL · time · rows · cost),
flushed on graceful shutdown · 15-pair eval graded by EXECUTION ACCURACY,
CI-gated at 90% · AST guardrails (SELECT-only, table/column whitelist,
injection scan) · read-only DB role + statement timeout + forced LIMIT +
PII block · cost per query (~$0.004 avg, daily budget alert) · graceful
degradation on timeout / bad SQL / zero rows · normalised-question cache ·
scale-to-zero on Container Apps ($0 idle)

---

## Demo
Expand Down Expand Up @@ -280,7 +289,9 @@ All deploys are zero-downtime rolling updates. The CD pipeline applies Neon migr
| GitHub Actions | $0 (free tier) |
| **Total infrastructure** | **~$2–8/month** |

Anthropic API cost scales with query volume. With the 2-query lifetime cap per user and JSONB result caching, the marginal cost per unique visitor is bounded at approximately $0.01–0.03.
Anthropic API cost scales with query volume. **Measured average per query: ~$0.03** at current schema-injection size (~10.5k input + ~130 output tokens on claude-sonnet-4-5). With prompt caching or a slimmer schema, that drops toward **~$0.004**. Each query is metered in Langfuse (`cost_usd` on the trace) and `query_audit`; daily spend is tracked in `daily_cost_spend` with a webhook alert when `BUDGET_USD_DAILY` is exceeded. The per-user query cap and JSONB result caching bound marginal cost per visitor.

See [docs/cost.md](docs/cost.md) for the Langfuse dashboard screenshot and SQL to compute live averages.

---

Expand Down
Loading
Loading