diff --git a/.env.example b/.env.example index b792561..f5d49a2 100644 --- a/.env.example +++ b/.env.example @@ -1,24 +1,16 @@ # zeroforce environment. Copy to .env.local (gitignored) and fill in. -# Provider mode hinge (spec §6.6). One of: fake | dev | prod -# fake = deterministic offline providers (default; no keys needed) -# dev = Cerebras + Tavily -# prod = Gemini 3.5 Flash (Vertex) + Vertex grounding -ZEROFORCE_MODE=fake +# Provider mode hinge (spec §6.6). One of: prod | fake +# prod = Gemini 3.5 Flash (Vertex) + built-in Google Search grounding (default) +# fake = deterministic offline test double (no keys, no network; tests/CI + keyless demo) +ZEROFORCE_MODE=prod -# SQLite path (dev). Use :memory: for ephemeral. +# SQLite path (fake/local). Use :memory: for ephemeral. ZEROFORCE_DB=./data/zeroforce.db -# Optional single bearer token; if unset, auth is open (fake/dev convenience). +# Optional single bearer token; if unset, auth is open (local convenience). # ZEROFORCE_API_TOKEN= -# --- dev mode keys --- -# CEREBRAS_API_KEY= # from cloud.cerebras.ai -# CEREBRAS_MODEL=zai-glm-4.7 # extraction (accuracy-critical structured graph) -# CEREBRAS_TEXT_MODEL=gpt-oss-120b # report prose (cheaper/faster — frugal) -# CEREBRAS_BASE_URL=https://api.cerebras.ai/v1 # optional override -# TAVILY_API_KEY= - # --- prod mode (GCP Application Default Credentials must also be configured) --- # GCP_PROJECT= # GCP_REGION=us-central1 diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a30c431..d77e4b1 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -9,7 +9,8 @@ Full specification: [`.agents/project_specifications.md`](.agents/project_specif ## The three layers 1. **Extraction** — turn natural language into a weighted directed dependency graph. - Prod: Gemini 3.5 Flash two-pass (ground → structure). Dev: Tavily + Cerebras. Fake: deterministic. + Prod: Gemini 3.5 Flash two-pass (ground → structure; web grounding via Google Search is + built into Gemini). Fake: deterministic offline double for tests/CI. 2. **Computation** — the RZF engine computes exact Expected Propagation Time (EPT) per node via Markov-chain DP. This is `packages/zeroforce/` and was built first. 3. **Presentation** — Next.js 14 dashboard: force graph, impact ranking, cascade animation, @@ -62,12 +63,12 @@ Frontend ─▶ Gateway ─▶ Orchestrator ─▶ ServiceClient ─▶ {extract └────────────────────────────┘ (SQLite/Firestore jobs + analyses; Redis/dict extraction cache) ``` -## The dev/prod/fake hinge +## The prod/fake hinge One env var, `ZEROFORCE_MODE`: -- `fake` (default) — deterministic offline providers; no keys, no network. Powers tests/CI. -- `dev` — Cerebras (`CEREBRAS_API_KEY`) + Tavily (`TAVILY_API_KEY`). -- `prod` — Gemini 3.5 Flash (Vertex, ADC) + Vertex grounding. +- `prod` (default) — Gemini 3.5 Flash via Vertex (GCP ADC); web grounding is built into Gemini + via Google Search. +- `fake` — deterministic offline providers; no keys, no network. Powers tests/CI. Identical I/O contract across modes; only source-attribution metadata and call count differ. Application code imports only the `LLMProvider` / `SearchProvider` protocols, never a vendor SDK. @@ -77,7 +78,7 @@ Application code imports only the `LLMProvider` / `SearchProvider` protocols, ne ``` packages/zeroforce RZF engine (built first; gating oracle suite) packages/schemas shared Pydantic API models (engine types re-exported) -packages/providers LLM + search abstraction (fake/cerebras/tavily/gemini/vertex) + factory +packages/providers LLM + search abstraction (fake/gemini/vertex) + factory services/backend 6 service cores + FastAPI apps + orchestrator + storage + observability apps/web Next.js 14 dashboard infra/ Dockerfiles, docker-compose.dev, Terraform (Cloud Run, Firestore, …) diff --git a/PROGRESS.md b/PROGRESS.md index b3a493b..e67ea96 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -49,6 +49,9 @@ Started autonomous full build 2026-05-22. Operator asleep; no questions allowed. forcing 6 live HTTP processes for every test/CI run. - **Provider modes:** `ZEROFORCE_MODE = fake | dev | prod`. `fake` = deterministic offline (default for tests/CI). `dev` = NVIDIA+Tavily (needs keys). `prod` = Gemini+Vertex (needs GCP). + > UPDATE (post-build): the runtime was later collapsed to **Gemini-only**. The `dev` mode and + > the Cerebras/Tavily providers were removed; `ZEROFORCE_MODE` is now `prod` (Gemini via + > Vertex/ADC, default; web grounding built into Gemini) | `fake` (offline test double only). - **Storage abstraction:** `Repository` protocol; `SQLiteRepository` (default/dev), `FirestoreRepository` (prod, untested). Cache: `dict` (default) / `Redis` (prod). - **uv workspace** at repo root; members = `packages/*` + `services/*`. diff --git a/README.md b/README.md index c7b4780..8368101 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ engine that computes exact Expected Propagation Time (EPT) from every node. ## Quickstart ```bash -make install # uv workspace + provider SDKs (Cerebras/Tavily + Gemini) + frontend deps +make install # uv workspace + provider SDK (Gemini / google-genai) + frontend deps make dev # backend (:8080) + frontend (:3000) together; Ctrl-C kills both # open http://localhost:3000 ``` @@ -38,26 +38,27 @@ make dev # backend (:8080) + frontend (:3000) together; Ctrl-C kills bot `make dev` (`scripts/dev.sh`) frees the ports, then runs both in one foreground process group, so a single Ctrl-C tears down both. Run separately with `make backend` / `make web`. -Mode comes from `.env.local` (copy [`.env.example`](.env.example)). **`fake` needs no keys** and -runs the whole pipeline deterministically offline — good for a demo or development. +Mode comes from `.env.local` (copy [`.env.example`](.env.example)). The default is `prod` +(Gemini via GCP ADC). For a keyless offline demo set **`ZEROFORCE_MODE=fake`**, which runs the +whole pipeline deterministically with no keys and no network. -## Modes (the dev/prod hinge) +## Modes -One env var, `ZEROFORCE_MODE`: +One runtime — **Gemini** (via Vertex / GCP ADC) — with web grounding built into Gemini +(Google Search). One env var, `ZEROFORCE_MODE`: | Mode | LLM | Web grounding | Storage | Setup | |---|---|---|---|---| -| `fake` (default) | deterministic | deterministic | SQLite / memory | nothing | -| `dev` | **Cerebras** — `zai-glm-4.7` (extraction) + `gpt-oss-120b` (report) | Tavily | SQLite | `CEREBRAS_API_KEY`, `TAVILY_API_KEY` | -| `prod` | **Gemini 3.5 Flash** (Vertex) | built into Gemini (Google Search) | Firestore + Redis | GCP ADC | +| `prod` (default) | **Gemini 3.5 Flash** (Vertex) | built into Gemini (Google Search) | Firestore + Redis | GCP ADC | +| `fake` | deterministic | deterministic | SQLite / memory | nothing | -Model overrides: `CEREBRAS_MODEL`, `CEREBRAS_TEXT_MODEL`, `GEMINI_MODEL`. The split in `dev` is -frugal — the stronger model only on the accuracy-critical extraction, the cheaper one for prose. +Model override: `GEMINI_MODEL`. `fake` is an offline, deterministic test double — it needs +**no keys and no network** and exists only for tests/CI (and a quick keyless local demo). ### prod / GCP prod uses **Application Default Credentials** (no API key) and Gemini's **built-in** Google -Search grounding (Tavily is not used in prod). One-time auth: +Search grounding. One-time auth: ```bash make adc # = gcloud auth application-default login (or run setup_adc.sh) @@ -92,7 +93,7 @@ why that discipline is the project's most important safety mechanism. ``` packages/zeroforce RZF engine (built first; gating oracle suite) packages/schemas shared Pydantic API models -packages/providers LLM + search abstraction (fake/cerebras/tavily/gemini/vertex) + factory +packages/providers LLM + search abstraction (fake/gemini) + factory services/backend 6 service cores + FastAPI apps + orchestrator + storage + observability apps/web Next.js 14 dashboard infra/ Dockerfiles, docker-compose.dev, Terraform (Cloud Run, Firestore, …) diff --git a/SECURITY.md b/SECURITY.md index 3d9ac7b..b5148af 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -14,7 +14,7 @@ Threat model and mitigations (spec §12), with implementation status in this rep ## Operational notes -- **No secrets are committed.** Verify with `git grep -nE "(Cerebras|TAVILY|API_KEY)=.+"` returning nothing. +- **No secrets are committed.** Verify with `git grep -nE "(API_KEY|SECRET|TOKEN)=.+"` returning nothing. - **Chaos**: a provider outage mid-pipeline fails the job cleanly with `llm_provider_error` (tested in `test_provider_outage_fails_job_gracefully`) rather than hanging or crashing. - **Before public deployment**: enable rate limiting, write Firestore security rules, add the diff --git a/apps/web/app/confirm/page.tsx b/apps/web/app/confirm/page.tsx new file mode 100644 index 0000000..34f130b --- /dev/null +++ b/apps/web/app/confirm/page.tsx @@ -0,0 +1,167 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; +import { startAnalysis } from "@/lib/api"; +import type { DraftGraph } from "@/lib/types"; + +export default function Confirm() { + const router = useRouter(); + const [draft, setDraft] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + const raw = sessionStorage.getItem("zeroforce.draft"); + if (raw) setDraft(JSON.parse(raw)); + }, []); + + if (!draft) { + return ( +
+

No detected graph found.

+ +
+ ); + } + + function renameNode(id: string, label: string) { + setDraft((d) => d && { ...d, nodes: d.nodes.map((n) => (n.id === id ? { ...n, label } : n)) }); + } + function deleteNode(id: string) { + setDraft((d) => + d && { + ...d, + nodes: d.nodes.filter((n) => n.id !== id), + edges: d.edges.filter((e) => e.source !== id && e.target !== id), + focal_node: d.focal_node === id ? "" : d.focal_node, + }, + ); + } + function addNode() { + setDraft((d) => { + if (!d) return d; + const maxN = d.nodes.reduce((m, n) => { + const match = /^node_(\d+)$/.exec(n.id); + return match ? Math.max(m, Number(match[1])) : m; + }, 0); + const id = `node_${maxN + 1}`; + return { ...d, nodes: [...d.nodes, { id, label: "New node" }] }; + }); + } + function deleteEdge(i: number) { + setDraft((d) => d && { ...d, edges: d.edges.filter((_, idx) => idx !== i) }); + } + function reverseEdge(i: number) { + setDraft( + (d) => + d && { + ...d, + edges: d.edges.map((e, idx) => (idx === i ? { source: e.target, target: e.source } : e)), + }, + ); + } + function addEdge() { + setDraft((d) => { + if (!d || d.nodes.length < 2) return d; + return { ...d, edges: [...d.edges, { source: d.nodes[0].id, target: d.nodes[1].id }] }; + }); + } + + async function analyze() { + if (!draft || !draft.focal_node) { + setError("Pick a focal node first."); + return; + } + setLoading(true); + setError(null); + try { + const focalLabel = draft.nodes.find((n) => n.id === draft.focal_node)?.label ?? "whiteboard graph"; + const { id } = await startAnalysis(focalLabel, "company", draft); + router.push(`/analyze/${id}`); + } catch (err) { + setError((err as Error).message); + setLoading(false); + } + } + + return ( +
+

Confirm the detected graph

+

+ Fix any misread labels or arrows, choose the focal node, then analyze. +

+ +
+
+

Nodes

+ +
+
+ {draft.nodes.map((n) => ( +
+ setDraft((d) => d && { ...d, focal_node: n.id })} + title="focal node" + /> + renameNode(n.id, e.target.value)} + className="flex-1 rounded border border-edge bg-panel px-2 py-1" + /> + +
+ ))} +
+
+ +
+
+

Edges (supplier → customer)

+ +
+
+ {draft.edges.map((e, i) => ( +
+ + + + + +
+ ))} +
+
+ + {error &&

{error}

} + +
+ + +
+
+ ); +} diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 890ccb1..0ec3a45 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -3,7 +3,7 @@ import { useRouter } from "next/navigation"; import { useState } from "react"; import HistorySidebar from "@/components/HistorySidebar"; -import { startAnalysis } from "@/lib/api"; +import { startAnalysis, uploadWhiteboard } from "@/lib/api"; const EXAMPLES = [ "Apple semiconductor supply chain", @@ -30,6 +30,22 @@ export default function Home() { } } + async function onPhoto(e: React.ChangeEvent) { + const file = e.target.files?.[0]; + e.target.value = ""; + if (!file) return; + setLoading(true); + setError(null); + try { + const draft = await uploadWhiteboard(file); + sessionStorage.setItem("zeroforce.draft", JSON.stringify(draft)); + router.push("/confirm"); + } catch (err) { + setError((err as Error).message); + setLoading(false); + } + } + return (