From 6848e6b48de820dfa32eea1ab833e358e9b0dfd6 Mon Sep 17 00:00:00 2001 From: Abhinaysai Kamineni Date: Thu, 11 Jun 2026 00:09:11 -0400 Subject: [PATCH 1/4] Add dual-workspace OSS import tooling and Vercel dashboard deploy path. Enables local-dev vs real GitHub/ADR workspaces, same-origin API rewrites for Vercel previews, and Makefile smoke targets with 12 new tests. Co-authored-by: Cursor --- Makefile | 12 +- README.md | 18 ++ docs/DATA_SOURCES.md | 70 ++++++ docs/DEPLOY.md | 76 ++++++ frontend/.gitignore | 3 + frontend/package.json | 2 +- frontend/scripts/vercel-api-rewrites.mjs | 46 ++++ .../src/components/layout/WorkspaceBar.tsx | 2 +- frontend/src/lib/errors.ts | 7 + frontend/vercel.json | 10 + scripts/dual_workspace_smoke.py | 101 ++++++++ scripts/import_adr_markdown.py | 222 ++++++++++++++++++ scripts/import_github_org.py | 188 +++++++++++++++ scripts/inject_github_event.py | 25 +- tests/scripts/test_dual_workspace_smoke.py | 46 ++++ tests/scripts/test_import_adr_markdown.py | 71 ++++++ tests/scripts/test_import_github_org.py | 87 +++++++ 17 files changed, 976 insertions(+), 10 deletions(-) create mode 100644 docs/DATA_SOURCES.md create mode 100644 docs/DEPLOY.md create mode 100644 frontend/.gitignore create mode 100644 frontend/scripts/vercel-api-rewrites.mjs create mode 100644 frontend/vercel.json create mode 100644 scripts/dual_workspace_smoke.py create mode 100644 scripts/import_adr_markdown.py create mode 100644 scripts/import_github_org.py create mode 100644 tests/scripts/test_dual_workspace_smoke.py create mode 100644 tests/scripts/test_import_adr_markdown.py create mode 100644 tests/scripts/test_import_github_org.py diff --git a/Makefile b/Makefile index c061417..ff6a059 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: demo demo-dry-run test ci stack init-kafka pipeline-restart pipeline-local verify-pipeline verify-github verify-jira verify-linear verify-connectors +.PHONY: demo demo-dry-run test ci stack init-kafka pipeline-restart pipeline-local verify-pipeline verify-github verify-jira verify-linear verify-connectors seed-dev import-oss verify-dual # Local portfolio demo: Docker infra + migrations + seed + API + worker + frontend. demo: @@ -45,3 +45,13 @@ verify-linear: python scripts/verify_slack_pipeline.py --source linear --timeout 120 verify-connectors: verify-pipeline verify-github verify-jira verify-linear + +# Dual-workspace: synthetic dev seed vs OSS import smoke. +seed-dev: + uv run python scripts/seed_demo.py --workspace local-dev + +import-oss: + uv run python scripts/import_github_org.py --org tiangolo --repo fastapi --dry-run + +verify-dual: + uv run python scripts/dual_workspace_smoke.py --workspaces local-dev,oss-tiangolo-fastapi diff --git a/README.md b/README.md index 12482eb..717fee0 100644 --- a/README.md +++ b/README.md @@ -217,6 +217,24 @@ This brings up Kafka, Neo4j, Redis, Postgres, applies graph migrations, writes t **Recording a video or GIF for the README:** see [docs/DEMO_RECORDING.md](docs/DEMO_RECORDING.md). **Validating real webhooks (Slack / GitHub / Jira):** [docs/CONNECTOR_VALIDATION.md](docs/CONNECTOR_VALIDATION.md). +### Deploy preview (Vercel dashboard) + +Host the **dashboard only** on Vercel (`frontend/` as root directory). The API and pipeline worker run on Docker, Railway, or similar — do not deploy the repo root as FastAPI on Vercel (full `uv.lock` exceeds Lambda size limits). + +| Step | Action | +|------|--------| +| 1 | Deploy `frontend/` with `CORTEX_API_ORIGIN=https://your-api.example.com` | +| 2 | Build runs `scripts/vercel-api-rewrites.mjs` — same-origin `/query` proxy | +| 3 | Avoid `VITE_API_URL` pointing at tunnel URLs (511 errors) | + +Full guide: [docs/DEPLOY.md](docs/DEPLOY.md). **Dual workspaces** (synthetic `local-dev` vs real OSS imports): [docs/DATA_SOURCES.md](docs/DATA_SOURCES.md). + +```bash +make seed-dev # local-dev synthetic decisions +make import-oss # dry-run GitHub PR import preview +make verify-dual # compare queries across workspaces +``` + ### Context API (REST) | Method | Path | Purpose | diff --git a/docs/DATA_SOURCES.md b/docs/DATA_SOURCES.md new file mode 100644 index 0000000..afc9fba --- /dev/null +++ b/docs/DATA_SOURCES.md @@ -0,0 +1,70 @@ +# Cortex data workspaces + +Cortex isolates organizational memory by **`workspace_id`**. Use separate workspaces for synthetic demo data vs real open-source imports. + +## Workspace naming + +| Workspace | Source | How to populate | +|-----------|--------|-----------------| +| `local-dev` | Synthetic demo catalog | `make demo` or `python scripts/seed_demo.py --workspace local-dev` | +| `oss--` | Real GitHub merged PRs | `python scripts/import_github_org.py --org tiangolo --repo fastapi` | +| `oss-adr` / `oss-adr-` | ADR markdown files | `python scripts/import_adr_markdown.py --path docs/adr` | + +## Path A — GitHub (full pipeline) + +Exercises Kafka → extractor → importance/trust → Neo4j. + +```bash +# Preview without Kafka +python scripts/import_github_org.py --org tiangolo --repo fastapi --dry-run + +# Publish (requires Kafka + pipeline-worker) +python scripts/import_github_org.py \ + --org tiangolo \ + --repo fastapi \ + --workspace oss-tiangolo-fastapi \ + --limit 30 + +# Optional: GITHUB_TOKEN in .env for higher rate limits +``` + +After import, wait for `pipeline-worker` to process, then: + +```bash +python scripts/dual_workspace_smoke.py --workspaces local-dev,oss-tiangolo-fastapi +``` + +## Path B — ADR markdown (direct graph write) + +High-quality decisions without LLM extraction variance. Uses the same scoring loop as `seed_demo.py`. + +```bash +python scripts/import_adr_markdown.py --path /path/to/docs/adr --workspace oss-adr-myproject --dry-run +python scripts/import_adr_markdown.py --path /path/to/docs/adr --workspace oss-adr-myproject +``` + +Supports common MADR sections: Context, Decision, Consequences, Status. + +## Compare workspaces + +```bash +make seed-dev # local-dev only +make verify-dual # query local-dev vs oss-tiangolo-fastapi +``` + +Dashboard presets: **local-dev**, **oss-tiangolo-fastapi**, **oss-adr** (Connection bar). + +## Open data sources (future) + +| Source | Maps to | Notes | +|--------|---------|-------| +| GitHub REST API | `RawEvent` via `normalise_github_event` | Implemented | +| ADR markdown repos | `DecisionEvent` direct | Implemented | +| Kaggle GitHub issues CSV | Custom CSV → `RawEvent` | Not yet shipped | +| GH Archive | Batch JSON → inject script | Not yet shipped | + +## Licenses and ethics + +- Only import **public** repositories and datasets. +- Do not import private Slack exports or employee PII without GDPR review. +- Document dataset provenance in PR descriptions when used for portfolio demos. diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md new file mode 100644 index 0000000..23dd186 --- /dev/null +++ b/docs/DEPLOY.md @@ -0,0 +1,76 @@ +# Deploying Cortex + +Cortex is a **multi-service stack** (Kafka, Neo4j, Redis, API, pipeline worker, dashboard). Vercel hosts the **dashboard only**; the API and worker run elsewhere. + +## Recommended split + +| Component | Host | Notes | +|-----------|------|-------| +| Dashboard | **Vercel** (`frontend/`) | Vite static + API rewrites | +| API | Railway / Render / Fly | `api/Dockerfile` | +| pipeline-worker | Same as API | `pipeline/Dockerfile` | +| Neo4j | Neo4j Aura | Bolt URI in env | +| Redis | Upstash | Query cache | +| Kafka | Upstash Kafka / Confluent Cloud | Event bus | + +## Vercel (frontend) + +**Project settings** + +- Root Directory: `frontend` +- Framework: Vite +- Build: `npm run build` (runs `scripts/vercel-api-rewrites.mjs`) + +**Environment variables** + +| Variable | Purpose | +|----------|---------| +| `CORTEX_API_ORIGIN` | Public API URL — Vercel rewrites `/query`, `/health`, etc. server-side | +| ~~`VITE_API_URL`~~ | **Do not** point at `loca.lt` — causes 511 tunnel interstitial errors | + +**Do not** import the repo root with Framework Preset **FastAPI**. That installs the full `uv.lock` (~5 GB) and exceeds Lambda limits. + +```bash +cd frontend +npx vercel deploy --prod +``` + +Set `CORTEX_API_ORIGIN=https://your-api.example.com` in the Vercel project before deploy. + +## Local full stack + +```bash +make demo +open http://localhost:3000 # dashboard (nginx → API) +open http://localhost:8000/docs +``` + +## Dev preview (laptop + tunnel) + +For short-lived public demos while the API runs locally: + +```bash +cloudflared tunnel --url http://localhost:8000 +# Set CORTEX_API_ORIGIN to the trycloudflare.com URL on Vercel, redeploy frontend +``` + +Prefer **cloudflared** over localtunnel — localtunnel returns **511** for browser `fetch()` calls. + +## Dual-workspace testing + +See [DATA_SOURCES.md](./DATA_SOURCES.md) for `local-dev` vs `oss-*` workspaces. + +```bash +python scripts/seed_demo.py --workspace local-dev +python scripts/import_github_org.py --org tiangolo --repo fastapi --dry-run +make verify-dual +``` + +## Auth on preview + +```bash +CORTEX_API_KEYS=preview-key:admin;authenticated +CORTEX_DEMO_API_KEY=preview-key +``` + +`make demo` and `scripts/demo.sh` send `Authorization` when these are set. diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..3cced3b --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,3 @@ +.vercel +dist +node_modules diff --git a/frontend/package.json b/frontend/package.json index 19f6c5a..f45a16c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,7 +6,7 @@ "scripts": { "dev": "vite", "typecheck": "tsc --noEmit", - "build": "tsc --noEmit && vite build", + "build": "node scripts/vercel-api-rewrites.mjs && tsc --noEmit && vite build", "preview": "vite preview", "test": "vitest run", "test:watch": "vitest", diff --git a/frontend/scripts/vercel-api-rewrites.mjs b/frontend/scripts/vercel-api-rewrites.mjs new file mode 100644 index 0000000..a3150bc --- /dev/null +++ b/frontend/scripts/vercel-api-rewrites.mjs @@ -0,0 +1,46 @@ +#!/usr/bin/env node +/** + * Inject Vercel rewrites so the dashboard calls /query on the same origin. + * Vercel proxies to CORTEX_API_ORIGIN server-side — avoids localtunnel 511 + * interstitials and CORS when the API runs on your laptop via cloudflared. + */ +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const configPath = join(root, "vercel.json"); + +const origin = String(process.env.CORTEX_API_ORIGIN ?? "") + .trim() + .replace(/\/$/, ""); + +const base = JSON.parse(readFileSync(configPath, "utf8")); + +const apiRewrites = origin + ? [ + { source: "/health", destination: `${origin}/health` }, + { source: "/metrics", destination: `${origin}/metrics` }, + { source: "/query", destination: `${origin}/query` }, + { source: "/inject", destination: `${origin}/inject` }, + { source: "/remember", destination: `${origin}/remember` }, + { source: "/docs", destination: `${origin}/docs` }, + { source: "/openapi.json", destination: `${origin}/openapi.json` }, + { source: "/decisions/:path*", destination: `${origin}/decisions/:path*` }, + { source: "/contradictions/:path*", destination: `${origin}/contradictions/:path*` }, + { source: "/gdpr/:path*", destination: `${origin}/gdpr/:path*` }, + { source: "/webhooks/:path*", destination: `${origin}/webhooks/:path*` }, + ] + : []; + +base.rewrites = [ + ...apiRewrites, + { source: "/((?!assets/).*)", destination: "/index.html" }, +]; + +writeFileSync(configPath, `${JSON.stringify(base, null, 2)}\n`); +if (origin) { + console.log(`vercel.json: proxying API routes → ${origin}`); +} else { + console.log("vercel.json: no CORTEX_API_ORIGIN — SPA only (use nginx/Vite proxy locally)"); +} diff --git a/frontend/src/components/layout/WorkspaceBar.tsx b/frontend/src/components/layout/WorkspaceBar.tsx index edb11fa..5480598 100644 --- a/frontend/src/components/layout/WorkspaceBar.tsx +++ b/frontend/src/components/layout/WorkspaceBar.tsx @@ -2,7 +2,7 @@ import { useId, useState } from "react"; import { useApp } from "../../context/AppContext"; import { hasApiKeyConfigured } from "../../api/client"; -const PRESETS = ["local-dev", "acme-demo"] as const; +const PRESETS = ["local-dev", "oss-tiangolo-fastapi", "oss-adr"] as const; export function WorkspaceBar() { const { workspaceId, setWorkspaceId, apiKey, setApiKey, saveApiKey } = useApp(); diff --git a/frontend/src/lib/errors.ts b/frontend/src/lib/errors.ts index c6cc789..3ab34ff 100644 --- a/frontend/src/lib/errors.ts +++ b/frontend/src/lib/errors.ts @@ -45,6 +45,13 @@ export async function parseHttpError(response: Response): Promise { const base = "Service unavailable (503). Neo4j or another dependency may be down."; return detail ? `${base} ${detail.slice(0, 120)}` : base; } + if (response.status === 511 || detail.includes("Tunnel website ahead")) { + return ( + "API tunnel blocked the request (511). The dashboard must use same-origin /query via " + + "Vercel rewrites (CORTEX_API_ORIGIN), not a localtunnel URL in VITE_API_URL. " + + "See README deploy notes." + ); + } if (!detail) return `Request failed (${status})`; return `Request failed (${status}): ${detail.slice(0, 200)}`; } diff --git a/frontend/vercel.json b/frontend/vercel.json new file mode 100644 index 0000000..218eaa9 --- /dev/null +++ b/frontend/vercel.json @@ -0,0 +1,10 @@ +# Vercel build may rewrite this file when CORTEX_API_ORIGIN is set (see scripts/vercel-api-rewrites.mjs). +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "framework": "vite", + "buildCommand": "npm run build", + "outputDirectory": "dist", + "rewrites": [ + { "source": "/((?!assets/).*)", "destination": "/index.html" } + ] +} diff --git a/scripts/dual_workspace_smoke.py b/scripts/dual_workspace_smoke.py new file mode 100644 index 0000000..4dd679e --- /dev/null +++ b/scripts/dual_workspace_smoke.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""Compare query results across multiple Cortex workspaces. + +Useful for validating synthetic seed data (local-dev) vs real OSS imports (oss-*). + +Usage: + python scripts/dual_workspace_smoke.py --url http://localhost:8000 + python scripts/dual_workspace_smoke.py --workspaces local-dev,oss-tiangolo-fastapi +""" + +from __future__ import annotations + +import argparse +import json +import sys +import urllib.error +import urllib.request + +DEFAULT_QUERIES = [ + "Why CockroachDB for payments?", + "cache session strategy", + "architecture migration decision", +] + +DEFAULT_WORKSPACES = ["local-dev", "oss-tiangolo-fastapi"] + + +def post_query(base_url: str, *, query: str, workspace_id: str, limit: int) -> dict: + """POST /query and return parsed JSON.""" + body = json.dumps( + {"query": query, "workspace_id": workspace_id, "limit": limit}, + ).encode("utf-8") + req = urllib.request.Request( + f"{base_url.rstrip('/')}/query", + data=body, + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=30) as resp: + return json.loads(resp.read().decode("utf-8")) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Dual-workspace query smoke test") + parser.add_argument("--url", default="http://localhost:8000", help="Cortex API base URL") + parser.add_argument( + "--workspaces", + default=",".join(DEFAULT_WORKSPACES), + help="Comma-separated workspace ids", + ) + parser.add_argument( + "--queries", + default="", + help="Comma-separated queries (default: built-in set)", + ) + parser.add_argument("--limit", type=int, default=3, help="Results per query") + args = parser.parse_args() + + workspaces = [w.strip() for w in args.workspaces.split(",") if w.strip()] + queries = ( + [q.strip() for q in args.queries.split(",") if q.strip()] + if args.queries + else DEFAULT_QUERIES + ) + + print(f"API: {args.url}") + print(f"Workspaces: {', '.join(workspaces)}") + print() + + failed = 0 + for query in queries: + print(f"Query: {query!r}") + row: list[str] = [] + for ws in workspaces: + try: + payload = post_query(args.url, query=query, workspace_id=ws, limit=args.limit) + results = payload.get("results") or [] + if results: + top = results[0].get("content", "")[:60] + row.append(f"{ws}: {len(results)} hit(s) — {top!r}…") + else: + row.append(f"{ws}: 0 results") + except urllib.error.HTTPError as exc: + failed += 1 + row.append(f"{ws}: HTTP {exc.code}") + except urllib.error.URLError as exc: + failed += 1 + row.append(f"{ws}: unreachable ({exc.reason})") + for line in row: + print(f" {line}") + print() + + if failed: + print(f"FAIL: {failed} request(s) failed", file=sys.stderr) + return 1 + print("OK: all workspace queries completed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/import_adr_markdown.py b/scripts/import_adr_markdown.py new file mode 100644 index 0000000..0eba283 --- /dev/null +++ b/scripts/import_adr_markdown.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python3 +"""Import Architecture Decision Records from markdown files into Neo4j. + +Parses common MADR-style ADR markdown and writes Decision nodes via the same +scoring pipeline as ``seed_demo.py`` (no Kafka / LLM extraction). + +Usage: + python scripts/import_adr_markdown.py --path ./vendor/adr --workspace oss-adr-fastapi --dry-run + python scripts/import_adr_markdown.py --path docs/adr --glob '*.md' +""" + +from __future__ import annotations + +import argparse +import os +import re +import sys +import uuid +from datetime import UTC, datetime +from pathlib import Path + +_REPO = Path(__file__).resolve().parents[1] +if str(_REPO) not in sys.path: + sys.path.insert(0, str(_REPO)) + +from dotenv import load_dotenv + +from shared.models import DecisionEvent, Provenance + +SECTION_HEADING = re.compile(r"^#{1,3}\s+(.+)$", re.MULTILINE) + + +def adr_uuid(path_key: str) -> str: + """Deterministic id for idempotent MERGE on re-import.""" + return str(uuid.uuid5(uuid.NAMESPACE_URL, f"cortex.adr:{path_key}")) + + +def parse_adr_markdown(text: str, *, path_key: str) -> dict[str, str | list[str]] | None: + """Extract title, status, context, decision, consequences from MADR-like markdown.""" + lines = text.strip().splitlines() + if not lines: + return None + + title = lines[0].lstrip("#").strip() + if not title: + return None + + sections: dict[str, str] = {} + current: str | None = None + buf: list[str] = [] + + def flush() -> None: + nonlocal buf, current + if current and buf: + sections[current] = "\n".join(buf).strip() + buf = [] + + for line in lines[1:]: + m = SECTION_HEADING.match(line) + if m: + flush() + current = m.group(1).strip().lower() + elif current: + buf.append(line) + flush() + + decision_text = ( + sections.get("decision") + or sections.get("decision outcome") + or sections.get("decision outcome / rationale") + or "" + ) + context = sections.get("context") or sections.get("issue") or "" + consequences = sections.get("consequences") or sections.get("consequences / implications") or "" + + content_parts = [title] + if decision_text: + content_parts.append(decision_text) + elif context: + content_parts.append(context[:500]) + content = ". ".join(p for p in content_parts if p) + + rationale: list[str] = [] + if context: + rationale.append(context[:400]) + if consequences: + rationale.append(consequences[:400]) + + status_raw = (sections.get("status") or "accepted").lower() + event_type = "update" if "supersed" in status_raw or "deprecated" in status_raw else "decision" + + return { + "title": title, + "content": content[:4000], + "rationale": rationale, + "status": status_raw, + "event_type": event_type, + "path_key": path_key, + } + + +def build_decision_from_adr( + parsed: dict[str, str | list[str]], + workspace_id: str, + *, + channel: str, +) -> DecisionEvent: + """Materialize parsed ADR fields into a DecisionEvent.""" + now = datetime.now(UTC) + path_key = str(parsed["path_key"]) + event_id = adr_uuid(path_key) + raw_id = adr_uuid(f"raw-{path_key}") + + importance = 0.82 if parsed["event_type"] == "decision" else 0.72 + trust = 0.85 + + return DecisionEvent( + event_id=event_id, + source_raw_event_id=raw_id, + workspace_id=workspace_id, + event_type=str(parsed["event_type"]), # type: ignore[arg-type] + content=str(parsed["content"]), + made_by=["adr-import"], + affects=[channel.split("/")[0] if "/" in channel else "architecture"], + rationale=list(parsed["rationale"]), # type: ignore[arg-type] + extraction_confidence=0.95, + importance_score=importance, + trust_score=trust, + provenance=Provenance( + source="manual", + channel=channel, + original_timestamp=now, + extractor_version="adr-import-1.0", + extractor_model="markdown-parser", + verified_by=[], + raw_event_id=raw_id, + ), + extracted_at=now, + status="active" if "accept" in str(parsed["status"]) else "under_review", + ) + + +def collect_adr_files(root: Path, glob_pattern: str) -> list[Path]: + """Return markdown files under root matching glob.""" + if root.is_file(): + return [root] if root.suffix.lower() in {".md", ".markdown"} else [] + return sorted(root.rglob(glob_pattern)) + + +def write_decisions(decisions: list[DecisionEvent]) -> tuple[int, int]: + """Score and persist decisions; return (written, skipped).""" + from graph.writer import GraphWriter + from memory.quarantine import persist_quarantine + from scoring.write_pipeline import DecisionScoringPipeline, write_reject_reason + + scoring = DecisionScoringPipeline() + writer = GraphWriter() + written = 0 + skipped = 0 + try: + for decision in decisions: + scoring.score(decision) + reject = write_reject_reason(decision) + if reject is not None: + persist_quarantine(decision, reject) + skipped += 1 + continue + writer.write(decision) + written += 1 + finally: + writer.close() + return written, skipped + + +def main() -> int: + parser = argparse.ArgumentParser(description="Import ADR markdown into Neo4j") + parser.add_argument("--path", type=Path, required=True, help="ADR file or directory") + parser.add_argument("--glob", default="*.md", help="Glob under directory (default: *.md)") + parser.add_argument("--workspace", default=None, help="Cortex workspace id") + parser.add_argument( + "--channel", + default=None, + help="Provenance channel label (default: adr/)", + ) + parser.add_argument("--dry-run", action="store_true", help="Parse only; do not write") + args = parser.parse_args() + + load_dotenv(_REPO / ".env") + workspace = args.workspace or os.environ.get("CORTEX_WORKSPACE_ID", "oss-adr") + + files = collect_adr_files(args.path, args.glob) + if not files: + print(f"No ADR files found under {args.path}", file=sys.stderr) + return 1 + + decisions: list[DecisionEvent] = [] + for file_path in files: + text = file_path.read_text(encoding="utf-8") + rel = file_path.relative_to(args.path) if args.path.is_dir() else file_path.name + path_key = str(rel) + parsed = parse_adr_markdown(text, path_key=path_key) + if parsed is None: + continue + channel = args.channel or f"adr/{path_key}" + decisions.append(build_decision_from_adr(parsed, workspace, channel=channel)) + + print(f"Parsed {len(decisions)} ADR(s) from {len(files)} file(s) → workspace={workspace!r}") + + if args.dry_run: + for d in decisions[:3]: + print(f" - {d.event_id}: {d.content[:80]}…") + if len(decisions) > 3: + print(f" … and {len(decisions) - 3} more") + return 0 + + written, skipped = write_decisions(decisions) + print(f"ADR import complete: wrote {written}, skipped {skipped} (quarantine/importance)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/import_github_org.py b/scripts/import_github_org.py new file mode 100644 index 0000000..596f75a --- /dev/null +++ b/scripts/import_github_org.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +"""Import merged GitHub PRs from a public repo into the Cortex Kafka pipeline. + +Fetches closed merged pull requests via the GitHub REST API, filters for +decision-like titles/bodies, normalises to RawEvent, and publishes to +``cortex.raw.github.events`` for the extraction worker. + +Usage: + python scripts/import_github_org.py --org tiangolo --repo fastapi --dry-run + python scripts/import_github_org.py --org tiangolo --repo fastapi --workspace oss-fastapi --limit 30 +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import sys +import time +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any + +_REPO = Path(__file__).resolve().parents[1] +if str(_REPO) not in sys.path: + sys.path.insert(0, str(_REPO)) + +from dotenv import load_dotenv + +from connectors.github.producer import GitHubKafkaProducer, normalise_github_event +from scripts.inject_github_event import build_merged_pr_payload + +DECISION_PATTERN = re.compile( + r"\b(decision|rfc|adr|migrate|migration|deprecat|architect|proposal|" + r"we decided|chosen|replace|switch to)\b", + re.IGNORECASE, +) + +GITHUB_API = "https://api.github.com" + + +def _github_headers() -> dict[str, str]: + headers = { + "Accept": "application/vnd.github+json", + "User-Agent": "cortex-import-github-org", + } + token = os.environ.get("GITHUB_TOKEN", "").strip() + if token: + headers["Authorization"] = f"Bearer {token}" + return headers + + +def fetch_merged_prs( + owner: str, + repo: str, + *, + limit: int, + page_size: int = 100, +) -> list[dict[str, Any]]: + """Return up to ``limit`` merged pull requests (newest first).""" + collected: list[dict[str, Any]] = [] + page = 1 + while len(collected) < limit: + url = ( + f"{GITHUB_API}/repos/{owner}/{repo}/pulls" + f"?state=closed&sort=updated&direction=desc&per_page={page_size}&page={page}" + ) + req = urllib.request.Request(url, headers=_github_headers()) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + batch: list[dict[str, Any]] = json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as exc: + raise RuntimeError(f"GitHub API error {exc.code}: {exc.reason}") from exc + + if not batch: + break + + for pr in batch: + if pr.get("merged_at"): + collected.append(pr) + if len(collected) >= limit: + break + page += 1 + if len(batch) < page_size: + break + time.sleep(0.5) + + return collected[:limit] + + +def is_decision_like(pr: dict[str, Any]) -> bool: + """Heuristic filter for PRs likely to contain organizational decisions.""" + text = f"{pr.get('title', '')}\n{pr.get('body') or ''}" + return bool(DECISION_PATTERN.search(text)) + + +def pr_to_payload(pr: dict[str, Any], repo_full_name: str) -> dict[str, Any]: + """Map GitHub pull object to webhook-shaped payload.""" + user = pr.get("user") or {} + head = pr.get("head") or {} + return build_merged_pr_payload( + title=str(pr.get("title", "")), + body=str(pr.get("body") or ""), + repo=repo_full_name, + number=int(pr.get("number", 0)), + user_login=str(user.get("login", "unknown")), + updated_at=str(pr.get("updated_at") or pr.get("merged_at") or ""), + created_at=str(pr.get("created_at") or ""), + head_ref=str(head.get("ref", "feature/import")), + ) + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Import merged GitHub PRs into cortex.raw.github.events", + ) + parser.add_argument("--org", required=True, help="GitHub org or user") + parser.add_argument("--repo", required=True, help="Repository name") + parser.add_argument( + "--workspace", + default=None, + help="Cortex workspace id (default: oss--)", + ) + parser.add_argument("--limit", type=int, default=50, help="Max merged PRs to fetch") + parser.add_argument( + "--all-merged", + action="store_true", + help="Import all merged PRs (default: filter decision-like PRs only)", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Print planned RawEvents without publishing to Kafka", + ) + args = parser.parse_args() + + load_dotenv(_REPO / ".env") + workspace = args.workspace or f"oss-{args.org}-{args.repo}" + repo_full = f"{args.org}/{args.repo}" + decision_only = not args.all_merged + + print(f"Fetching merged PRs from {repo_full} (limit={args.limit})…") + prs = fetch_merged_prs(args.org, args.repo, limit=args.limit) + if decision_only: + prs = [pr for pr in prs if is_decision_like(pr)] + + print(f"Selected {len(prs)} pull request(s) for workspace={workspace!r}") + + events: list[tuple[str, dict[str, Any]]] = [] + for pr in prs: + payload = pr_to_payload(pr, repo_full) + raw = normalise_github_event(payload, "pull_request", workspace) + if raw is None: + continue + events.append((raw.event_id, json.loads(raw.model_dump_json()))) + + if args.dry_run: + for _eid, body in events[:5]: + print(json.dumps(body, indent=2)[:500], "…") + if len(events) > 5: + print(f"… and {len(events) - 5} more") + print(f"\nDry run — would publish {len(events)} event(s) to cortex.raw.github.events") + return 0 + + if not events: + print("No events to publish.", file=sys.stderr) + return 1 + + producer = GitHubKafkaProducer() + try: + for _eid, body in events: + from shared.models import RawEvent + + raw_event = RawEvent.model_validate(body) + producer.publish(raw_event) + producer.flush(timeout=30.0) + finally: + producer.close() + + print(f"Published {len(events)} event(s) to cortex.raw.github.events") + print("Run pipeline-worker and query with workspace_id=", repr(workspace)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/inject_github_event.py b/scripts/inject_github_event.py index 46a80fd..df8eba4 100644 --- a/scripts/inject_github_event.py +++ b/scripts/inject_github_event.py @@ -31,20 +31,31 @@ DEFAULT_TITLE = "Decision: migrate payments to CockroachDB" -def build_merged_pr_payload(*, title: str, body: str, repo: str) -> dict: +def build_merged_pr_payload( + *, + title: str, + body: str, + repo: str, + number: int = 42, + user_login: str = "priya", + updated_at: str = "2026-06-10T18:00:00Z", + created_at: str = "2026-06-10T17:00:00Z", + head_ref: str = "feature/decision", +) -> dict: """Build a GitHub ``pull_request`` closed+merged webhook payload.""" return { "action": "closed", "pull_request": { - "number": 42, + "number": number, "title": title, - "body": body, + "body": body or "", "merged": True, - "user": {"login": "priya"}, + "merged_at": updated_at, + "user": {"login": user_login}, "base": {"ref": "main"}, - "head": {"ref": "feature/cockroachdb"}, - "updated_at": "2026-06-10T18:00:00Z", - "created_at": "2026-06-10T17:00:00Z", + "head": {"ref": head_ref}, + "updated_at": updated_at, + "created_at": created_at, "labels": [], "requested_reviewers": [], }, diff --git a/tests/scripts/test_dual_workspace_smoke.py b/tests/scripts/test_dual_workspace_smoke.py new file mode 100644 index 0000000..7f8837f --- /dev/null +++ b/tests/scripts/test_dual_workspace_smoke.py @@ -0,0 +1,46 @@ +"""Unit tests for scripts/dual_workspace_smoke.py.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from unittest.mock import patch + +_REPO = Path(__file__).resolve().parents[2] +if str(_REPO) not in sys.path: + sys.path.insert(0, str(_REPO)) + +from scripts import dual_workspace_smoke as dws + + +def test_post_query_parses_json() -> None: + payload = {"results": [{"content": "CockroachDB migration"}], "total": 1} + body = json.dumps(payload).encode("utf-8") + + class FakeResp: + def read(self) -> bytes: + return body + + def __enter__(self) -> FakeResp: + return self + + def __exit__(self, *args: object) -> None: + return None + + with patch("urllib.request.urlopen", return_value=FakeResp()): + out = dws.post_query("http://localhost:8000", query="test", workspace_id="local-dev", limit=3) + assert out["total"] == 1 + + +def test_main_ok() -> None: + fake = {"results": [{"content": "hit"}], "total": 1} + with ( + patch.object(dws, "post_query", return_value=fake), + patch.object( + sys, + "argv", + ["dual_workspace_smoke.py", "--workspaces", "local-dev", "--queries", "test"], + ), + ): + assert dws.main() == 0 diff --git a/tests/scripts/test_import_adr_markdown.py b/tests/scripts/test_import_adr_markdown.py new file mode 100644 index 0000000..e0efb27 --- /dev/null +++ b/tests/scripts/test_import_adr_markdown.py @@ -0,0 +1,71 @@ +"""Unit tests for scripts/import_adr_markdown.py (no Neo4j).""" + +from __future__ import annotations + +import sys +from pathlib import Path + +_REPO = Path(__file__).resolve().parents[2] +if str(_REPO) not in sys.path: + sys.path.insert(0, str(_REPO)) + +from scripts import import_adr_markdown as iam + +SAMPLE_ADR = """# Use Redis for session cache + +## Status + +Accepted + +## Context + +In-memory sessions do not survive horizontal scaling. + +## Decision + +We will store sessions in Redis with a 24h TTL. + +## Consequences + +Requires Redis cluster in staging and production. +""" + + +def test_parse_adr_markdown_extracts_sections() -> None: + parsed = iam.parse_adr_markdown(SAMPLE_ADR, path_key="001-redis.md") + assert parsed is not None + assert parsed["title"] == "Use Redis for session cache" + assert "Redis" in str(parsed["content"]) + assert parsed["event_type"] == "decision" + assert len(parsed["rationale"]) >= 1 + + +def test_adr_uuid_stable() -> None: + assert iam.adr_uuid("001-redis.md") == iam.adr_uuid("001-redis.md") + assert iam.adr_uuid("a") != iam.adr_uuid("b") + + +def test_build_decision_from_adr_workspace() -> None: + parsed = iam.parse_adr_markdown(SAMPLE_ADR, path_key="001-redis.md") + assert parsed is not None + decision = iam.build_decision_from_adr(parsed, "oss-adr-test", channel="adr/001-redis.md") + assert decision.workspace_id == "oss-adr-test" + assert decision.importance_score >= 0.7 + assert decision.provenance.channel == "adr/001-redis.md" + + +def test_collect_adr_files_single_file(tmp_path: Path) -> None: + adr = tmp_path / "0001-test.md" + adr.write_text(SAMPLE_ADR, encoding="utf-8") + files = iam.collect_adr_files(adr, "*.md") + assert files == [adr] + + +def test_collect_adr_files_directory(tmp_path: Path) -> None: + sub = tmp_path / "adr" + sub.mkdir() + (sub / "a.md").write_text(SAMPLE_ADR, encoding="utf-8") + (sub / "skip.txt").write_text("nope", encoding="utf-8") + files = iam.collect_adr_files(sub, "*.md") + assert len(files) == 1 + assert files[0].name == "a.md" diff --git a/tests/scripts/test_import_github_org.py b/tests/scripts/test_import_github_org.py new file mode 100644 index 0000000..f9d1fa4 --- /dev/null +++ b/tests/scripts/test_import_github_org.py @@ -0,0 +1,87 @@ +"""Unit tests for scripts/import_github_org.py (no GitHub / Kafka).""" + +from __future__ import annotations + +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +_REPO = Path(__file__).resolve().parents[2] +if str(_REPO) not in sys.path: + sys.path.insert(0, str(_REPO)) + +from scripts import import_github_org as igo + + +SAMPLE_PR = { + "number": 123, + "title": "RFC: migrate session cache to Redis", + "body": "We decided to replace in-memory sessions.", + "merged_at": "2026-01-15T12:00:00Z", + "updated_at": "2026-01-15T12:00:00Z", + "created_at": "2026-01-10T09:00:00Z", + "user": {"login": "alice"}, + "head": {"ref": "feature/redis-cache"}, +} + + +def test_is_decision_like_matches_keywords() -> None: + assert igo.is_decision_like(SAMPLE_PR) is True + assert igo.is_decision_like({"title": "Fix typo", "body": "n/a"}) is False + + +def test_pr_to_payload_includes_repo_and_number() -> None: + payload = igo.pr_to_payload(SAMPLE_PR, "tiangolo/fastapi") + assert payload["pull_request"]["number"] == 123 + assert payload["repository"]["full_name"] == "tiangolo/fastapi" + assert payload["pull_request"]["user"]["login"] == "alice" + + +def test_main_dry_run_filters_decision_prs() -> None: + prs = [ + SAMPLE_PR, + {"number": 1, "title": "chore: bump deps", "body": "", "merged_at": "2026-01-01T00:00:00Z"}, + ] + with ( + patch.object(igo, "fetch_merged_prs", return_value=prs), + patch.object(sys, "argv", ["import_github_org.py", "--org", "o", "--repo", "r", "--dry-run"]), + ): + assert igo.main() == 0 + + +def test_main_dry_run_all_merged() -> None: + prs = [ + SAMPLE_PR, + { + "number": 2, + "title": "chore: bump deps", + "body": "", + "merged_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + "created_at": "2026-01-01T00:00:00Z", + "user": {"login": "bot"}, + "head": {"ref": "chore"}, + }, + ] + with ( + patch.object(igo, "fetch_merged_prs", return_value=prs), + patch.object( + sys, + "argv", + ["import_github_org.py", "--org", "o", "--repo", "r", "--all-merged", "--dry-run"], + ), + ): + assert igo.main() == 0 + + +def test_main_publish_calls_producer() -> None: + producer = MagicMock() + with ( + patch.object(igo, "fetch_merged_prs", return_value=[SAMPLE_PR]), + patch.object(igo, "GitHubKafkaProducer", return_value=producer), + patch.object(sys, "argv", ["import_github_org.py", "--org", "o", "--repo", "r"]), + ): + assert igo.main() == 0 + assert producer.publish.call_count == 1 + producer.flush.assert_called_once() + producer.close.assert_called_once() From e3699313ba3c4e50434d7e1930fd8fd56fe46103 Mon Sep 17 00:00:00 2001 From: Abhinaysai Kamineni Date: Thu, 11 Jun 2026 00:15:30 -0400 Subject: [PATCH 2/4] Fix Vercel deploy: force frontend-only Vite build at repo root. Adds root vercel.json so monorepo deploys skip Python/uv.lock bundling; rewrite script updates both config files. Co-authored-by: Cursor --- docs/DEPLOY.md | 13 +++++++- frontend/scripts/vercel-api-rewrites.mjs | 39 ++++++++++++++++-------- frontend/vercel.json | 1 - vercel.json | 10 ++++++ 4 files changed, 49 insertions(+), 14 deletions(-) create mode 100644 vercel.json diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 23dd186..83e4c10 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -15,7 +15,18 @@ Cortex is a **multi-service stack** (Kafka, Neo4j, Redis, API, pipeline worker, ## Vercel (frontend) -**Project settings** +**Do not deploy the Python API on Vercel.** If the build log shows `Using Python 3.12 from pyproject.toml` or installs `uv.lock`, Vercel is treating the repo as FastAPI — that bundle (~5 GB) exceeds Lambda limits. + +**Fix (pick one):** + +| Approach | Settings | +|----------|----------| +| **Recommended** | Project Settings → General → **Root Directory** → `frontend` → Save → Redeploy | +| **Repo root** | Keep Root Directory `.` — root `vercel.json` forces `framework: vite` and builds `frontend/dist` only (no Python) | + +After changing Root Directory, clear the Framework Preset override if it still says **FastAPI** — it should be **Vite** or **Other**. + +**Project settings (Root Directory = `frontend`)** - Root Directory: `frontend` - Framework: Vite diff --git a/frontend/scripts/vercel-api-rewrites.mjs b/frontend/scripts/vercel-api-rewrites.mjs index a3150bc..8b390db 100644 --- a/frontend/scripts/vercel-api-rewrites.mjs +++ b/frontend/scripts/vercel-api-rewrites.mjs @@ -3,20 +3,21 @@ * Inject Vercel rewrites so the dashboard calls /query on the same origin. * Vercel proxies to CORTEX_API_ORIGIN server-side — avoids localtunnel 511 * interstitials and CORS when the API runs on your laptop via cloudflared. + * + * Updates frontend/vercel.json (Root Directory = frontend) and repo-root + * vercel.json (Root Directory = .) when the latter exists. */ -import { readFileSync, writeFileSync } from "node:fs"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -const root = join(dirname(fileURLToPath(import.meta.url)), ".."); -const configPath = join(root, "vercel.json"); +const frontendRoot = join(dirname(fileURLToPath(import.meta.url)), ".."); +const repoRoot = join(frontendRoot, ".."); const origin = String(process.env.CORTEX_API_ORIGIN ?? "") .trim() .replace(/\/$/, ""); -const base = JSON.parse(readFileSync(configPath, "utf8")); - const apiRewrites = origin ? [ { source: "/health", destination: `${origin}/health` }, @@ -33,14 +34,28 @@ const apiRewrites = origin ] : []; -base.rewrites = [ - ...apiRewrites, - { source: "/((?!assets/).*)", destination: "/index.html" }, -]; +const spaFallback = { source: "/((?!assets/).*)", destination: "/index.html" }; + +function applyRewrites(configPath) { + const base = JSON.parse(readFileSync(configPath, "utf8")); + base.rewrites = [...apiRewrites, spaFallback]; + writeFileSync(configPath, `${JSON.stringify(base, null, 2)}\n`); +} + +const targets = [join(frontendRoot, "vercel.json")]; +const rootConfig = join(repoRoot, "vercel.json"); +if (existsSync(rootConfig)) { + targets.push(rootConfig); +} + +for (const configPath of targets) { + applyRewrites(configPath); +} -writeFileSync(configPath, `${JSON.stringify(base, null, 2)}\n`); if (origin) { - console.log(`vercel.json: proxying API routes → ${origin}`); + console.log(`vercel.json: proxying API routes → ${origin} (${targets.length} file(s))`); } else { - console.log("vercel.json: no CORTEX_API_ORIGIN — SPA only (use nginx/Vite proxy locally)"); + console.log( + `vercel.json: no CORTEX_API_ORIGIN — SPA only (${targets.length} file(s); use nginx/Vite proxy locally)`, + ); } diff --git a/frontend/vercel.json b/frontend/vercel.json index 218eaa9..ee8695f 100644 --- a/frontend/vercel.json +++ b/frontend/vercel.json @@ -1,4 +1,3 @@ -# Vercel build may rewrite this file when CORTEX_API_ORIGIN is set (see scripts/vercel-api-rewrites.mjs). { "$schema": "https://openapi.vercel.sh/vercel.json", "framework": "vite", diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..051d7ed --- /dev/null +++ b/vercel.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "installCommand": "cd frontend && npm install", + "buildCommand": "cd frontend && npm run build", + "outputDirectory": "frontend/dist", + "framework": "vite", + "rewrites": [ + { "source": "/((?!assets/).*)", "destination": "/index.html" } + ] +} From df862b13dd2a23a3fe3506b2d226129900d4c605 Mon Sep 17 00:00:00 2001 From: Abhinaysai Kamineni Date: Tue, 23 Jun 2026 21:27:53 -0400 Subject: [PATCH 3/4] Add production deploy path and post-deploy portfolio hardening. Ship Vercel middleware API proxy, slim Railway query image, gated demo seeding, OSS workspace import, coverage scores, uptime smoke CI, and Phase 7 launch docs so the live demo stays reliable for recruiters. Co-authored-by: Cursor --- .env.example | 5 + .github/workflows/production-smoke.yml | 46 +++++++ Makefile | 30 ++++- README.md | 11 +- SESSIONS.md | 45 +++++++ api/Dockerfile | 2 +- api/Dockerfile.query | 30 +++++ api/main.py | 15 ++- api/memory.py | 26 ++-- api/requirements-query.txt | 22 ++++ api/schemas.py | 6 + docs/AURA_MIGRATION.md | 76 +++++++++++ docs/CUSTOM_DOMAIN.md | 49 ++++++++ docs/DEMO_RECORDING.md | 16 +++ docs/DEPLOY.md | 99 ++++++++++++++- docs/LAUNCH.md | 60 +++++++++ docs/MCP_SETUP.md | 67 ++++++++++ docs/PORTFOLIO_DEMO.md | 108 ++++++++++++++++ frontend/.gitignore | 1 + frontend/middleware.ts | 72 +++++++++++ frontend/src/index.css | 32 +++++ frontend/src/types/index.ts | 1 + frontend/src/views/AskView.tsx | 23 ++++ frontend/src/views/ExploreView.tsx | 44 ++++--- frontend/src/views/ReviewView.tsx | 17 ++- frontend/vercel.json | 5 +- graph/query.py | 28 +++++ railway.toml | 18 +++ render.yaml | 33 +++++ scripts/import_github_graph.py | 147 ++++++++++++++++++++++ scripts/seed_oss_fastapi_demo.py | 125 ++++++++++++++++++ scripts/start_api_production.sh | 19 +++ scripts/start_portfolio_demo.sh | 30 +++++ tests/api/test_edge_cases.py | 20 +++ tests/api/test_memory_resilience.py | 17 ++- tests/scripts/test_post_deploy_scripts.py | 57 +++++++++ vercel.json | 5 +- 37 files changed, 1366 insertions(+), 41 deletions(-) create mode 100644 .github/workflows/production-smoke.yml create mode 100644 api/Dockerfile.query create mode 100644 api/requirements-query.txt create mode 100644 docs/AURA_MIGRATION.md create mode 100644 docs/CUSTOM_DOMAIN.md create mode 100644 docs/LAUNCH.md create mode 100644 docs/MCP_SETUP.md create mode 100644 docs/PORTFOLIO_DEMO.md create mode 100644 frontend/middleware.ts create mode 100644 railway.toml create mode 100644 render.yaml create mode 100644 scripts/import_github_graph.py create mode 100644 scripts/seed_oss_fastapi_demo.py create mode 100755 scripts/start_api_production.sh create mode 100755 scripts/start_portfolio_demo.sh create mode 100644 tests/scripts/test_post_deploy_scripts.py diff --git a/.env.example b/.env.example index 20f0d54..bc45045 100644 --- a/.env.example +++ b/.env.example @@ -57,9 +57,14 @@ QDRANT_PORT=6333 # ── Redis (hot cache) ──────────────────────────────────────────────────────── # Docker Compose overrides REDIS_HOST to `redis` for api/pipeline-worker. +# Cloud (Upstash): set REDIS_URL instead — takes precedence over HOST/PORT. REDIS_HOST=localhost REDIS_PORT=6379 REDIS_PASSWORD=cortex_local +# REDIS_URL=rediss://default:token@host.upstash.io:6379 + +# Production API: seed demo graph on container start (true = first deploy only). +CORTEX_SEED_DEMO=false # ── Kafka ──────────────────────────────────────────────────────────────────── # External listener on host :9092. Compose sets KAFKA_BOOTSTRAP_SERVERS=kafka:29092 for workers. diff --git a/.github/workflows/production-smoke.yml b/.github/workflows/production-smoke.yml new file mode 100644 index 0000000..588cd18 --- /dev/null +++ b/.github/workflows/production-smoke.yml @@ -0,0 +1,46 @@ +# Optional production uptime smoke — hits live Railway + Vercel proxy. +name: Production smoke + +on: + schedule: + # Every 6 hours — keeps Railway warm-ish and catches regressions. + - cron: "0 */6 * * *" + workflow_dispatch: + +permissions: + contents: read + +jobs: + production-api: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Smoke Railway API + env: + CORTEX_PRODUCTION_URL: ${{ secrets.CORTEX_PRODUCTION_URL }} + run: | + URL="${CORTEX_PRODUCTION_URL:-https://cortex-api-production-fbd5.up.railway.app}" + python scripts/staging_smoke.py --url "$URL" --query "Why CockroachDB for payments?" + + production-dashboard: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Smoke Vercel dashboard proxy + env: + CORTEX_DASHBOARD_URL: ${{ secrets.CORTEX_DASHBOARD_URL }} + run: | + URL="${CORTEX_DASHBOARD_URL:-https://frontend-ten-rouge-99.vercel.app}" + python scripts/staging_smoke.py --url "$URL" --query "Why CockroachDB for payments?" diff --git a/Makefile b/Makefile index ff6a059..13cec92 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,7 @@ -.PHONY: demo demo-dry-run test ci stack init-kafka pipeline-restart pipeline-local verify-pipeline verify-github verify-jira verify-linear verify-connectors seed-dev import-oss verify-dual +.PHONY: demo demo-dry-run test ci stack init-kafka pipeline-restart pipeline-local verify-pipeline verify-github verify-jira verify-linear verify-connectors portfolio-demo seed-dev import-oss import-oss-graph seed-cloud verify-dual verify-dual-production verify-production + +# Production API URL for smoke tests (override on CLI). +CORTEX_PRODUCTION_URL ?= https://cortex-api-production-fbd5.up.railway.app # Local portfolio demo: Docker infra + migrations + seed + API + worker + frontend. demo: @@ -53,5 +56,30 @@ seed-dev: import-oss: uv run python scripts/import_github_org.py --org tiangolo --repo fastapi --dry-run +# Direct Neo4j import (no Kafka) — use for cloud / portfolio backends. +import-oss-graph: + uv run python scripts/import_github_graph.py --org tiangolo --repo fastapi --workspace oss-tiangolo-fastapi --limit 30 + +# Synthetic OSS workspace seed when GitHub import is unavailable. +seed-oss-fastapi: + uv run python scripts/seed_oss_fastapi_demo.py --workspace oss-tiangolo-fastapi + +# Seed + OSS import against remote Neo4j (set NEO4J_URI / credentials in env). +seed-cloud: seed-dev seed-oss-fastapi + verify-dual: uv run python scripts/dual_workspace_smoke.py --workspaces local-dev,oss-tiangolo-fastapi + +verify-production: + uv run python scripts/staging_smoke.py --url $(CORTEX_PRODUCTION_URL) --query "Why CockroachDB for payments?" + +verify-dual-production: + uv run python scripts/dual_workspace_smoke.py --url $(CORTEX_PRODUCTION_URL) --workspaces local-dev,oss-tiangolo-fastapi + +# Vercel frontend build smoke (injects API rewrites when CORTEX_API_ORIGIN is set). +verify-vercel-build: + cd frontend && npm run build + +# Portfolio demo: Docker API must be up; starts cloudflared and prints Vercel env instructions. +portfolio-demo: + @./scripts/start_portfolio_demo.sh diff --git a/README.md b/README.md index 717fee0..06aad31 100644 --- a/README.md +++ b/README.md @@ -21,9 +21,11 @@ | In 3 minutes | Command | |---|---| -| **Run the demo** | `make demo` → [localhost:3000](http://localhost:3000) | -| **Ask a question** | Workspace `local-dev` → *Why CockroachDB for payments?* | -| **Wire an agent** | Add the MCP block below — `cortex_query`, `cortex_inject`, `cortex_remember` | +| **Live demo** | **[frontend-ten-rouge-99.vercel.app](https://frontend-ten-rouge-99.vercel.app)** — workspace `local-dev`, ask *Why CockroachDB for payments?* | +| **Run locally** | `make demo` → [localhost:3000](http://localhost:3000) | +| **Wire an agent** | [MCP setup in 60s](docs/MCP_SETUP.md) — `cortex_query`, `cortex_inject`, `cortex_remember` | + +> **Portfolio / LinkedIn:** Share the [live demo link](https://frontend-ten-rouge-99.vercel.app). For a 24/7 backend (no laptop required), deploy the API to Render/Railway — [docs/PORTFOLIO_DEMO.md](docs/PORTFOLIO_DEMO.md). --- @@ -224,8 +226,9 @@ Host the **dashboard only** on Vercel (`frontend/` as root directory). The API a | Step | Action | |------|--------| | 1 | Deploy `frontend/` with `CORTEX_API_ORIGIN=https://your-api.example.com` | -| 2 | Build runs `scripts/vercel-api-rewrites.mjs` — same-origin `/query` proxy | +| 2 | Edge `middleware.ts` proxies same-origin `/query` to the API at runtime | | 3 | Avoid `VITE_API_URL` pointing at tunnel URLs (511 errors) | +| 4 | API on Railway/Render via `railway.toml` or `render.yaml` — see [docs/DEPLOY.md](docs/DEPLOY.md) | Full guide: [docs/DEPLOY.md](docs/DEPLOY.md). **Dual workspaces** (synthetic `local-dev` vs real OSS imports): [docs/DATA_SOURCES.md](docs/DATA_SOURCES.md). diff --git a/SESSIONS.md b/SESSIONS.md index bee5e17..c031871 100644 --- a/SESSIONS.md +++ b/SESSIONS.md @@ -528,3 +528,48 @@ 1. Merge PR #15 after CI green 2. Phase 6: dashboard polish + demo video 3. Staging smoke with `CORTEX_CMVK_BACKEND=openai` + +--- + +## Session — 2026-06-11 — Vercel v1 deployment + dual-workspace restore +**Duration:** ~45m +**Phase:** Phase 6 — deploy preview + dual-workspace tooling + +### Built +- Restored deleted dual-workspace files from HEAD (`import_github_org.py`, `DEPLOY.md`, `vercel.json`, tests) +- **`frontend/middleware.ts`** — runtime Edge proxy via `CORTEX_API_ORIGIN` (fixes build-time rewrite limitation on Vercel) +- **`railway.toml`**, **`render.yaml`** — API v1 deploy blueprints +- **`api/memory.py`** — `REDIS_URL` support for Upstash cloud Redis +- **`docs/DEPLOY.md`**, **`README.md`** — Railway/Render + middleware deploy guide +- Vercel production redeploy: **https://frontend-ten-rouge-99.vercel.app** — `/health` and `/query` verified end-to-end + +### State at end +- Branch **`feature/dual-workspace-import`** — uncommitted changes (middleware, railway.toml, render.yaml, REDIS_URL) +- **370 passed** (full suite, `--no-cov`) +- `CORTEX_API_ORIGIN` on Vercel points at ephemeral localtunnel — replace with Railway URL for stable demo + +### Next session starts with +1. `railway login` + deploy API; seed Neo4j Aura; update `CORTEX_API_ORIGIN` +2. Commit + PR for dual-workspace + Vercel middleware +3. Optional: `import_github_org.py` live run for `oss-tiangolo-fastapi` + +--- + +## Session — 2026-06-11 — Portfolio demo link live +**Duration:** ~20m +**Phase:** Phase 6 — public demo for portfolio / LinkedIn + +### Built +- **`docs/PORTFOLIO_DEMO.md`** — shareable demo URL, recruiter walkthrough, 24/7 Render/Railway path +- **`scripts/start_portfolio_demo.sh`** + **`make portfolio-demo`** — cloudflared tunnel helper +- **README** — Live demo row points to Vercel production URL +- Vercel `CORTEX_API_ORIGIN` updated; production verified (`/health` 200, `/query` 3 hits) + +### State at end +- **Portfolio URL:** https://frontend-ten-rouge-99.vercel.app +- Demo works while Docker API + `cloudflared` run on Mac; Render/Railway needed for 24/7 LinkedIn traffic +- Uncommitted: middleware, railway.toml, render.yaml, portfolio docs + +### Next session starts with +1. Deploy API to Render/Railway + Aura for always-on demo +2. Commit + push deployment slice diff --git a/api/Dockerfile b/api/Dockerfile index ad86aa8..1708bd5 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -22,4 +22,4 @@ RUN pip install --no-cache-dir --upgrade pip \ EXPOSE 8000 -CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000"] +CMD ["sh", "-c", "uvicorn api.main:app --host 0.0.0.0 --port ${PORT:-8000}"] diff --git a/api/Dockerfile.query b/api/Dockerfile.query new file mode 100644 index 0000000..9ded5e2 --- /dev/null +++ b/api/Dockerfile.query @@ -0,0 +1,30 @@ +# Query-only Cortex API for cloud deploy (Render/Railway portfolio demo). +# Semantic search off (CORTEX_SEMANTIC_ENABLED=false) — no torch/spacy/mlflow. +FROM python:3.11-slim + +WORKDIR /app + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 +ENV CORTEX_SEMANTIC_ENABLED=false + +COPY api/requirements-query.txt ./requirements-query.txt +RUN pip install --no-cache-dir --upgrade pip \ + && pip install --no-cache-dir -r requirements-query.txt + +COPY api ./api +COPY connectors ./connectors +COPY extraction ./extraction +COPY graph ./graph +COPY scoring ./scoring +COPY shared ./shared +COPY pipeline ./pipeline +COPY memory ./memory +COPY intelligence ./intelligence +COPY scripts ./scripts + +EXPOSE 8000 + +RUN chmod +x scripts/start_api_production.sh + +CMD ["scripts/start_api_production.sh"] diff --git a/api/main.py b/api/main.py index 59d1461..fa5d088 100644 --- a/api/main.py +++ b/api/main.py @@ -245,15 +245,26 @@ async def query( workspace_id=request.workspace_id, ) + try: + coverage_score = await memory().workspace_coverage(request.workspace_id) + except Exception: + coverage_score = 0.0 + return QueryResponse( query=request.query, workspace_id=request.workspace_id, results=results, total=len(results), latency_ms=latency_ms, + coverage_score=coverage_score, ) +def _inject_result_limit(max_tokens: int) -> int: + """Map agent token budget to decision count (~400 tokens each), always ≥1.""" + return max(1, min(max_tokens // 400, 10)) + + @app.post( "/inject", response_model=InjectResponse, @@ -266,7 +277,7 @@ async def inject( ) -> InjectResponse: """Inject relevant organizational memory into an AI agent's context window. - Called by the MCP server (mcp/server.ts) on every agent tool invocation. + Called by the MCP server (mcp/server.js) on every agent tool invocation. Returns the most relevant decisions ranked by importance × trust × recency. Ranks injectable decisions by importance × trust (see scoring.trust_scorer). @@ -285,7 +296,7 @@ async def inject( context=request.context, workspace_id=request.workspace_id, caller_roles=roles, - limit=min(request.max_tokens // 400, 10), + limit=_inject_result_limit(request.max_tokens), ) except Exception as exc: log.error("inject.failed", error=str(exc), agent_id=request.agent_id) diff --git a/api/memory.py b/api/memory.py index d0cf5e8..a058bb3 100644 --- a/api/memory.py +++ b/api/memory.py @@ -35,14 +35,22 @@ def _build_redis_client() -> Any | None: except ImportError: return None + redis_url = os.environ.get("REDIS_URL", "").strip() try: - client = redis.Redis( - host=os.environ.get("REDIS_HOST", "localhost"), - port=int(os.environ.get("REDIS_PORT", "6379")), - password=os.environ.get("REDIS_PASSWORD"), - socket_connect_timeout=1, - decode_responses=True, - ) + if redis_url: + client = redis.from_url( + redis_url, + socket_connect_timeout=1, + decode_responses=True, + ) + else: + client = redis.Redis( + host=os.environ.get("REDIS_HOST", "localhost"), + port=int(os.environ.get("REDIS_PORT", "6379")), + password=os.environ.get("REDIS_PASSWORD"), + socket_connect_timeout=1, + decode_responses=True, + ) client.ping() return client except Exception: @@ -136,6 +144,10 @@ async def query_decisions( return results + async def workspace_coverage(self, workspace_id: str) -> float: + """Return heuristic completeness score for a workspace.""" + return await self._graph.workspace_coverage_score(workspace_id) + async def inject_decisions( self, *, diff --git a/api/requirements-query.txt b/api/requirements-query.txt new file mode 100644 index 0000000..5e90b5f --- /dev/null +++ b/api/requirements-query.txt @@ -0,0 +1,22 @@ +# Minimal deps for query-only Cortex API (portfolio demo on Render/Railway). +# Skips torch, spacy, mlflow, sentence-transformers — semantic search disabled. +fastapi>=0.111.0 +uvicorn[standard]>=0.29.0 +pydantic>=2.7.0 +pydantic-settings>=2.3.0 +python-dotenv>=1.0.1 +neo4j>=5.20.0 +redis>=5.0.4 +structlog>=24.2.0 +httpx>=0.27.0 +tenacity>=8.3.0 +python-multipart>=0.0.9 +prometheus-client>=0.20.0 +python-jose[cryptography]>=3.3.0 +passlib[bcrypt]>=1.7.4 +opentelemetry-api>=1.42.0 +opentelemetry-sdk>=1.42.0 +opentelemetry-exporter-otlp-proto-http>=1.42.0 +opentelemetry-instrumentation-fastapi>=0.51b0 +confluent-kafka>=2.4.0 +openai>=1.30.0 diff --git a/api/schemas.py b/api/schemas.py index f4f531f..bf8be00 100644 --- a/api/schemas.py +++ b/api/schemas.py @@ -44,6 +44,12 @@ class QueryResponse(BaseModel): results: list[DecisionResult] total: int latency_ms: float + coverage_score: float = Field( + default=0.0, + ge=0.0, + le=1.0, + description="Heuristic workspace memory completeness (0–1)", + ) class InjectRequest(BaseModel): diff --git a/docs/AURA_MIGRATION.md b/docs/AURA_MIGRATION.md new file mode 100644 index 0000000..2b0eef1 --- /dev/null +++ b/docs/AURA_MIGRATION.md @@ -0,0 +1,76 @@ +# Migrate production Neo4j from Railway to Aura + +Railway’s community Neo4j image is fine for first deploy but lacks managed backups and stable memory limits. **Neo4j Aura Free** is the recommended production graph for the portfolio demo. + +## Why migrate + +| Railway Neo4j | Neo4j Aura Free | +|---------------|-----------------| +| 256MB heap, manual ops | Managed, auto-patches | +| No backups | Daily backups (paid tiers) / export on free | +| Crash loops under load | Stable for demo scale | +| Internal DNS only | `neo4j+s://` public bolt | + +Decision **P-003** in [DECISIONS.md](../DECISIONS.md): Aura for production graph; Railway hosts API + Redis only. + +## Steps + +### 1. Create Aura instance + +1. Sign in at [console.neo4j.io](https://console.neo4j.io). +2. **New instance** → **Free** → region close to Railway API (e.g. `us-east-1`). +3. Save connection URI, user (`neo4j`), and password. + +### 2. Export from Railway (if data exists) + +From a machine that can reach Railway Neo4j: + +```bash +export NEO4J_URI=bolt://neo4j-db.railway.internal:7687 # or public proxy if configured +export NEO4J_USER=neo4j +export NEO4J_PASSWORD= + +uv run python graph/migrate.py +# Optional: dump via neo4j-admin if you have shell access on the container +``` + +Simplest path for demo data: **re-seed on Aura** instead of dump/restore: + +```bash +export NEO4J_URI=neo4j+s://xxxx.databases.neo4j.io +export NEO4J_USER=neo4j +export NEO4J_PASSWORD= + +uv run python graph/migrate.py +CORTEX_SEED_DEMO=true uv run python scripts/seed_demo.py --workspace local-dev --scale small +uv run python scripts/import_github_graph.py --org tiangolo --repo fastapi --workspace oss-tiangolo-fastapi --limit 30 +``` + +### 3. Point Railway API at Aura + +Railway → **cortex-api** → Variables: + +```env +NEO4J_URI=neo4j+s://xxxx.databases.neo4j.io +NEO4J_USER=neo4j +NEO4J_PASSWORD= +CORTEX_SEED_DEMO=false +``` + +Redeploy API. Verify: + +```bash +curl -s https://cortex-api-production-fbd5.up.railway.app/health | jq . +``` + +### 4. Rotate credentials + +1. Change Aura password in console after migration. +2. Update Railway env vars (never commit passwords). +3. If the old Railway Neo4j password appeared in chat/logs, rotate it and decommission the Railway Neo4j service. + +### 5. Uptime + +Keep Railway Neo4j stopped or deleted after cutover to avoid split-brain confusion. + +See [DEPLOY.md](./DEPLOY.md) and [PORTFOLIO_DEMO.md](./PORTFOLIO_DEMO.md). diff --git a/docs/CUSTOM_DOMAIN.md b/docs/CUSTOM_DOMAIN.md new file mode 100644 index 0000000..abb6952 --- /dev/null +++ b/docs/CUSTOM_DOMAIN.md @@ -0,0 +1,49 @@ +# Custom domain for the Cortex dashboard (Vercel) + +Replace `frontend-ten-rouge-99.vercel.app` with a branded URL (e.g. `cortex.askmy-stack.dev`). + +## 1. Add domain in Vercel + +1. Vercel → Project **frontend** → **Settings** → **Domains**. +2. Add your domain (apex or subdomain). +3. Follow Vercel’s DNS instructions (CNAME to `cname.vercel-dns.com` or A records for apex). + +## 2. Keep API proxy working + +No change to `CORTEX_API_ORIGIN` — middleware still proxies `/query` and `/health` to Railway. + +Ensure **Environment Variables** include: + +```env +CORTEX_API_ORIGIN=https://cortex-api-production-fbd5.up.railway.app +``` + +Apply to **Production** (and Preview if you use preview URLs). + +## 3. Optional: API subdomain + +If you want `api.cortex.example.com` instead of middleware-only: + +1. Deploy API with public URL (Railway custom domain or Render). +2. Set `CORTEX_API_ORIGIN` to that URL **or** set `VITE_API_URL` (requires rebuild — prefer middleware). + +Recommended for portfolio: **dashboard on custom domain, API stays on Railway**, proxy via middleware (no CORS). + +## 4. Update links + +After DNS propagates, update: + +- [README.md](../README.md) live demo badge +- [docs/PORTFOLIO_DEMO.md](./PORTFOLIO_DEMO.md) +- LinkedIn / portfolio site + +## 5. Verify + +```bash +curl -s https://your-domain.example/health | jq . +curl -s -X POST https://your-domain.example/query \ + -H 'content-type: application/json' \ + -d '{"query":"Why CockroachDB for payments?","workspace_id":"local-dev","limit":3}' +``` + +Both should return JSON from the Railway API. diff --git a/docs/DEMO_RECORDING.md b/docs/DEMO_RECORDING.md index 1176429..f63b522 100644 --- a/docs/DEMO_RECORDING.md +++ b/docs/DEMO_RECORDING.md @@ -11,6 +11,22 @@ Use this when capturing the **~3 minute** portfolio demo and any **README GIF**. - [http://localhost:3000](http://localhost:3000) — dashboard loads, **API health** is green. - [http://localhost:8000/docs](http://localhost:8000/docs) — OpenAPI loads. +## Cloud demo script (Vercel + Railway) + +Use this when recording for LinkedIn without running Docker locally. + +| Time | Action | +|------|--------| +| 0:00 | Open https://frontend-ten-rouge-99.vercel.app — mention 24/7 Vercel + Railway stack. | +| 0:15 | **Ask** tab → confirm workspace **`local-dev`**. | +| 0:30 | Query *Why CockroachDB for payments?* — show results, trust scores, **coverage %**. | +| 1:00 | Switch workspace to **`oss-tiangolo-fastapi`** (if imported) — compare real OSS decisions. | +| 1:30 | **Explore** memory map; **Review** contradictions if seeded. | +| 2:00 | Show [MCP_SETUP.md](./MCP_SETUP.md) — wire Cursor in one JSON block. | +| 2:30 | GitHub README live demo link; close with organizational memory thesis. | + +Custom domain steps: [CUSTOM_DOMAIN.md](./CUSTOM_DOMAIN.md). + ## Suggested script (voiceover optional) | Time | Action | diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 83e4c10..dedc604 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -30,13 +30,14 @@ After changing Root Directory, clear the Framework Preset override if it still s - Root Directory: `frontend` - Framework: Vite -- Build: `npm run build` (runs `scripts/vercel-api-rewrites.mjs`) +- Build: `npm run build` +- API proxy: `middleware.ts` reads `CORTEX_API_ORIGIN` at **runtime** (Vercel parses `vercel.json` before build — build-time rewrites cannot inject env vars) **Environment variables** | Variable | Purpose | |----------|---------| -| `CORTEX_API_ORIGIN` | Public API URL — Vercel rewrites `/query`, `/health`, etc. server-side | +| `CORTEX_API_ORIGIN` | Public API URL — Edge Middleware proxies `/query`, `/health`, etc. server-side | | ~~`VITE_API_URL`~~ | **Do not** point at `loca.lt` — causes 511 tunnel interstitial errors | **Do not** import the repo root with Framework Preset **FastAPI**. That installs the full `uv.lock` (~5 GB) and exceeds Lambda limits. @@ -77,6 +78,89 @@ python scripts/import_github_org.py --org tiangolo --repo fastapi --dry-run make verify-dual ``` +## Railway / Render (API v1) + +Deploy **only the API** — not Kafka or the pipeline worker. Pre-seed Neo4j with `scripts/seed_demo.py` so `/query` works without live ingestion. + +### Railway + +```bash +# Install CLI: https://docs.railway.com/guides/cli +railway login +railway init # link this repo +railway up # uses railway.toml → api/Dockerfile +``` + +**Required environment variables** (Railway → Variables): + +| Variable | Example | +|----------|---------| +| `NEO4J_URI` | `neo4j+s://xxxx.databases.neo4j.io` | +| `NEO4J_USER` | `neo4j` | +| `NEO4J_PASSWORD` | Aura password | +| `REDIS_URL` | `rediss://default:token@host.upstash.io:6379` | +| `CORTEX_API_KEYS` | `demo-readonly:authenticated` (optional abuse control) | +| `CORTEX_SEMANTIC_ENABLED` | `false` | +| `CORTEX_SEED_DEMO` | `true` **first deploy only** — then set `false` so restarts skip re-seed | + +Copy the public Railway URL (e.g. `https://cortex-api-production.up.railway.app`), set `CORTEX_API_ORIGIN` on Vercel, and redeploy the frontend. + +**Production startup:** `scripts/start_api_production.sh` runs migrations on every boot. Demo seed runs only when `CORTEX_SEED_DEMO=true`. + +### Render + +Use [render.yaml](../render.yaml) as a Blueprint, or create a **Web Service** with Docker runtime and `api/Dockerfile` as the Dockerfile path. Same env vars as Railway. + +### Seed production graph (one-time) + +**Option A — first Railway deploy:** set `CORTEX_SEED_DEMO=true` on the API service, deploy once, then set `CORTEX_SEED_DEMO=false`. + +**Option B — from laptop** with Neo4j credentials in `.env`: + +```bash +export NEO4J_URI=neo4j+s://... +export NEO4J_USER=neo4j +export NEO4J_PASSWORD=... +uv run python graph/migrate.py +uv run python scripts/seed_demo.py --workspace local-dev --scale small +uv run python scripts/import_github_graph.py --org tiangolo --repo fastapi --workspace oss-tiangolo-fastapi --limit 30 +make verify-dual-production +``` + +Direct graph import (no Kafka): [scripts/import_github_graph.py](../scripts/import_github_graph.py). + +### Neo4j Aura migration + +See [AURA_MIGRATION.md](./AURA_MIGRATION.md). Rotate passwords after any credential exposure — update Railway env vars only, never commit secrets. + +### Optional demo API key (abuse control) + +For public demos, use a read-only key so anonymous traffic cannot hammer write endpoints: + +```bash +CORTEX_API_KEYS=demo-readonly:authenticated +``` + +Dashboard users paste the key in **Connection** settings. Open `/query` and `/health` remain usable without a key when `CORTEX_API_KEYS` is unset. + +### Wire Vercel → API + +```bash +# Vercel project → Settings → Environment Variables +CORTEX_API_ORIGIN=https://your-api.railway.app + +cd frontend && npx vercel deploy --prod +``` + +Verify: + +```bash +curl -s https://your-vercel-app.vercel.app/health +curl -s -X POST https://your-vercel-app.vercel.app/query \ + -H "Content-Type: application/json" \ + -d '{"query":"Why CockroachDB?","workspace_id":"local-dev","limit":5}' +``` + ## Auth on preview ```bash @@ -85,3 +169,14 @@ CORTEX_DEMO_API_KEY=preview-key ``` `make demo` and `scripts/demo.sh` send `Authorization` when these are set. + +## Optional cloud webhook path (v2) + +Full ingestion uses Kafka + pipeline-worker locally. For cloud v1 without Kafka, use direct graph import: + +```bash +make import-oss-graph # real GitHub PRs → Neo4j +make seed-oss-fastapi # synthetic OSS demo fallback +``` + +Future v2: deploy pipeline-worker as a second Railway service + Upstash Kafka, or add `POST /webhooks/github` → synchronous extract/write for demo scale. See [LAUNCH.md](./LAUNCH.md). diff --git a/docs/LAUNCH.md b/docs/LAUNCH.md new file mode 100644 index 0000000..66ebd01 --- /dev/null +++ b/docs/LAUNCH.md @@ -0,0 +1,60 @@ +# Phase 7 — Open-source launch checklist + +Use this when posting to HN, LinkedIn, and GitHub. + +## Assets + +- [ ] Live demo: https://frontend-ten-rouge-99.vercel.app (or custom domain — [CUSTOM_DOMAIN.md](./CUSTOM_DOMAIN.md)) +- [ ] 3-min video: [DEMO_RECORDING.md](./DEMO_RECORDING.md) (cloud + local scripts) +- [ ] README **Live demo** badge at top +- [ ] MCP wiring: [MCP_SETUP.md](./MCP_SETUP.md) + +## Smoke before posting + +```bash +make verify-production # health + query against Railway +make verify-dual-production # local-dev + oss-tiangolo-fastapi +``` + +## Hacker News (Show HN draft) + +**Title:** Show HN: Cortex – organizational memory OS for AI agents (Neo4j + MCP) + +**Body:** + +Cortex captures **decisions** (not documents) from Slack, GitHub, Jira, etc., stores them in a knowledge graph, and injects relevant context into agents via MCP at inference time. + +Live demo (no install): https://frontend-ten-rouge-99.vercel.app +Try workspace `local-dev`, query: *Why CockroachDB for payments?* + +Stack: Kafka ingestion pipeline, Neo4j graph, importance/trust scoring, contradiction detection, FastAPI query layer, React dashboard, TypeScript MCP server. + +GitHub: https://github.com/askmy-stack/Cortex + +Feedback welcome — especially on the “active injection vs passive RAG” thesis. + +## LinkedIn post (draft) + +Shipped a live demo of **Cortex** — an organizational memory OS for AI-native teams. + +Instead of dumping docs into a vector DB, Cortex: +- Extracts **decisions** from Slack, GitHub, Jira +- Scores importance + trust at ingestion +- Stores relationships in **Neo4j** +- Injects context into any MCP agent at inference time + +Try it: https://frontend-ten-rouge-99.vercel.app +Query: *Why CockroachDB for payments?* (workspace: local-dev) + +Open source: github.com/askmy-stack/Cortex +#AI #MLOps #OpenSource #KnowledgeGraph + +## Optional v2 (post-launch) + +- Cloud webhook → API → graph write (bypass Kafka for demo) — see [DEPLOY.md](./DEPLOY.md) +- Railway worker service for live GitHub ingestion +- Contradiction pair in Review tab for demo narrative + +## CI + +Scheduled production smoke: `.github/workflows/production-smoke.yml` (optional `CORTEX_PRODUCTION_URL` secret). diff --git a/docs/MCP_SETUP.md b/docs/MCP_SETUP.md new file mode 100644 index 0000000..ad28d23 --- /dev/null +++ b/docs/MCP_SETUP.md @@ -0,0 +1,67 @@ +# Wire Cortex MCP in 60 seconds + +Cortex ships a **stdio MCP server** that calls your live API (`POST /query`, `POST /inject`). Agents get organizational memory without custom integration code. + +## Prerequisites + +- Node.js 18+ +- A running Cortex API (local `make demo`, or production Railway URL) + +## Cursor / Claude Desktop config + +Add to your MCP settings (Cursor: **Settings → MCP**, or `~/.cursor/mcp.json`): + +```json +{ + "mcpServers": { + "cortex": { + "command": "node", + "args": ["/absolute/path/to/Cortex/mcp/server.js"], + "env": { + "CORTEX_API_URL": "https://cortex-api-production-fbd5.up.railway.app", + "CORTEX_API_KEY": "" + } + } + } +} +``` + +For **local** development: + +```json +"CORTEX_API_URL": "http://localhost:8000" +``` + +When the cloud API uses optional auth, set `CORTEX_API_KEY` to your read-only demo key (see [DEPLOY.md](./DEPLOY.md)). + +## Available tools + +| Tool | Purpose | +|------|---------| +| `cortex_query` | Natural-language search over organizational decisions | +| `cortex_remember` | Submit explicit memory into the ingestion pipeline | +| `cortex_inject` | Active context injection for an agent prompt | + +## Verify + +1. Restart Cursor after saving MCP config. +2. In chat, ask the agent to call `cortex_query` with workspace `local-dev` and query *Why CockroachDB for payments?* +3. You should see structured decision JSON — not raw Slack threads. + +## Architecture note (interviews) + +```text +Your IDE agent → MCP (stdio) → Cortex API → Neo4j graph (+ Redis cache) +``` + +The MCP server is **agent-neutral** — any MCP-compatible client works. Cloud v1 deploys the API only; MCP runs beside your agent locally (zero extra Railway cost). + +## Troubleshooting + +| Symptom | Fix | +|---------|-----| +| `401 Unauthorized` | Set `CORTEX_API_KEY` or remove keys on API for open demo | +| `503` / connection refused | Check `CORTEX_API_URL`; Railway free tier may cold-start ~30s | +| Empty results | Confirm workspace id (`local-dev` on cloud seed) | + +See also: [README.md](../README.md), [PORTFOLIO_DEMO.md](./PORTFOLIO_DEMO.md). diff --git a/docs/PORTFOLIO_DEMO.md b/docs/PORTFOLIO_DEMO.md new file mode 100644 index 0000000..8ea1cf3 --- /dev/null +++ b/docs/PORTFOLIO_DEMO.md @@ -0,0 +1,108 @@ +# Cortex — Portfolio & LinkedIn Demo + +Use this page when linking Cortex from your portfolio site, GitHub README, or LinkedIn posts. + +## Live demo (share this URL) + +**https://frontend-ten-rouge-99.vercel.app** + +### 30-second walkthrough (for recruiters) + +1. Open the link — Cortex dashboard loads (Vercel CDN). +2. Confirm workspace is **`local-dev`** (preset chip). +3. Go to **Ask** and query: **`Why CockroachDB for payments?`** +4. Point out: decisions from Slack/GitHub/Jira, trust scores, graph relationships — not raw chat logs. + +### What to say in one line + +> *Cortex is an organizational memory OS — it captures decisions from every tool, stores them in a knowledge graph, and injects relevant context into AI agents at inference time via MCP.* + +--- + +## How the public demo is wired + +```text +Browser → Vercel (React dashboard) + ↓ Edge Middleware (CORTEX_API_ORIGIN) + API (FastAPI + Neo4j + Redis) +``` + +- **Dashboard:** Vercel project `frontend` (static Vite build). +- **API proxy:** `frontend/middleware.ts` forwards `/query`, `/health`, etc. server-side. +- **Do not** set `VITE_API_URL` on Vercel — same-origin `/query` avoids CORS and tunnel 511 errors. + +--- + +## Keeping the demo online + +| Mode | Uptime | Setup | +|------|--------|--------| +| **Production** (live) | 24/7 | Railway API + Neo4j + Redis on `cortex-api-demo` project | +| **Dev tunnel** (fallback) | While your Mac + Docker + `cloudflared` run | `make portfolio-demo` | + +### Quick dev tunnel (laptop demo) + +```bash +make portfolio-demo +``` + +Starts `cloudflared` to `localhost:8000`, prints the tunnel URL, and reminds you to set `CORTEX_API_ORIGIN` on Vercel if the URL changed. + +### Production backend (deployed) + +| Service | URL | +|---------|-----| +| API | https://cortex-api-production-fbd5.up.railway.app | +| Dashboard | https://frontend-ten-rouge-99.vercel.app | +| Railway project | `cortex-api-demo` | + +Vercel `CORTEX_API_ORIGIN` → Railway API URL (no laptop required). + +### Re-deploy from scratch + +1. `railway login` → `railway init` (or use existing `cortex-api-demo` project). +2. Add **Redis** (`railway add --database redis`) and **Neo4j** (`neo4j:5.20-community` image). +3. Deploy API: `railway up --service cortex-api` (uses `api/Dockerfile.query`). +4. Set env on the API service: + + ```env + NEO4J_URI=neo4j+s://... + NEO4J_USER=neo4j + NEO4J_PASSWORD=... + REDIS_URL=rediss://... + CORTEX_SEMANTIC_ENABLED=false + CORTEX_API_KEYS=preview-key:admin;authenticated + ``` + +5. Seed the graph once from your machine: + + ```bash + export NEO4J_URI=neo4j+s://... + uv run python graph/migrate.py + uv run python scripts/seed_demo.py --workspace local-dev --scale small + ``` + + Or set `CORTEX_SEED_DEMO=true` on first API deploy, then **`CORTEX_SEED_DEMO=false`** after boot. + +6. Vercel → Project **frontend** → Environment Variables → `CORTEX_API_ORIGIN` = your public API URL (e.g. `https://cortex-api.onrender.com`). No frontend redeploy needed — middleware reads it at runtime. + +**Custom domain:** [CUSTOM_DOMAIN.md](./CUSTOM_DOMAIN.md) +**Neo4j Aura:** [AURA_MIGRATION.md](./AURA_MIGRATION.md) +**Launch checklist:** [LAUNCH.md](./LAUNCH.md) + +--- + +## Vercel project settings + +| Setting | Value | +|---------|--------| +| Root Directory | `frontend` | +| Framework | Vite | +| Production URL | https://frontend-ten-rouge-99.vercel.app | +| Env var | `CORTEX_API_ORIGIN` = public API base URL (no trailing slash) | + +Redeploy frontend after code changes: + +```bash +cd frontend && npx vercel deploy --prod +``` diff --git a/frontend/.gitignore b/frontend/.gitignore index 3cced3b..3cffee0 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -1,3 +1,4 @@ .vercel dist node_modules +.env*.local diff --git a/frontend/middleware.ts b/frontend/middleware.ts new file mode 100644 index 0000000..c665959 --- /dev/null +++ b/frontend/middleware.ts @@ -0,0 +1,72 @@ +/** + * Vercel Edge Middleware — proxy API routes to CORTEX_API_ORIGIN at runtime. + * + * Build-time vercel.json rewrites cannot read env vars on Vercel (config is + * parsed before the build step). Middleware reads CORTEX_API_ORIGIN on each + * request so production deploys pick up dashboard → API wiring without rebuilds. + */ + +export const config = { + matcher: [ + "/health", + "/metrics", + "/query", + "/inject", + "/remember", + "/docs", + "/openapi.json", + "/decisions/:path*", + "/contradictions/:path*", + "/gdpr/:path*", + "/webhooks/:path*", + ], +}; + +const API_PREFIXES = config.matcher.map((m) => m.replace("/:path*", "")); + +function isApiRoute(pathname: string): boolean { + return API_PREFIXES.some( + (prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`), + ); +} + +export default async function middleware(request: Request): Promise { + const origin = (process.env.CORTEX_API_ORIGIN ?? "").trim().replace(/\/$/, ""); + if (!origin) { + return new Response( + JSON.stringify({ + detail: + "CORTEX_API_ORIGIN is not set on Vercel. Add your Railway/Render API URL in Project Settings.", + }), + { status: 503, headers: { "content-type": "application/json" } }, + ); + } + + const url = new URL(request.url); + if (!isApiRoute(url.pathname)) { + return new Response("Not found", { status: 404 }); + } + + const target = `${origin}${url.pathname}${url.search}`; + const headers = new Headers(request.headers); + headers.delete("host"); + + const init: RequestInit = { + method: request.method, + headers, + redirect: "manual", + }; + if (request.method !== "GET" && request.method !== "HEAD") { + init.body = request.body; + } + + try { + return await fetch(target, init); + } catch (error) { + const message = error instanceof Error ? error.message : "upstream fetch failed"; + return new Response( + JSON.stringify({ detail: `API unreachable at ${origin}: ${message}` }), + { status: 502, headers: { "content-type": "application/json" } }, + ); + } +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 8fd06b5..5f46067 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -547,6 +547,38 @@ a { background: var(--accent-glow); } +.demo-hint { + margin-bottom: 1rem; + font-size: 0.9rem; + color: var(--text-muted); +} + +.link-button { + padding: 0; + border: none; + background: none; + color: var(--accent); + font: inherit; + font-weight: 600; + cursor: pointer; + text-decoration: underline; + text-underline-offset: 2px; +} + +.link-button:hover { + opacity: 0.85; +} + +.coverage-badge { + display: inline-block; + padding: 0.1rem 0.45rem; + border-radius: 999px; + background: var(--accent-glow); + color: var(--accent); + font-size: 0.85em; + font-weight: 600; +} + .btn { padding: 0.55rem 1.1rem; border-radius: var(--radius); diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index e98e7b1..cf89dd8 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -29,6 +29,7 @@ export type QueryResponse = { results: DecisionResult[]; total: number; latency_ms: number; + coverage_score?: number; }; export type InjectResponse = { diff --git a/frontend/src/views/AskView.tsx b/frontend/src/views/AskView.tsx index cf951dd..a5a59b3 100644 --- a/frontend/src/views/AskView.tsx +++ b/frontend/src/views/AskView.tsx @@ -113,6 +113,20 @@ export function AskView() { subtitle="Natural language search over captured decisions — who decided, what systems are affected, and why." /> +

+ Live demo: workspace local-dev · try{" "} + +

+
@@ -212,6 +226,15 @@ export function AskView() {

{lastQuery.total} result{lastQuery.total === 1 ? "" : "s"} ·{" "} {lastQuery.latency_ms}ms + {typeof lastQuery.coverage_score === "number" ? ( + <> + {" "} + · coverage{" "} + + {Math.round(lastQuery.coverage_score * 100)}% + + + ) : null}

@@ -160,7 +165,7 @@ export function ReviewView() { diff --git a/frontend/vercel.json b/frontend/vercel.json index ee8695f..cc7b312 100644 --- a/frontend/vercel.json +++ b/frontend/vercel.json @@ -4,6 +4,9 @@ "buildCommand": "npm run build", "outputDirectory": "dist", "rewrites": [ - { "source": "/((?!assets/).*)", "destination": "/index.html" } + { + "source": "/((?!assets/).*)", + "destination": "/index.html" + } ] } diff --git a/graph/query.py b/graph/query.py index 8db2862..78bbfcd 100644 --- a/graph/query.py +++ b/graph/query.py @@ -387,6 +387,34 @@ async def health(self) -> bool: except Exception: return False + async def workspace_coverage_score(self, workspace_id: str) -> float: + """Estimate memory completeness for a workspace (0–1 heuristic). + + Uses decision and system counts vs portfolio demo targets. Full Phase 8 + coverage scoring will replace this with domain-aware completeness. + """ + driver = await self._driver_instance() + async with driver.session() as session: + result = await session.run( + """ + MATCH (d:Decision {workspace_id: $workspace_id}) + WHERE d.status <> 'archived' + WITH count(d) AS decisions + OPTIONAL MATCH (s:System {workspace_id: $workspace_id}) + WITH decisions, count(s) AS systems + RETURN decisions, systems + """, + workspace_id=workspace_id, + ) + record = await result.single() + if record is None: + return 0.0 + decisions = int(record.get("decisions") or 0) + systems = int(record.get("systems") or 0) + decision_ratio = min(1.0, decisions / 50.0) + system_ratio = min(1.0, systems / 15.0) + return round(min(1.0, 0.7 * decision_ratio + 0.3 * system_ratio), 3) + async def find_conflict_candidates( self, *, diff --git a/railway.toml b/railway.toml new file mode 100644 index 0000000..0955945 --- /dev/null +++ b/railway.toml @@ -0,0 +1,18 @@ +# Cortex API — Railway deployment (v1 preview) +# https://docs.railway.com/guides/dockerfiles +# +# Deploy: +# railway login && railway init +# railway up --dockerfile api/Dockerfile +# +# Set env vars in Railway dashboard (see docs/DEPLOY.md). + +[build] +builder = "DOCKERFILE" +dockerfilePath = "api/Dockerfile.query" + +[deploy] +healthcheckPath = "/health" +healthcheckTimeout = 300 +restartPolicyType = "ON_FAILURE" +restartPolicyMaxRetries = 3 diff --git a/render.yaml b/render.yaml new file mode 100644 index 0000000..7ecc9f1 --- /dev/null +++ b/render.yaml @@ -0,0 +1,33 @@ +# Cortex API — Render Blueprint (v1 preview alternative to Railway) +# https://render.com/docs/blueprint-spec +# +# Connect repo → New Blueprint → point at this file. +# Fill secret env vars in the Render dashboard after first deploy. + +services: + - type: web + name: cortex-api + runtime: docker + dockerfilePath: ./api/Dockerfile + dockerContext: . + plan: free + healthCheckPath: /health + envVars: + - key: ENVIRONMENT + value: production + - key: NEO4J_URI + sync: false + - key: NEO4J_USER + value: neo4j + - key: NEO4J_PASSWORD + sync: false + - key: REDIS_URL + sync: false + - key: CORTEX_API_KEYS + sync: false + - key: CORTEX_SEMANTIC_ENABLED + value: "false" + - key: EXTRACTION_BACKEND + value: openai + - key: OPENAI_API_KEY + sync: false diff --git a/scripts/import_github_graph.py b/scripts/import_github_graph.py new file mode 100644 index 0000000..8dbb6f9 --- /dev/null +++ b/scripts/import_github_graph.py @@ -0,0 +1,147 @@ +#!/usr/bin/env python3 +"""Import merged GitHub PRs directly into Neo4j (no Kafka). + +Use for cloud demos where pipeline-worker is not deployed. Fetches PRs from +the GitHub REST API, normalises to RawEvent, extracts decisions, scores, and +writes via GraphWriter — same path as the extraction worker minus Kafka. + +Usage: + python scripts/import_github_graph.py --org tiangolo --repo fastapi --dry-run + python scripts/import_github_graph.py --org tiangolo --repo fastapi \\ + --workspace oss-tiangolo-fastapi --limit 30 +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from pathlib import Path +from typing import Any + +_REPO = Path(__file__).resolve().parents[1] +if str(_REPO) not in sys.path: + sys.path.insert(0, str(_REPO)) + +from dotenv import load_dotenv + +from connectors.github.producer import normalise_github_event +from extraction.decision_extractor import DecisionExtractor +from graph.writer import GraphWriter +from memory.quarantine import persist_quarantine +from scoring.write_pipeline import DecisionScoringPipeline, write_reject_reason +from scripts.import_github_org import ( + fetch_merged_prs, + is_decision_like, + pr_to_payload, +) +from shared.models import RawEvent + + +def _process_event( + raw: RawEvent, + *, + extractor: DecisionExtractor, + scoring: DecisionScoringPipeline, + writer: GraphWriter, +) -> str | None: + """Extract, score, and write one raw event; return decision id or None.""" + decision = extractor.extract(raw) + if decision is None: + return None + scoring.score(decision) + reject = write_reject_reason(decision) + if reject is not None: + persist_quarantine(decision, reject) + return None + try: + return writer.write(decision) + except ValueError: + return None + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Import merged GitHub PRs directly into Neo4j", + ) + parser.add_argument("--org", required=True, help="GitHub org or user") + parser.add_argument("--repo", required=True, help="Repository name") + parser.add_argument( + "--workspace", + default=None, + help="Cortex workspace id (default: oss--)", + ) + parser.add_argument("--limit", type=int, default=30, help="Max merged PRs to fetch") + parser.add_argument( + "--all-merged", + action="store_true", + help="Import all merged PRs (default: decision-like PRs only)", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Print planned RawEvents without writing to Neo4j", + ) + args = parser.parse_args() + + load_dotenv(_REPO / ".env") + workspace = args.workspace or f"oss-{args.org}-{args.repo}" + repo_full = f"{args.org}/{args.repo}" + decision_only = not args.all_merged + + print(f"Fetching merged PRs from {repo_full} (limit={args.limit})…") + prs = fetch_merged_prs(args.org, args.repo, limit=args.limit) + if decision_only: + prs = [pr for pr in prs if is_decision_like(pr)] + + print(f"Selected {len(prs)} pull request(s) for workspace={workspace!r}") + + raw_events: list[RawEvent] = [] + for pr in prs: + payload = pr_to_payload(pr, repo_full) + raw = normalise_github_event(payload, "pull_request", workspace) + if raw is not None: + raw_events.append(raw) + + if args.dry_run: + for raw in raw_events[:5]: + print(json.dumps(json.loads(raw.model_dump_json()), indent=2)[:500], "…") + if len(raw_events) > 5: + print(f"… and {len(raw_events) - 5} more") + print(f"\nDry run — would process {len(raw_events)} event(s) into Neo4j") + return 0 + + if not raw_events: + print("No events to import.", file=sys.stderr) + return 1 + + extractor = DecisionExtractor() + scoring = DecisionScoringPipeline() + writer = GraphWriter() + written: list[str] = [] + skipped = 0 + try: + for raw in raw_events: + event_id = _process_event( + raw, + extractor=extractor, + scoring=scoring, + writer=writer, + ) + if event_id: + written.append(event_id) + else: + skipped += 1 + finally: + writer.close() + + print(f"Import complete for workspace={workspace!r}") + print(f" Wrote {len(written)} decisions") + if skipped: + print(f" Skipped {skipped} events (no extract / below threshold / quarantine)") + return 0 if written else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/seed_oss_fastapi_demo.py b/scripts/seed_oss_fastapi_demo.py new file mode 100644 index 0000000..75959fd --- /dev/null +++ b/scripts/seed_oss_fastapi_demo.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Seed oss-tiangolo-fastapi workspace with FastAPI-themed demo decisions. + +Use when GitHub import (import_github_graph.py) is unavailable — e.g. cloud +Neo4j without Kafka or without GitHub token. Idempotent via deterministic UUIDs. + +Usage: + python scripts/seed_oss_fastapi_demo.py + python scripts/seed_oss_fastapi_demo.py --dry-run +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +_REPO = Path(__file__).resolve().parents[1] +if str(_REPO) not in sys.path: + sys.path.insert(0, str(_REPO)) + +from dotenv import load_dotenv + +from scripts.demo_catalog import _spec, build_decision_from_spec + +WORKSPACE = "oss-tiangolo-fastapi" + +_OSS_SPECS: list[dict[str, object]] = [ + _spec( + "oss-async-deps", + source="github", + channel="pull_request", + content="Adopt async dependency injection pattern for database sessions in FastAPI routers.", + made_by=["person:tiangolo"], + affects=["system:fastapi-core"], + rationale=["Aligns with ASGI concurrency model; reduces thread pool pressure."], + importance=0.72, + trust=0.8, + ), + _spec( + "oss-openapi-3-1", + source="github", + channel="pull_request", + content="Standardize on OpenAPI 3.1 schema generation for all public FastAPI examples.", + made_by=["person:tiangolo"], + affects=["system:fastapi-docs", "system:openapi-generator"], + rationale=["Improves SDK compatibility for downstream API clients."], + importance=0.74, + trust=0.82, + ), + _spec( + "oss-pydantic-v2", + source="github", + channel="issue_comment", + content="Migrate validation layer to Pydantic v2 with model_config instead of Config class.", + made_by=["person:tiangolo"], + affects=["system:fastapi-core"], + rationale=["Performance gains and parity with ecosystem tooling."], + importance=0.76, + trust=0.83, + ), + _spec( + "oss-starlette-pin", + source="github", + channel="pull_request", + content="Pin Starlette minor version in release branches to avoid breaking middleware hooks.", + made_by=["person:tiangolo"], + affects=["system:fastapi-core", "system:starlette"], + rationale=["Prevents surprise regressions in long-lived LTS installs."], + importance=0.7, + trust=0.79, + ), + _spec( + "oss-security-defaults", + source="github", + channel="pull_request", + content="Enable secure cookie defaults and document HTTPS requirements in deployment guides.", + made_by=["person:tiangolo"], + affects=["system:fastapi-docs", "system:fastapi-security"], + rationale=["Reduces misconfiguration in production deployments."], + importance=0.78, + trust=0.85, + ), +] + + +def main() -> int: + parser = argparse.ArgumentParser(description="Seed oss-tiangolo-fastapi demo workspace") + parser.add_argument("--workspace", default=WORKSPACE) + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args() + + load_dotenv(_REPO / ".env") + decisions = [build_decision_from_spec(spec, args.workspace) for spec in _OSS_SPECS] + + if args.dry_run: + print(f"Dry run — would write {len(decisions)} decisions to {args.workspace!r}") + for d in decisions: + print(f" - {d.event_id}: {d.content[:64]}...") + return 0 + + from graph.writer import GraphWriter + from memory.quarantine import persist_quarantine + from scoring.write_pipeline import DecisionScoringPipeline, write_reject_reason + + scoring = DecisionScoringPipeline() + writer = GraphWriter() + written: list[str] = [] + try: + for decision in decisions: + scoring.score(decision) + reject = write_reject_reason(decision) + if reject is not None: + persist_quarantine(decision, reject) + continue + written.append(writer.write(decision)) + finally: + writer.close() + + print(f"OSS seed complete for workspace={args.workspace!r}: {len(written)} decisions") + return 0 if written else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/start_api_production.sh b/scripts/start_api_production.sh new file mode 100755 index 0000000..02f5748 --- /dev/null +++ b/scripts/start_api_production.sh @@ -0,0 +1,19 @@ +#!/bin/sh +# Production API entrypoint — migrate graph schema, optional one-time seed, then serve. +# +# CORTEX_SEED_DEMO defaults to false so redeploys do not re-seed. Set to "true" only +# on first boot or when intentionally resetting demo data. +set -eu + +echo "Running Neo4j migrations..." +python graph/migrate.py + +if [ "${CORTEX_SEED_DEMO:-false}" = "true" ]; then + echo "Seeding local-dev demo workspace (CORTEX_SEED_DEMO=true)..." + python scripts/seed_demo.py --workspace local-dev --scale small +else + echo "Skipping demo seed (CORTEX_SEED_DEMO is not true)." +fi + +echo "Starting uvicorn on port ${PORT:-8000}..." +exec uvicorn api.main:app --host 0.0.0.0 --port "${PORT:-8000}" diff --git a/scripts/start_portfolio_demo.sh b/scripts/start_portfolio_demo.sh new file mode 100755 index 0000000..1393554 --- /dev/null +++ b/scripts/start_portfolio_demo.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Start cloudflared tunnel to local Cortex API for Vercel portfolio demo. +# Requires: docker compose api on :8000, cloudflared installed (brew install cloudflared). +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +API_URL="${CORTEX_API_URL:-http://localhost:8000}" +VERCEL_DEMO_URL="${VERCEL_DEMO_URL:-https://frontend-ten-rouge-99.vercel.app}" + +if ! curl -sf "${API_URL}/health" >/dev/null 2>&1; then + echo "ERROR: Cortex API not reachable at ${API_URL}" >&2 + echo "Start the stack: make demo (or: docker compose --profile api up -d)" >&2 + exit 1 +fi + +if ! command -v cloudflared >/dev/null 2>&1; then + echo "ERROR: cloudflared not found. Install: brew install cloudflared" >&2 + exit 1 +fi + +echo "Starting cloudflared tunnel → ${API_URL}" +echo "When the trycloudflare.com URL appears below:" +echo " 1. Vercel → frontend project → Settings → Environment Variables" +echo " 2. Set CORTEX_API_ORIGIN to that URL (Production)" +echo " 3. Share demo: ${VERCEL_DEMO_URL}" +echo "" +echo "For 24/7 demo without this laptop, see docs/PORTFOLIO_DEMO.md" +echo "---" + +exec cloudflared tunnel --url "${API_URL}" diff --git a/tests/api/test_edge_cases.py b/tests/api/test_edge_cases.py index e924216..4e39184 100644 --- a/tests/api/test_edge_cases.py +++ b/tests/api/test_edge_cases.py @@ -93,6 +93,26 @@ def test_inject_rejects_short_context() -> None: assert response.status_code == 422 +def test_inject_uses_at_least_one_result_slot_for_small_token_budget() -> None: + """max_tokens=100 is valid; inject must not pass limit=0 to memory.""" + client = TestClient(app) + mock_memory = AsyncMock() + mock_memory.inject_decisions = AsyncMock(return_value=[]) + with patch("api.main.memory", return_value=mock_memory): + response = client.post( + "/inject", + json={ + "context": "payments service database migration context", + "workspace_id": "ws-1", + "agent_id": "agent-1", + "max_tokens": 100, + }, + ) + assert response.status_code == 200 + mock_memory.inject_decisions.assert_awaited_once() + assert mock_memory.inject_decisions.await_args.kwargs["limit"] == 1 + + def test_query_limit_bounds() -> None: client = TestClient(app) response = client.post( diff --git a/tests/api/test_memory_resilience.py b/tests/api/test_memory_resilience.py index db1c697..6a875cc 100644 --- a/tests/api/test_memory_resilience.py +++ b/tests/api/test_memory_resilience.py @@ -3,7 +3,7 @@ from __future__ import annotations import json -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -92,6 +92,21 @@ async def test_query_propagates_graph_failure() -> None: ) +def test_build_redis_client_uses_redis_url(monkeypatch: pytest.MonkeyPatch) -> None: + """REDIS_URL (Upstash) takes precedence over REDIS_HOST for cloud deploys.""" + monkeypatch.setenv("REDIS_URL", "redis://localhost:6379/0") + monkeypatch.delenv("REDIS_HOST", raising=False) + + with patch("redis.from_url") as from_url: + client = MagicMock() + client.ping.return_value = True + from_url.return_value = client + built = MemoryService._build_redis_client() + + from_url.assert_called_once() + assert built is client + + def test_redis_health_unreachable_when_client_none() -> None: svc = MemoryService() svc._redis = None diff --git a/tests/scripts/test_post_deploy_scripts.py b/tests/scripts/test_post_deploy_scripts.py new file mode 100644 index 0000000..ab215bd --- /dev/null +++ b/tests/scripts/test_post_deploy_scripts.py @@ -0,0 +1,57 @@ +"""Tests for post-deploy seed/import scripts.""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path +from unittest.mock import patch + +_REPO = Path(__file__).resolve().parents[2] + + +def test_seed_oss_fastapi_dry_run() -> None: + proc = subprocess.run( + [ + sys.executable, + str(_REPO / "scripts" / "seed_oss_fastapi_demo.py"), + "--dry-run", + ], + capture_output=True, + text=True, + check=False, + ) + assert proc.returncode == 0, proc.stderr + assert "oss-tiangolo-fastapi" in proc.stdout + + +def test_import_github_graph_dry_run() -> None: + fake_prs = [ + { + "number": 1, + "title": "RFC: migrate architecture to async", + "body": "We decided to switch dependency injection pattern.", + "merged_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-01T00:00:00Z", + "created_at": "2024-01-01T00:00:00Z", + "user": {"login": "tiangolo"}, + "head": {"ref": "feature/async"}, + } + ] + import scripts.import_github_graph as import_mod + + argv = [ + "import_github_graph.py", + "--org", + "tiangolo", + "--repo", + "fastapi", + "--workspace", + "oss-tiangolo-fastapi", + "--limit", + "3", + "--dry-run", + ] + with patch.object(sys, "argv", argv): + with patch.object(import_mod, "fetch_merged_prs", return_value=fake_prs): + assert import_mod.main() == 0 diff --git a/vercel.json b/vercel.json index 051d7ed..7f4447c 100644 --- a/vercel.json +++ b/vercel.json @@ -5,6 +5,9 @@ "outputDirectory": "frontend/dist", "framework": "vite", "rewrites": [ - { "source": "/((?!assets/).*)", "destination": "/index.html" } + { + "source": "/((?!assets/).*)", + "destination": "/index.html" + } ] } From 492a706e090c3aa861a1039e490bb6198bf58c77 Mon Sep 17 00:00:00 2001 From: Abhinaysai Kamineni Date: Thu, 25 Jun 2026 12:22:23 -0400 Subject: [PATCH 4/4] Fix Vercel deploy: whitelist .vercelignore to exclude Python backend bundle. Vercel was detecting pyproject.toml after the Vite build and packaging ~5.4 GB of Python deps. Upload only frontend/ plus root deploy config so the bundle stays under the 500 MB ephemeral storage limit. Co-authored-by: Cursor --- .gitignore | 4 +-- .vercelignore | 27 ++++++++++++++++++ docs/DEPLOY.md | 2 +- middleware.ts | 76 ++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 106 insertions(+), 3 deletions(-) create mode 100644 .vercelignore create mode 100644 middleware.ts diff --git a/.gitignore b/.gitignore index 768ea40..4a1b295 100644 --- a/.gitignore +++ b/.gitignore @@ -22,8 +22,8 @@ htmlcov/ coverage.xml *.cover -# Environment -.env +# Vercel local project link (never commit credentials) +.vercel/ .env.* !.env.example diff --git a/.vercelignore b/.vercelignore new file mode 100644 index 0000000..ad4aacb --- /dev/null +++ b/.vercelignore @@ -0,0 +1,27 @@ +# Cortex monorepo — Vercel hosts the Vite dashboard only. +# Whitelist: only frontend + root deploy config are uploaded (keeps bundle < 500 MB). +# Use leading paths where noted so frontend/scripts/ is never excluded by mistake. + +* +!frontend +!frontend/** +!vercel.json +!middleware.ts +!.vercelignore + +# Belt-and-suspenders: never upload Python backend even if patterns change +/api/ +/pipeline/ +/connectors/ +/extraction/ +/graph/ +/intelligence/ +/memory/ +/scoring/ +/shared/ +/mcp/ +/tests/ +pyproject.toml +uv.lock +.python-version +*.py diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index dedc604..360e8c1 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -22,7 +22,7 @@ Cortex is a **multi-service stack** (Kafka, Neo4j, Redis, API, pipeline worker, | Approach | Settings | |----------|----------| | **Recommended** | Project Settings → General → **Root Directory** → `frontend` → Save → Redeploy | -| **Repo root** | Keep Root Directory `.` — root `vercel.json` forces `framework: vite` and builds `frontend/dist` only (no Python) | +| **Repo root** | Keep Root Directory `.` — root `vercel.json` + `.vercelignore` upload only `frontend/` (excludes Python/`api/` so the bundle stays under Vercel limits) | After changing Root Directory, clear the Framework Preset override if it still says **FastAPI** — it should be **Vite** or **Other**. diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..0b65504 --- /dev/null +++ b/middleware.ts @@ -0,0 +1,76 @@ +/** + * Vercel Edge Middleware — proxy API routes to CORTEX_API_ORIGIN at runtime. + * + * Used when the Vercel project Root Directory is the repo root (`cortex` project). + * When Root Directory = `frontend`, see frontend/middleware.ts. + */ + +export const config = { + matcher: [ + "/health", + "/metrics", + "/query", + "/inject", + "/remember", + "/docs", + "/openapi.json", + "/decisions/:path*", + "/contradictions/:path*", + "/gdpr/:path*", + "/webhooks/:path*", + ], +}; + +const API_PREFIXES = config.matcher.map((m) => m.replace("/:path*", "")); + +function isApiRoute(pathname: string): boolean { + return API_PREFIXES.some( + (prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`), + ); +} + +export default async function middleware(request: Request): Promise { + const origin = String( + (globalThis as typeof globalThis & { process?: { env?: Record } }) + .process?.env?.CORTEX_API_ORIGIN ?? "", + ) + .trim() + .replace(/\/$/, ""); + if (!origin) { + return new Response( + JSON.stringify({ + detail: + "CORTEX_API_ORIGIN is not set on Vercel. Add your Railway/Render API URL in Project Settings.", + }), + { status: 503, headers: { "content-type": "application/json" } }, + ); + } + + const url = new URL(request.url); + if (!isApiRoute(url.pathname)) { + return new Response("Not found", { status: 404 }); + } + + const target = `${origin}${url.pathname}${url.search}`; + const headers = new Headers(request.headers); + headers.delete("host"); + + const init: RequestInit = { + method: request.method, + headers, + redirect: "manual", + }; + if (request.method !== "GET" && request.method !== "HEAD") { + init.body = request.body; + } + + try { + return await fetch(target, init); + } catch (error) { + const message = error instanceof Error ? error.message : "upstream fetch failed"; + return new Response( + JSON.stringify({ detail: `API unreachable at ${origin}: ${message}` }), + { status: 502, headers: { "content-type": "application/json" } }, + ); + } +}