Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
68b8ecf
docs: spec for whiteboard photo → graph (Gemini-only)
justin06lee May 22, 2026
8bdcc83
docs: implementation plan for whiteboard photo → graph
justin06lee May 22, 2026
a5662f1
feat(types): add DraftGraph for whiteboard vision structure
justin06lee May 22, 2026
f425e83
feat(runtime): collapse to Gemini-only; keep fake as test double
justin06lee May 22, 2026
72984c6
feat(extractor): seed-aware extraction from whiteboard DraftGraph
justin06lee May 22, 2026
987f3ff
fix(fake): fall back to generic build on seed-marker parse failure
justin06lee May 22, 2026
8c3b709
feat(providers): add extract_structure_from_image vision method
justin06lee May 22, 2026
35f7af4
fix(gemini): guard empty vision response with a clear error
justin06lee May 22, 2026
d515696
feat(pipeline): thread whiteboard seed_graph through analyze
justin06lee May 22, 2026
dcd03ec
feat(api): POST /api/v1/vision — photo to DraftGraph
justin06lee May 22, 2026
6c44a37
chore: sync uv.lock after dropping Cerebras/Tavily deps
justin06lee May 22, 2026
0d77799
feat(web): DraftGraph types + vision/seed API helpers
justin06lee May 22, 2026
c78e1c6
feat(web): whiteboard photo upload entry on home page
justin06lee May 22, 2026
5eaa79d
fix(web): reset file input + disable upload while loading
justin06lee May 22, 2026
407b9e6
feat(web): editable confirm screen for detected whiteboard graph
justin06lee May 22, 2026
9b57acf
fix(web): unique node ids on add + aria-labels on confirm controls
justin06lee May 22, 2026
01bfcce
docs(infra): Gemini-only sweep — drop dev/Cerebras/Tavily, remove dea…
justin06lee May 22, 2026
72f7206
docs: align comments + ARCHITECTURE/RUNBOOK/PROGRESS with Gemini-only…
justin06lee May 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 6 additions & 14 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
Expand Down
13 changes: 7 additions & 6 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand All @@ -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, …)
Expand Down
3 changes: 3 additions & 0 deletions PROGRESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/*`.
Expand Down
25 changes: 13 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,34 +30,35 @@ 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
```

`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)
Expand Down Expand Up @@ -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, …)
Expand Down
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
167 changes: 167 additions & 0 deletions apps/web/app/confirm/page.tsx
Original file line number Diff line number Diff line change
@@ -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<DraftGraph | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
const raw = sessionStorage.getItem("zeroforce.draft");
if (raw) setDraft(JSON.parse(raw));
}, []);

if (!draft) {
return (
<main className="mx-auto max-w-2xl px-6 py-16 text-center">
<p className="text-gray-400">No detected graph found.</p>
<button className="mt-4 rounded-lg border border-edge px-4 py-2" onClick={() => router.push("/")}>
Back to start
</button>
</main>
);
}

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 (
<main className="mx-auto max-w-3xl px-6 py-10">
<h1 className="text-2xl font-semibold">Confirm the detected graph</h1>
<p className="mt-2 text-sm text-gray-400">
Fix any misread labels or arrows, choose the focal node, then analyze.
</p>

<section className="mt-8">
<div className="flex items-center justify-between">
<h2 className="text-lg font-medium">Nodes</h2>
<button className="rounded border border-edge px-2 py-1 text-sm" onClick={addNode}>+ Node</button>
</div>
<div className="mt-3 space-y-2">
{draft.nodes.map((n) => (
<div key={n.id} className="flex items-center gap-2">
<input
type="radio"
name="focal"
checked={draft.focal_node === n.id}
onChange={() => setDraft((d) => d && { ...d, focal_node: n.id })}
title="focal node"
/>
<input
value={n.label}
onChange={(e) => renameNode(n.id, e.target.value)}
className="flex-1 rounded border border-edge bg-panel px-2 py-1"
/>
<button className="text-danger" aria-label="Delete node" onClick={() => deleteNode(n.id)}>✕</button>
</div>
))}
</div>
</section>

<section className="mt-8">
<div className="flex items-center justify-between">
<h2 className="text-lg font-medium">Edges (supplier → customer)</h2>
<button className="rounded border border-edge px-2 py-1 text-sm" onClick={addEdge}>+ Edge</button>
</div>
<div className="mt-3 space-y-2">
{draft.edges.map((e, i) => (
<div key={i} className="flex items-center gap-2">
<select
value={e.source}
onChange={(ev) => setDraft((d) => d && { ...d, edges: d.edges.map((x, idx) => (idx === i ? { ...x, source: ev.target.value } : x)) })}
className="rounded border border-edge bg-panel px-2 py-1"
>
{draft.nodes.map((n) => <option key={n.id} value={n.id}>{n.label}</option>)}
</select>
<span className="text-gray-500">→</span>
<select
value={e.target}
onChange={(ev) => setDraft((d) => d && { ...d, edges: d.edges.map((x, idx) => (idx === i ? { ...x, target: ev.target.value } : x)) })}
className="rounded border border-edge bg-panel px-2 py-1"
>
{draft.nodes.map((n) => <option key={n.id} value={n.id}>{n.label}</option>)}
</select>
<button className="text-gray-400" aria-label="Reverse edge direction" onClick={() => reverseEdge(i)}>⇄</button>
<button className="text-danger" aria-label="Delete edge" onClick={() => deleteEdge(i)}>✕</button>
</div>
))}
</div>
</section>

{error && <p className="mt-4 text-danger">{error}</p>}

<div className="mt-8 flex gap-3">
<button className="rounded-lg border border-edge px-4 py-2" onClick={() => router.push("/")}>Back</button>
<button
className="rounded-lg bg-blue-600 px-4 py-2 font-medium disabled:opacity-50"
disabled={loading}
onClick={analyze}
>
{loading ? "Analyzing…" : "Analyze"}
</button>
</div>
</main>
);
}
32 changes: 31 additions & 1 deletion apps/web/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -30,6 +30,22 @@ export default function Home() {
}
}

async function onPhoto(e: React.ChangeEvent<HTMLInputElement>) {
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 (
<div className="flex min-h-screen">
<aside className="hidden w-[220px] shrink-0 border-r border-edge bg-panel md:block">
Expand Down Expand Up @@ -66,6 +82,20 @@ export default function Home() {
</button>
</form>

<div className="mt-4 w-full">
<label className={`flex cursor-pointer items-center justify-center gap-2 rounded-lg border border-dashed border-edge px-4 py-3 text-sm text-gray-300 hover:border-blue-500 ${loading ? "pointer-events-none opacity-50" : ""}`}>
📷 Upload a photo of a whiteboard
<input
type="file"
accept="image/png,image/jpeg,image/webp,image/heic"
capture="environment"
className="hidden"
disabled={loading}
onChange={onPhoto}
/>
</label>
</div>

<div className="mt-4 flex flex-wrap gap-2">
{EXAMPLES.map((ex) => (
<button
Expand Down
17 changes: 14 additions & 3 deletions apps/web/lib/api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { AnalysisSummary, JobRecord, SimulateResponse } from "./types";
import type { AnalysisSummary, DraftGraph, JobRecord, SimulateResponse } from "./types";

async function jsonOrThrow(res: Response) {
const body = await res.json().catch(() => ({}));
Expand All @@ -9,15 +9,26 @@ async function jsonOrThrow(res: Response) {
return body;
}

export async function startAnalysis(input: string, mode = "company"): Promise<{ id: string }> {
export async function startAnalysis(
input: string,
mode = "company",
seedGraph?: DraftGraph,
): Promise<{ id: string }> {
const res = await fetch("/api/v1/analyze", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ input, mode, max_nodes: 16 }),
body: JSON.stringify({ input, mode, max_nodes: 16, seed_graph: seedGraph ?? null }),
});
return jsonOrThrow(res);
}

export async function uploadWhiteboard(file: File): Promise<DraftGraph> {
const form = new FormData();
form.append("file", file);
const res = await fetch("/api/v1/vision", { method: "POST", body: form });
return jsonOrThrow(res) as Promise<DraftGraph>;
}

export async function getAnalysis(id: string): Promise<JobRecord> {
return jsonOrThrow(await fetch(`/api/v1/analyses/${id}`));
}
Expand Down
Loading
Loading