luguog commited on
Commit
9646e24
·
verified ·
1 Parent(s): bc86691

Deploy mailbox-automation Next.js app

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +3 -35
  2. .gitignore +5 -1
  3. .npmrc +1 -0
  4. Dockerfile +36 -11
  5. README.md +96 -8
  6. fly.toml +16 -0
  7. netlify.toml +14 -0
  8. next-env.d.ts +5 -0
  9. next.config.js +12 -0
  10. package-lock.json +0 -0
  11. package.json +45 -31
  12. postcss.config.js +6 -0
  13. public/.gitkeep +0 -0
  14. scripts/__pycache__/prior_art_generator.cpython-313.pyc +0 -0
  15. scripts/cicd_research.py +157 -0
  16. scripts/fractal_book.py +566 -0
  17. scripts/prior_art_generator.py +624 -0
  18. src/app/(app)/discovery-ledger/page.tsx +97 -0
  19. src/app/(app)/experiment/page.tsx +2 -0
  20. src/app/(app)/experiments/page.tsx +140 -0
  21. src/app/(app)/foundry/page.tsx +279 -0
  22. src/app/(app)/golden-nodes/page.tsx +41 -0
  23. src/app/(app)/history/page.tsx +2 -0
  24. src/app/(app)/inbox/page.tsx +16 -0
  25. src/app/(app)/layout.tsx +10 -0
  26. src/app/(app)/learnings/page.tsx +112 -0
  27. src/app/(app)/process-lab/page.tsx +119 -0
  28. src/app/(app)/results/page.tsx +38 -0
  29. src/app/(app)/spinor/page.tsx +260 -0
  30. src/app/(app)/today/page.tsx +56 -0
  31. src/app/api/azure/config/route.ts +49 -0
  32. src/app/api/commitments/detect/route.ts +39 -0
  33. src/app/api/commitments/execute/route.ts +70 -0
  34. src/app/api/commitments/outcome/route.ts +102 -0
  35. src/app/api/commitments/route.ts +26 -0
  36. src/app/api/competitive/actions/route.ts +85 -0
  37. src/app/api/competitive/experiments/route.ts +37 -0
  38. src/app/api/competitive/learnings/route.ts +33 -0
  39. src/app/api/competitive/manager/route.ts +25 -0
  40. src/app/api/competitive/plan/route.ts +25 -0
  41. src/app/api/competitive/results/route.ts +29 -0
  42. src/app/api/competitive/score/route.ts +25 -0
  43. src/app/api/competitive/trajectory/route.ts +25 -0
  44. src/app/api/config/route.ts +68 -0
  45. src/app/api/demo/seed/route.ts +350 -0
  46. src/app/api/gmail/action/route.ts +83 -0
  47. src/app/api/gmail/auth/route.ts +49 -0
  48. src/app/api/gmail/body/route.ts +54 -0
  49. src/app/api/gmail/callback/route.ts +21 -0
  50. src/app/api/gmail/config/route.ts +34 -0
.gitattributes CHANGED
@@ -1,37 +1,5 @@
1
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
3
  *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
5
- *.ckpt filter=lfs diff=lfs merge=lfs -text
6
- *.ftz filter=lfs diff=lfs merge=lfs -text
7
- *.gz filter=lfs diff=lfs merge=lfs -text
8
- *.h5 filter=lfs diff=lfs merge=lfs -text
9
- *.joblib filter=lfs diff=lfs merge=lfs -text
10
- *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
- *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
- *.model filter=lfs diff=lfs merge=lfs -text
13
- *.msgpack filter=lfs diff=lfs merge=lfs -text
14
- *.npy filter=lfs diff=lfs merge=lfs -text
15
- *.npz filter=lfs diff=lfs merge=lfs -text
16
- *.onnx filter=lfs diff=lfs merge=lfs -text
17
- *.ot filter=lfs diff=lfs merge=lfs -text
18
- *.parquet filter=lfs diff=lfs merge=lfs -text
19
- *.pb filter=lfs diff=lfs merge=lfs -text
20
- *.pickle filter=lfs diff=lfs merge=lfs -text
21
- *.pkl filter=lfs diff=lfs merge=lfs -text
22
  *.pt filter=lfs diff=lfs merge=lfs -text
23
- *.pth filter=lfs diff=lfs merge=lfs -text
24
- *.rar filter=lfs diff=lfs merge=lfs -text
25
- *.safetensors filter=lfs diff=lfs merge=lfs -text
26
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
- *.tflite filter=lfs diff=lfs merge=lfs -text
30
- *.tgz filter=lfs diff=lfs merge=lfs -text
31
- *.wasm filter=lfs diff=lfs merge=lfs -text
32
- *.xz filter=lfs diff=lfs merge=lfs -text
33
- *.zip filter=lfs diff=lfs merge=lfs -text
34
- *.zst filter=lfs diff=lfs merge=lfs -text
35
- *tfevents* filter=lfs diff=lfs merge=lfs -text
36
- sources/needful_things/needful_things.duckdb filter=lfs diff=lfs merge=lfs -text
37
- .netlify/functions-internal/___netlify-server-handler/.next/server/app/api/gmail/auth/route.js filter=lfs diff=lfs merge=lfs -text
 
1
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
 
2
  *.bin filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  *.pt filter=lfs diff=lfs merge=lfs -text
4
+ *.onnx filter=lfs diff=lfs merge=lfs -text
5
+ *.pdf filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
.gitignore CHANGED
@@ -3,11 +3,15 @@ node_modules/
3
  out/
4
  build/
5
  dist/
 
6
  exports/
7
  app-config.json
8
  *.log
9
  .DS_Store
10
  .env
11
  .env.local
12
- .env.*.local
13
  .vercel
 
 
 
 
3
  out/
4
  build/
5
  dist/
6
+ data/
7
  exports/
8
  app-config.json
9
  *.log
10
  .DS_Store
11
  .env
12
  .env.local
13
+ .env*.local
14
  .vercel
15
+ .netlify
16
+ .netlify/
17
+ scripts/__pycache__/
.npmrc ADDED
@@ -0,0 +1 @@
 
 
1
+ legacy-peer-deps=true
Dockerfile CHANGED
@@ -1,16 +1,41 @@
1
- FROM node:20
2
 
3
- # Install http-server to serve the static site
4
- RUN npm install -g http-server
 
 
 
5
 
6
- # Build the app
7
- WORKDIR /usr/app
8
- COPY ./ /usr/app
9
- RUN npm install
10
- RUN npm run sources && npm run build
 
 
11
 
12
- # Expose port 7860
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  EXPOSE 7860
14
 
15
- # Serve the static site from the /usr/app/build directory on host 0.0.0.0 and port 7860
16
- CMD ["http-server", "/usr/app/build", "-a", "0.0.0.0", "-p", "7860"]
 
1
+ FROM node:20-alpine AS base
2
 
3
+ # Stage 1: Install dependencies
4
+ FROM base AS deps
5
+ WORKDIR /app
6
+ COPY package.json package-lock.json* .npmrc ./
7
+ RUN npm ci --legacy-peer-deps
8
 
9
+ # Stage 2: Build the app
10
+ FROM base AS builder
11
+ WORKDIR /app
12
+ COPY --from=deps /app/node_modules ./node_modules
13
+ COPY . .
14
+ ENV NEXT_PUBLIC_DEMO=true
15
+ RUN npm run build
16
 
17
+ # Stage 3: Production runner
18
+ # Works on Hugging Face Spaces (port 7860), Fly.io (port 7860), and any Docker host.
19
+ # PORT is set by the platform; default to 7860 for HF Spaces compatibility.
20
+ FROM base AS runner
21
+ WORKDIR /app
22
+ ENV NODE_ENV=production
23
+ ENV NEXT_PUBLIC_DEMO=true
24
+ ENV PORT=7860
25
+ ENV HOSTNAME=0.0.0.0
26
+
27
+ # Hugging Face Spaces runs containers as UID 1000.
28
+ # node:20-alpine already has a 'node' user with UID 1000 / GID 1000.
29
+ # Use that existing user instead of creating a new one.
30
+ RUN mkdir -p /app && chown -R node:node /app
31
+
32
+ COPY --from=builder /app/public ./public
33
+ COPY --from=builder --chown=node:node /app/.next/standalone ./
34
+ COPY --from=builder --chown=node:node /app/.next/static ./.next/static
35
+
36
+ USER node
37
+
38
+ # Expose 7860 (HF Spaces default)
39
  EXPOSE 7860
40
 
41
+ CMD ["node", "server.js"]
 
README.md CHANGED
@@ -1,12 +1,100 @@
1
  ---
2
- title: Over
3
- emoji: 📦
4
- colorFrom: blue
5
- colorTo: green
6
  sdk: docker
7
- pinned: false
8
- license: mit
9
- short_description: over
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Mailbox Automation — Scientific Data Extraction
3
+ emoji: 📬
4
+ colorFrom: indigo
5
+ colorTo: purple
6
  sdk: docker
7
+ app_port: 7860
8
+ short_description: AI-powered mailbox analysis with scientific data extraction
9
+ startup_duration_timeout: 10m
10
  ---
11
 
12
+ # Mailbox Automation Scientific Data Extraction
13
+
14
+ A production-ready Next.js app for analyzing Gmail / Microsoft 365 mailboxes, extracting structured scientific data, and exporting it to spreadsheets. Deployed on Hugging Face Spaces (Docker SDK), Netlify, and Vercel from a single codebase.
15
+
16
+ ## Deploy
17
+
18
+ ### Hugging Face Spaces (Docker SDK)
19
+
20
+ This Space runs the Next.js standalone build inside a Docker container on port 7860. The Dockerfile handles the full build — no manual build step needed.
21
+
22
+ ```bash
23
+ # The Space auto-builds on push. To deploy manually:
24
+ hf spaces restart luguog/mailbox-automation
25
+ ```
26
+
27
+ ### Netlify
28
+
29
+ [![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](https://app.netlify.com/start)
30
+
31
+ Uses `@netlify/plugin-nextjs` with serverless functions. Build command and publish dir are in `netlify.toml`.
32
+
33
+ ### Vercel
34
+
35
+ [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Foverandor%2Fburchi&env=NEXT_PUBLIC_DEMO,NEXT_PUBLIC_OAUTH_REDIRECT_BASE&project-name=mailbox-automation)
36
+
37
+ Uses native Next.js support. Configuration in `vercel.json`.
38
+
39
+ ## Environment variables
40
+
41
+ All platforms support the same env vars. Set them in the platform dashboard (HF Spaces Settings → Variables and secrets, Netlify Site Settings → Environment, Vercel Project Settings → Environment Variables).
42
+
43
+ ```bash
44
+ # For a public demo with no real mailbox, set NEXT_PUBLIC_DEMO=true
45
+ NEXT_PUBLIC_DEMO=true
46
+
47
+ # LLM provider (optional — app falls back to free LLM7 if not set)
48
+ OPENAI_API_KEY=sk-...
49
+ LLM_MODEL=gpt-4o-mini
50
+ LLM_ENDPOINT=https://api.openai.com/v1
51
+
52
+ # For live Microsoft 365 sync
53
+ AZURE_CLIENT_ID=
54
+ AZURE_TENANT_ID=common
55
+ AZURE_CLIENT_SECRET=
56
+ MAILBOX_EMAIL=
57
+
58
+ # For live Gmail sync
59
+ GMAIL_CLIENT_ID=
60
+ GMAIL_CLIENT_SECRET=
61
+
62
+ # Public URL of the deployed app (for OAuth redirect URIs)
63
+ # HF Spaces: https://luguog-mailbox-automation.hf.space
64
+ # Netlify: https://your-site.netlify.app
65
+ # Vercel: https://your-project.vercel.app
66
+ NEXT_PUBLIC_OAUTH_REDIRECT_BASE=https://luguog-mailbox-automation.hf.space
67
+ ```
68
+
69
+ ## What works without any credentials
70
+
71
+ - **Public demo mode** — pre-generated sample emails with analyses, mindmaps, and execution plans
72
+ - **LLM inference** — automatic fallback to free LLM7 API (no key needed) when the configured endpoint is unavailable
73
+ - **Dark mode** — toggle in the header
74
+ - **Health check** — `GET /api/health` returns system status
75
+
76
+ ## Run locally
77
+
78
+ ```bash
79
+ npm install
80
+ npm run dev
81
+ ```
82
+
83
+ Then open `http://localhost:3000`.
84
+
85
+ ## Architecture
86
+
87
+ - **Framework**: Next.js 14 (App Router, standalone output)
88
+ - **Styling**: Tailwind CSS with dark mode support
89
+ - **Deployment**: Single codebase → HF Spaces (Docker), Netlify (serverless), Vercel (serverless)
90
+ - **LLM**: OpenAI-compatible endpoints with multi-provider fallback chain (configured → LLM7 → Pollinations)
91
+ - **Storage**: Client-side localStorage (serverless-friendly); filesystem on Docker
92
+ - **Tests**: Node built-in test runner (`npm test`)
93
+ - **Health**: `GET /api/health` checks config, telemetry, analysis, and utils modules
94
+
95
+ ## Notes
96
+
97
+ - `next.config.js` uses `output: 'standalone'` for Docker/Node hosts (HF Spaces, Fly.io)
98
+ - On Netlify/Vercel, the Next.js plugin handles serverless adaptation automatically
99
+ - Server-side config is env-first; file-based config falls back safely on serverless
100
+ - The Dockerfile exposes port 7860 (HF Spaces default) and 3000 (generic Docker)
fly.toml ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ app = "mailbox-sci-data"
2
+ primary_region = "iad"
3
+
4
+ [build]
5
+
6
+ [http_service]
7
+ internal_port = 7860
8
+ force_https = true
9
+ auto_stop_machines = true
10
+ auto_start_machines = true
11
+ min_machines_running = 0
12
+
13
+ [[vm]]
14
+ memory = "1024mb"
15
+ cpu_kind = "shared"
16
+ cpus = 1
netlify.toml ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build]
2
+ command = "NEXT_PUBLIC_DEMO=true npm run build"
3
+ publish = ".next"
4
+
5
+ [build.environment]
6
+ NEXT_PUBLIC_DEMO = "true"
7
+ NODE_VERSION = "20"
8
+
9
+ [[plugins]]
10
+ package = "@netlify/plugin-nextjs"
11
+
12
+ [functions]
13
+ # Allow up to 120s for LLM inference and sync operations
14
+ max_duration = 120
next-env.d.ts ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ /// <reference types="next" />
2
+ /// <reference types="next/image-types/global" />
3
+
4
+ // NOTE: This file should not be edited
5
+ // see https://nextjs.org/docs/basic-features/typescript for more information.
next.config.js ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** @type {import('next').NextConfig} */
2
+ const nextConfig = {
3
+ output: 'standalone',
4
+ experimental: {
5
+ serverComponentsExternalPackages: ['exceljs', 'pdf-parse', '@azure/msal-node', 'imapflow']
6
+ },
7
+ env: {
8
+ NEXT_PUBLIC_DEMO: process.env.NEXT_PUBLIC_DEMO,
9
+ },
10
+ };
11
+
12
+ module.exports = nextConfig;
package-lock.json CHANGED
The diff for this file is too large to render. See raw diff
 
package.json CHANGED
@@ -1,37 +1,51 @@
1
  {
2
- "name": "my-evidence-project",
3
- "version": "0.0.1",
 
4
  "scripts": {
5
- "build": "evidence build",
6
- "build:strict": "evidence build:strict",
7
- "dev": "evidence dev --open /",
8
- "test": "evidence build",
9
- "sources": "evidence sources",
10
- "preview": "evidence preview"
11
  },
12
- "engines": {
13
- "npm": ">=7.0.0",
14
- "node": ">=18.0.0"
15
- },
16
- "type": "module",
17
  "dependencies": {
18
- "@evidence-dev/bigquery": "^2.0.7",
19
- "@evidence-dev/core-components": "^4.8.10",
20
- "@evidence-dev/csv": "^1.0.12",
21
- "@evidence-dev/databricks": "^1.0.7",
22
- "@evidence-dev/duckdb": "^1.0.11",
23
- "@evidence-dev/evidence": "^39.1.13",
24
- "@evidence-dev/motherduck": "^1.0.2",
25
- "@evidence-dev/mssql": "^1.0.9",
26
- "@evidence-dev/mysql": "^1.1.3",
27
- "@evidence-dev/postgres": "^1.0.6",
28
- "@evidence-dev/snowflake": "^1.1.0",
29
- "@evidence-dev/sqlite": "^2.0.6",
30
- "@evidence-dev/trino": "^1.0.8"
 
 
 
 
 
 
 
 
 
31
  },
32
- "overrides": {
33
- "jsonwebtoken": "9.0.0",
34
- "trim@<0.0.3": ">0.0.3",
35
- "sqlite3": "5.1.5"
 
 
 
 
 
 
 
 
 
36
  }
37
- }
 
1
  {
2
+ "name": "microsoft-mailbox-automation",
3
+ "version": "1.0.0",
4
+ "private": true,
5
  "scripts": {
6
+ "dev": "next dev -p 3000",
7
+ "build": "next build",
8
+ "start": "next start -p 3000",
9
+ "lint": "next lint",
10
+ "test": "node --test --import tsx src/lib/__tests__/*.test.ts",
11
+ "pages:build": "npx @cloudflare/next-on-pages"
12
  },
 
 
 
 
 
13
  "dependencies": {
14
+ "@azure/msal-browser": "^5.17.3",
15
+ "@azure/msal-node": "^2.9.2",
16
+ "@azure/msal-react": "^5.5.4",
17
+ "@google-cloud/local-auth": "^3.0.1",
18
+ "@microsoft/microsoft-graph-client": "^3.0.5",
19
+ "clsx": "^2.1.1",
20
+ "date-fns": "^3.6.0",
21
+ "exceljs": "^4.4.0",
22
+ "googleapis": "^173.0.0",
23
+ "imapflow": "^1.6.5",
24
+ "isomorphic-fetch": "^3.0.0",
25
+ "lucide-react": "^0.408.0",
26
+ "mermaid": "^11.16.0",
27
+ "nanoid": "^5.0.7",
28
+ "next": "14.2.5",
29
+ "openai": "^4.52.0",
30
+ "papaparse": "^5.4.1",
31
+ "pdf-parse": "^1.1.2",
32
+ "react": "18.3.1",
33
+ "react-dom": "18.3.1",
34
+ "tailwind-merge": "^2.4.0",
35
+ "zustand": "^4.5.4"
36
  },
37
+ "devDependencies": {
38
+ "@cloudflare/next-on-pages": "^1.13.16",
39
+ "@netlify/plugin-nextjs": "^5.8.1",
40
+ "@types/node": "^20.14.10",
41
+ "@types/papaparse": "^5.3.14",
42
+ "@types/react": "^18.3.3",
43
+ "@types/react-dom": "^18.3.0",
44
+ "autoprefixer": "^10.4.19",
45
+ "eslint": "^8.57.0",
46
+ "postcss": "^8.4.39",
47
+ "tailwindcss": "^3.4.6",
48
+ "tsx": "^4.23.5",
49
+ "typescript": "^5.5.3"
50
  }
51
+ }
postcss.config.js ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ module.exports = {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {},
5
+ },
6
+ };
public/.gitkeep ADDED
File without changes
scripts/__pycache__/prior_art_generator.cpython-313.pyc ADDED
Binary file (31.1 kB). View file
 
scripts/cicd_research.py ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ CI/CD Research Generator — 24/7 continuous prior art research.
4
+
5
+ Loops through all 8 research categories, generates a draft for each,
6
+ then starts over. Runs forever. Saves everything to disk.
7
+
8
+ Each cycle:
9
+ 1. Search GitHub + arXiv + HuggingFace for prior art
10
+ 2. Generate 5-section research draft
11
+ 3. Save to output dir with timestamp
12
+ 4. Move to next category
13
+ 5. After all 8, start over
14
+
15
+ Usage:
16
+ python3 cicd_research.py --output ./research_output
17
+ python3 cicd_research.py --output ./research_output --interval 3600 # wait 1h between cycles
18
+ """
19
+
20
+ import sys, os, time, json, argparse, traceback
21
+ from datetime import datetime
22
+ from pathlib import Path
23
+
24
+ # Add parent to path
25
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
26
+
27
+ from prior_art_generator import (
28
+ PriorArtResearcher, ResearchDraftGenerator, RESEARCH_CATEGORIES,
29
+ llm_chat, llm_generate_long
30
+ )
31
+
32
+ def run_cycle(cycle_num, output_dir):
33
+ """Run one full cycle through all categories."""
34
+ researcher = PriorArtResearcher()
35
+ cycle_dir = output_dir / f"cycle_{cycle_num:04d}"
36
+ cycle_dir.mkdir(parents=True, exist_ok=True)
37
+
38
+ print(f"\n{'#'*70}", flush=True)
39
+ print(f"# CYCLE {cycle_num} — {datetime.now().isoformat()}", flush=True)
40
+ print(f"# Output: {cycle_dir}", flush=True)
41
+ print(f"{'#'*70}", flush=True)
42
+
43
+ results_summary = []
44
+
45
+ for i, category in enumerate(RESEARCH_CATEGORIES):
46
+ print(f"\n{'='*60}", flush=True)
47
+ print(f" [{i+1}/8] {category['name']}", flush=True)
48
+ print(f" Gap: {category['gap'][:80]}...", flush=True)
49
+ print(f"{'='*60}", flush=True)
50
+
51
+ try:
52
+ # Search for prior art
53
+ prior_art = researcher.research(category)
54
+
55
+ # Generate draft
56
+ generator = ResearchDraftGenerator(output_dir=str(cycle_dir))
57
+ filepath, tokens = generator.generate_draft(category, prior_art)
58
+
59
+ results_summary.append({
60
+ "category": category["name"],
61
+ "prior_art_count": len(prior_art),
62
+ "tokens_generated": tokens,
63
+ "file": str(filepath),
64
+ "status": "success",
65
+ })
66
+
67
+ print(f"\n ✅ {category['name']}: {tokens} tokens, {len(prior_art)} sources", flush=True)
68
+
69
+ except Exception as e:
70
+ print(f"\n ❌ {category['name']}: {e}", flush=True)
71
+ traceback.print_exc()
72
+ results_summary.append({
73
+ "category": category["name"],
74
+ "status": "failed",
75
+ "error": str(e),
76
+ })
77
+ continue
78
+
79
+ # Save cycle summary
80
+ summary_path = cycle_dir / "cycle_summary.json"
81
+ summary_path.write_text(json.dumps({
82
+ "cycle": cycle_num,
83
+ "timestamp": datetime.now().isoformat(),
84
+ "categories": results_summary,
85
+ "total_tokens": sum(r.get("tokens_generated", 0) for r in results_summary),
86
+ }, indent=2))
87
+
88
+ print(f"\n{'#'*70}", flush=True)
89
+ print(f"# CYCLE {cycle_num} COMPLETE", flush=True)
90
+ print(f"# Categories: {len(results_summary)}", flush=True)
91
+ print(f"# Success: {sum(1 for r in results_summary if r['status']=='success')}", flush=True)
92
+ print(f"# Failed: {sum(1 for r in results_summary if r['status']=='failed')}", flush=True)
93
+ print(f"{'#'*70}", flush=True)
94
+
95
+ return results_summary
96
+
97
+
98
+ def main():
99
+ parser = argparse.ArgumentParser(description="CI/CD 24/7 Research Generator")
100
+ parser.add_argument("--output", type=str, default="./research_output",
101
+ help="Output directory")
102
+ parser.add_argument("--interval", type=int, default=0,
103
+ help="Seconds to wait between cycles (0 = no wait)")
104
+ parser.add_argument("--max-cycles", type=int, default=0,
105
+ help="Max cycles (0 = infinite)")
106
+ args = parser.parse_args()
107
+
108
+ output_dir = Path(args.output)
109
+ output_dir.mkdir(parents=True, exist_ok=True)
110
+
111
+ # Write master index
112
+ index_path = output_dir / "INDEX.md"
113
+ index_path.write_text(f"# Research Output Index\n\n*Started: {datetime.now().isoformat()}*\n\n")
114
+
115
+ cycle = 1
116
+ all_summaries = []
117
+
118
+ while True:
119
+ try:
120
+ summary = run_cycle(cycle, output_dir)
121
+ all_summaries.append({"cycle": cycle, "results": summary})
122
+
123
+ # Update index
124
+ index_lines = [f"# Research Output Index",
125
+ f"*Last updated: {datetime.now().isoformat()}*",
126
+ f"*Cycles completed: {cycle}*",
127
+ f"*Total drafts: {sum(len(s['results']) for s in all_summaries)}*",
128
+ ""]
129
+ for s in all_summaries[-10:]: # last 10 cycles
130
+ index_lines.append(f"## Cycle {s['cycle']}")
131
+ for r in s["results"]:
132
+ status = "✅" if r["status"] == "success" else "❌"
133
+ tokens = r.get("tokens_generated", 0)
134
+ index_lines.append(f" {status} {r['category']} — {tokens} tokens")
135
+ index_lines.append("")
136
+ index_path.write_text("\n".join(index_lines))
137
+
138
+ except KeyboardInterrupt:
139
+ print(f"\n\nStopped by user after {cycle} cycles.", flush=True)
140
+ break
141
+ except Exception as e:
142
+ print(f"\n⚠ Cycle {cycle} crashed: {e}", flush=True)
143
+ traceback.print_exc()
144
+
145
+ if args.max_cycles and cycle >= args.max_cycles:
146
+ print(f"\nReached max cycles ({args.max_cycles}). Stopping.", flush=True)
147
+ break
148
+
149
+ cycle += 1
150
+
151
+ if args.interval > 0:
152
+ print(f"\n⏳ Waiting {args.interval}s before next cycle...", flush=True)
153
+ time.sleep(args.interval)
154
+
155
+
156
+ if __name__ == "__main__":
157
+ main()
scripts/fractal_book.py ADDED
@@ -0,0 +1,566 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Fractal Book Generator — 6 Tomes, Endless Generation
4
+
5
+ Architecture:
6
+ Tome (6 total)
7
+ └── Chapter (each chapter = book-length, fractal)
8
+ └── Section (generated via rotation)
9
+ └── Chunk (1024 tokens per request)
10
+
11
+ Speed decay: generation slows over time to preserve novelty.
12
+ - First chapter: fast (18 tok/s, raw creative flow)
13
+ - Later chapters: slower (down to 2 tok/s), more deliberate
14
+ - This mimics how a real author slows down as the story deepens
15
+
16
+ Crawler: runs between chapters, compresses previous content:
17
+ - Removes redundancy
18
+ - Paraphrases verbose passages
19
+ - Compresses to ~30% of original token count
20
+ - Frees up context window for new generation
21
+
22
+ LGBTQ+ themed — love stories, chosen family, identity journeys.
23
+
24
+ Usage:
25
+ python3 fractal_book.py [--tomes 6] [--chapters-per-tome 20] [--output ./book]
26
+ """
27
+
28
+ import requests
29
+ import json
30
+ import time
31
+ import os
32
+ import re
33
+ import argparse
34
+ import random
35
+ from datetime import datetime
36
+ from pathlib import Path
37
+
38
+ # ─── CONFIG ───────────────────────────────────────────────────────────────────
39
+
40
+ ENDPOINTS = [
41
+ {"url": "https://gguf-serverless-poc.vercel.app/v1", "model": "/models/model.gguf"},
42
+ # Add more nodes for parallelism:
43
+ # {"url": "https://gguf-node-2.vercel.app/v1", "model": "/models/model.gguf"},
44
+ ]
45
+
46
+ MAX_TOKENS_PER_REQUEST = 512 # smaller chunks = faster response within timeout
47
+ REQUEST_TIMEOUT = 120 # seconds — Vercel container may need to reload model
48
+ COLD_START_WAIT = 20 # seconds to wait on 503
49
+
50
+ # Speed decay — slows generation to preserve novelty
51
+ INITIAL_SPEED_FACTOR = 1.0 # full speed at start
52
+ MIN_SPEED_FACTOR = 0.15 # slow down to 15% by the end
53
+ SPEED_DECAY_PER_CHAPTER = 0.92 # multiply by this each chapter
54
+
55
+ # Crawler — compresses content to save tokens
56
+ CRAWLER_COMPRESSION_TARGET = 0.35 # compress to 35% of original
57
+ CRAWLER_ENABLED = True
58
+
59
+ # Fractal — each chapter is book-length
60
+ TOKENS_PER_SECTION = 8000 # each section within a chapter
61
+ SECTIONS_PER_CHAPTER = 5 # sections per chapter (fractal: chapter = book)
62
+ CHAPTERS_PER_TOME = 20 # chapters per tome
63
+
64
+ # ─── TOMES ────────────────────────────────────────────────────────────────────
65
+
66
+ TOMES = [
67
+ {
68
+ "title": "Tome I: The Awakening",
69
+ "theme": "A young man discovers his identity in a vibrant coastal city. First love, chosen family, and the courage to be seen. Queer awakening story with joy and heartbreak.",
70
+ "characters": ["Milo (protagonist, 24, artist)", "Jules (love interest, 26, musician)", "Sasha (best friend, 25, drag performer)", "Elena (mentor, 40, gallery owner)"],
71
+ "setting": "A seaside art district with queer bars, galleries, and found-family dinners",
72
+ },
73
+ {
74
+ "title": "Tome II: The Currents",
75
+ "theme": "Relationships deepen and fracture. Polyamory, long-distance love, and the tension between freedom and commitment. The chosen family faces its first real test.",
76
+ "characters": ["Milo and Jules (now 3 years in)", "Kai (new love, 23, surfer)", "Sasha (navigating sobriety)", "Diego (Jules's ex, returns)"],
77
+ "setting": "The same city, now through the lens of familiarity — the bars feel smaller, the ocean feels deeper",
78
+ },
79
+ {
80
+ "title": "Tome III: The Depths",
81
+ "theme": "Loss, grief, and rebirth. A pandemic, a death, a breakup. How queer community holds each other through the worst. Dark but ultimately hopeful.",
82
+ "characters": ["Milo (grieving)", "Elena (illness)", "Sasha (relapse and recovery)", "New: Tomás (hospice nurse, 30)"],
83
+ "setting": "Empty streets, hospital rooms, memorial gatherings, the ocean at dawn",
84
+ },
85
+ {
86
+ "title": "Tome IV: The Return",
87
+ "theme": "Healing and second chances. Milo returns to the city after years away. Rebuilding, forgiveness, and the discovery that home is people, not places.",
88
+ "characters": ["Milo (returning, 32)", "Jules (reunion)", "Sasha (sober, running a community center)", "New: Aria (16, queer youth Milo mentors)"],
89
+ "setting": "The city, changed and unchanged. Gentrified queerness, new bars, old ghosts",
90
+ },
91
+ {
92
+ "title": "Tome V: The Legacy",
93
+ "theme": "Milo builds something lasting — a queer art space, a community, a family. Intergenerational queer stories. What we leave behind.",
94
+ "characters": ["Milo (35, building)", "Aria (now 19, finding her path)", "Sasha (elder statesman of the scene)", "New: Cole (Milo's adopted son, 8)"],
95
+ "setting": "A queer community center, an art collective, a home that's always open",
96
+ },
97
+ {
98
+ "title": "Tome VI: The Eternal",
99
+ "theme": "Time becomes fluid. The book loops back on itself. Characters from all tomes appear at different ages. The story never ends — it just changes form. Queer eternity.",
100
+ "characters": ["All characters, across time", "The city itself as character", "The ocean as witness"],
101
+ "setting": "Everywhere and every-when. The coastal city across decades. Dream-logic spaces.",
102
+ },
103
+ ]
104
+
105
+ # ─── INFERENCE ────────────────────────────────────────────────────────────────
106
+
107
+ class InferenceNode:
108
+ def __init__(self, url, model):
109
+ self.url = url
110
+ self.model = model
111
+ self.healthy = True
112
+ self.request_count = 0
113
+ self.last_used = 0
114
+
115
+ def chat(self, messages, max_tokens=1024, temperature=0.7):
116
+ """Send a chat completion request. Retries on 503."""
117
+ endpoint = self.url.rstrip("/") + "/chat/completions"
118
+
119
+ for attempt in range(3):
120
+ try:
121
+ r = requests.post(endpoint, json={
122
+ "model": self.model,
123
+ "messages": messages,
124
+ "max_tokens": max_tokens,
125
+ "temperature": temperature,
126
+ "stream": False,
127
+ }, timeout=REQUEST_TIMEOUT)
128
+
129
+ if r.status_code == 503:
130
+ print(f" [cold start] waiting {COLD_START_WAIT}s...")
131
+ time.sleep(COLD_START_WAIT)
132
+ continue
133
+
134
+ r.raise_for_status()
135
+ data = r.json()
136
+ self.healthy = True
137
+ self.request_count += 1
138
+ self.last_used = time.time()
139
+
140
+ content = data["choices"][0]["message"]["content"]
141
+ tokens = data.get("usage", {}).get("completion_tokens", len(content) // 4)
142
+ finish = data["choices"][0].get("finish_reason", "stop")
143
+ return content, tokens, finish
144
+
145
+ except Exception as e:
146
+ print(f" [error] {e}")
147
+ self.healthy = False
148
+ if attempt < 2:
149
+ time.sleep(COLD_START_WAIT)
150
+ else:
151
+ raise
152
+
153
+ return "", 0, "error"
154
+
155
+ def health(self):
156
+ try:
157
+ url = self.url.replace("/v1", "/health").rstrip("/")
158
+ r = requests.get(url, timeout=10)
159
+ self.healthy = r.ok
160
+ return r.ok
161
+ except:
162
+ self.healthy = False
163
+ return False
164
+
165
+
166
+ # ─── CRAWLER (compression/paraphrasing) ───────────────────────────────────────
167
+
168
+ class Crawler:
169
+ """Runs between chapters to compress and refine content.
170
+ Reduces token count by ~65% while preserving narrative essence."""
171
+
172
+ def __init__(self, node):
173
+ self.node = node
174
+
175
+ def compress(self, content, target_ratio=0.35):
176
+ """Compress content to target_ratio of its original length."""
177
+ if not content.strip():
178
+ return content
179
+
180
+ original_tokens = len(content) // 4
181
+ target_tokens = int(original_tokens * target_ratio)
182
+
183
+ if target_tokens < 100:
184
+ return content # too short to compress
185
+
186
+ # Split into chunks of ~2000 tokens for compression
187
+ chunks = self._split_content(content, max_chars=8000)
188
+ compressed = []
189
+
190
+ for chunk in chunks:
191
+ messages = [
192
+ {"role": "system", "content": "You are an editor. Compress the following text to 35% of its length. Remove redundancy, paraphrase verbosely, keep all plot points, character moments, and emotional beats. Preserve the prose style. Output only the compressed text."},
193
+ {"role": "user", "content": f"Compress this passage:\n\n{chunk}"},
194
+ ]
195
+
196
+ try:
197
+ result, tokens, _ = self.node.chat(messages, max_tokens=512, temperature=0.3)
198
+ compressed.append(result)
199
+ print(f" [crawler] compressed {len(chunk)//4}→{tokens} tokens ({tokens/(len(chunk)//4)*100:.0f}%)")
200
+ except:
201
+ # If compression fails, keep original
202
+ compressed.append(chunk)
203
+
204
+ return "\n\n".join(compressed)
205
+
206
+ def _split_content(self, content, max_chars=8000):
207
+ """Split content at paragraph boundaries."""
208
+ paragraphs = content.split("\n\n")
209
+ chunks = []
210
+ current = ""
211
+
212
+ for p in paragraphs:
213
+ if len(current) + len(p) > max_chars and current:
214
+ chunks.append(current)
215
+ current = p
216
+ else:
217
+ current = current + "\n\n" + p if current else p
218
+
219
+ if current:
220
+ chunks.append(current)
221
+
222
+ return chunks
223
+
224
+
225
+ # ─── FRACTAL BOOK GENERATOR ────────────────────────────────────────────���──────
226
+
227
+ class FractalBookGenerator:
228
+ def __init__(self, nodes, output_dir="./book"):
229
+ self.nodes = nodes
230
+ self.output_dir = Path(output_dir)
231
+ self.output_dir.mkdir(parents=True, exist_ok=True)
232
+ self.crawler = Crawler(nodes[0])
233
+ self.speed_factor = INITIAL_SPEED_FACTOR
234
+ self.chapter_count = 0
235
+ self.total_tokens = 0
236
+ self.compressed_tokens = 0
237
+
238
+ def _get_speed_delay(self):
239
+ """Returns delay (seconds) to wait before generating, based on speed factor.
240
+ Slower speed = longer delay = more 'deliberate' generation."""
241
+ base_delay = 0.5 # minimum delay
242
+ max_delay = 8.0 # maximum delay at slowest speed
243
+ delay = base_delay + (1.0 - self.speed_factor) * (max_delay - base_delay)
244
+ # Add randomness for organic feel
245
+ delay *= random.uniform(0.7, 1.3)
246
+ return delay
247
+
248
+ def _get_temperature(self):
249
+ """Temperature increases slightly as speed decreases (more creative when slow)."""
250
+ return 0.7 + (1.0 - self.speed_factor) * 0.3 # 0.7 → 1.0
251
+
252
+ def _decay_speed(self):
253
+ """Slow down generation to preserve novelty."""
254
+ self.speed_factor = max(MIN_SPEED_FACTOR, self.speed_factor * SPEED_DECAY_PER_CHAPTER)
255
+ self.chapter_count += 1
256
+
257
+ def _get_node(self):
258
+ """Round-robin to next healthy node."""
259
+ for node in self.nodes:
260
+ if node.healthy:
261
+ return node
262
+ # All unhealthy — reset and try first
263
+ for node in self.nodes:
264
+ node.healthy = True
265
+ return self.nodes[0]
266
+
267
+ def generate_section(self, system_prompt, user_prompt, target_tokens, context=None):
268
+ """Generate a section via sequential requests within one node."""
269
+ node = self._get_node()
270
+ messages = [{"role": "system", "content": system_prompt}]
271
+
272
+ if context:
273
+ # Include compressed context for continuity
274
+ messages.append({"role": "user", "content": f"Previous context (compressed):\n{context[:4000]}"})
275
+ messages.append({"role": "assistant", "content": "Understood. I'll continue from this context."})
276
+
277
+ messages.append({"role": "user", "content": user_prompt})
278
+
279
+ section_content = ""
280
+ section_tokens = 0
281
+ rotations = 0
282
+
283
+ while section_tokens < target_tokens and rotations < 50:
284
+ current_msgs = list(messages)
285
+
286
+ if rotations > 0:
287
+ current_msgs.append({
288
+ "role": "user",
289
+ "content": "Continue exactly where you left off. Do not repeat. Write the next part of this section."
290
+ })
291
+
292
+ # Apply speed delay for novelty preservation
293
+ delay = self._get_speed_delay()
294
+ if delay > 0.1:
295
+ time.sleep(delay)
296
+
297
+ temp = self._get_temperature()
298
+
299
+ try:
300
+ content, tokens, finish = node.chat(
301
+ current_msgs, max_tokens=MAX_TOKENS_PER_REQUEST, temperature=temp
302
+ )
303
+
304
+ if not content.strip():
305
+ break
306
+
307
+ section_content += content
308
+ section_tokens += tokens
309
+ self.total_tokens += tokens
310
+ rotations += 1
311
+
312
+ # Add to context for continuity
313
+ messages.append({"role": "assistant", "content": content})
314
+
315
+ # Prune context to prevent KV cache bloat
316
+ if len(messages) > 8:
317
+ # Keep system + first user + last 4 exchanges
318
+ messages = messages[:2] + messages[-4:]
319
+
320
+ print(f" [{rotations}] {section_tokens}/{target_tokens} tok | "
321
+ f"speed={self.speed_factor:.2f} | delay={delay:.1f}s | temp={temp:.1f}")
322
+
323
+ # Natural stop
324
+ if finish == "stop" and tokens < MAX_TOKENS_PER_REQUEST * 0.5:
325
+ break
326
+
327
+ except Exception as e:
328
+ print(f" [error] {e}")
329
+ time.sleep(5)
330
+ continue
331
+
332
+ return section_content, section_tokens
333
+
334
+ def generate_chapter(self, tome, chapter_num, total_chapters, compressed_context=""):
335
+ """Generate one chapter (fractal: each chapter is book-length with sections)."""
336
+ chapter_title = f"Chapter {chapter_num}: {self._chapter_title(tome, chapter_num)}"
337
+
338
+ print(f"\n 📖 {chapter_title}")
339
+ print(f" Speed factor: {self.speed_factor:.3f} | "
340
+ f"Delay: {self._get_speed_delay():.1f}s | "
341
+ f"Temp: {self._get_temperature():.1f}")
342
+
343
+ system = (
344
+ f"You are a celebrated queer novelist writing {tome['title']}. "
345
+ f"Theme: {tome['theme']}\n"
346
+ f"Characters: {', '.join(tome['characters'])}\n"
347
+ f"Setting: {tome['setting']}\n\n"
348
+ f"Write vivid, immersive prose. Include dialogue, sensory details, "
349
+ f"emotional depth, and queer joy. This is literary fiction — not a summary. "
350
+ f"Write the actual narrative prose."
351
+ )
352
+
353
+ chapter_content = ""
354
+ chapter_tokens = 0
355
+
356
+ # Each chapter has multiple sections (fractal: chapter = book)
357
+ for section_num in range(1, SECTIONS_PER_CHAPTER + 1):
358
+ section_prompt = (
359
+ f"Write Section {section_num} of {SECTIONS_PER_CHAPTER} of {chapter_title}.\n"
360
+ f"This is chapter {chapter_num} of {total_chapters} in {tome['title']}.\n\n"
361
+ f"Section focus: {self._section_focus(tome, chapter_num, section_num)}\n\n"
362
+ f"Write full narrative prose — at least 1500 words. Include dialogue, "
363
+ f"description, internal monologue, and sensory detail. Do not summarize."
364
+ )
365
+
366
+ print(f"\n ─── Section {section_num}/{SECTIONS_PER_CHAPTER} ───")
367
+ content, tokens = self.generate_section(
368
+ system, section_prompt, TOKENS_PER_SECTION,
369
+ context=compressed_context if section_num == 1 else None
370
+ )
371
+
372
+ chapter_content += f"\n\n## Section {section_num}\n\n{content}"
373
+ chapter_tokens += tokens
374
+
375
+ # Save section incrementally
376
+ self._save_chapter(tome, chapter_num, chapter_title, chapter_content, chapter_tokens)
377
+
378
+ # Crawler: compress this chapter for future context
379
+ compressed = ""
380
+ if CRAWLER_ENABLED:
381
+ print(f"\n 🕷️ Crawler compressing chapter {chapter_num}...")
382
+ compressed = self.crawler.compress(chapter_content, CRAWLER_COMPRESSION_TARGET)
383
+ comp_tokens = len(compressed) // 4
384
+ self.compressed_tokens += comp_tokens
385
+ print(f" {chapter_tokens}→{comp_tokens} tokens "
386
+ f"({comp_tokens/max(chapter_tokens,1)*100:.0f}%)")
387
+
388
+ # Decay speed for next chapter (novelty preservation)
389
+ self._decay_speed()
390
+
391
+ return chapter_content, chapter_tokens, compressed
392
+
393
+ def generate_tome(self, tome, tome_num):
394
+ """Generate one full tome (20 chapters)."""
395
+ print(f"\n{'='*70}")
396
+ print(f" 📕 {tome['title']}")
397
+ print(f" {tome['theme'][:100]}...")
398
+ print(f"{'='*70}")
399
+
400
+ tome_content = ""
401
+ tome_tokens = 0
402
+ compressed_context = "" # Accumulates compressed chapters
403
+
404
+ for chapter_num in range(1, CHAPTERS_PER_TOME + 1):
405
+ chapter_content, chapter_tokens, compressed = self.generate_chapter(
406
+ tome, chapter_num, CHAPTERS_PER_TOME, compressed_context
407
+ )
408
+
409
+ tome_content += f"\n\n# {tome['title']} — Chapter {chapter_num}\n\n{chapter_content}"
410
+ tome_tokens += chapter_tokens
411
+
412
+ # Add compressed chapter to running context
413
+ if compressed:
414
+ compressed_context = self._merge_context(compressed_context, compressed)
415
+
416
+ # Save tome incrementally
417
+ self._save_tome(tome, tome_num, tome_content, tome_tokens)
418
+
419
+ # Stats
420
+ self._print_stats()
421
+
422
+ return tome_content, tome_tokens
423
+
424
+ def generate_all(self, num_tomes=6):
425
+ """Generate all tomes. Continuous, endless generation."""
426
+ print(f"\n{'#'*70}")
427
+ print(f"# FRACTAL BOOK GENERATOR")
428
+ print(f"# {num_tomes} tomes × {CHAPTERS_PER_TOME} chapters × {SECTIONS_PER_CHAPTER} sections")
429
+ print(f"# Speed decay: {INITIAL_SPEED_FACTOR}→{MIN_SPEED_FACTOR} (novelty preservation)")
430
+ print(f"# Crawler: {'ON' if CRAWLER_ENABLED else 'OFF'} (compresses to {CRAWLER_COMPRESSION_TARGET*100:.0f}%)")
431
+ print(f"# LGBTQ+ themed: love, chosen family, identity, queer joy")
432
+ print(f"{'#'*70}")
433
+
434
+ start_time = time.time()
435
+
436
+ # Health check all nodes
437
+ print("\nHealth checking nodes...")
438
+ for node in self.nodes:
439
+ healthy = node.health()
440
+ print(f" {node.url}: {'✓' if healthy else '✗'}")
441
+
442
+ all_content = ""
443
+ all_tokens = 0
444
+
445
+ for tome_num, tome in enumerate(TOMES[:num_tomes], 1):
446
+ tome_content, tome_tokens = self.generate_tome(tome, tome_num)
447
+ all_content += tome_content
448
+ all_tokens += tome_tokens
449
+
450
+ # Save complete book so far
451
+ self._save_book(all_content, all_tokens, start_time)
452
+
453
+ elapsed = time.time() - start_time
454
+ print(f"\n{'#'*70}")
455
+ print(f"# COMPLETE: {num_tomes} tomes, {all_tokens} tokens")
456
+ print(f"# Time: {elapsed/3600:.1f} hours")
457
+ print(f"# Compressed: {self.compressed_tokens} tokens saved by crawler")
458
+ print(f"# Words: ~{all_tokens * 4 // 1}")
459
+ print(f"# Pages: ~{all_tokens * 4 // 250}")
460
+ print(f"{'#'*70}")
461
+
462
+ # ─── HELPERS ──────────────────────────────────────────────────────────────
463
+
464
+ def _chapter_title(self, tome, chapter_num):
465
+ titles = [
466
+ "The First Light", "Salt and Skin", "The Bar at the End of the Pier",
467
+ "What the Ocean Knows", "Chosen", "The Art of Being Seen",
468
+ "Ebb", "Flood", "The Dinner Party", "Midnight Swims",
469
+ "The Gallery", "What We Carry", "The Long Walk Home",
470
+ "Letters Never Sent", "The Return", "Saudade",
471
+ "The Lighthouse", "Becoming", "The Eternal Now", "Full Circle",
472
+ ]
473
+ return titles[(chapter_num - 1) % len(titles)]
474
+
475
+ def _section_focus(self, tome, chapter_num, section_num):
476
+ focuses = [
477
+ "Open with a vivid scene. Introduce the emotional landscape. Who is present? What do they want?",
478
+ "Deepen the scene. Add dialogue — let characters reveal themselves through speech.",
479
+ "A shift or complication. Something changes. Internal monologue, doubt, desire.",
480
+ "The emotional peak of this chapter. The most vulnerable moment. Sensory immersion.",
481
+ "Resolution and transition. A closing image that lingers. Set up the next chapter.",
482
+ ]
483
+ return focuses[(section_num - 1) % len(focuses)]
484
+
485
+ def _merge_context(self, existing, new):
486
+ """Merge compressed contexts, keeping total under ~6000 tokens."""
487
+ merged = existing + "\n\n" + new if existing else new
488
+ max_chars = 24000 # ~6000 tokens
489
+ if len(merged) > max_chars:
490
+ # Keep the most recent (end of the context)
491
+ merged = merged[-max_chars:]
492
+ return merged
493
+
494
+ def _save_chapter(self, tome, chapter_num, title, content, tokens):
495
+ """Save chapter incrementally."""
496
+ tome_dir = self.output_dir / f"tome_{tome['title'].split(':')[0].split()[-1]}"
497
+ tome_dir.mkdir(parents=True, exist_ok=True)
498
+ filepath = tome_dir / f"chapter_{chapter_num:02d}.md"
499
+ with open(filepath, "w") as f:
500
+ f.write(f"# {title}\n\n*{tokens} tokens*\n\n{content}")
501
+
502
+ def _save_tome(self, tome, tome_num, content, tokens):
503
+ """Save complete tome."""
504
+ filepath = self.output_dir / f"tome_{tome_num:02d}_{tome['title'].split(':')[1].strip().lower().replace(' ', '_')}.md"
505
+ with open(filepath, "w") as f:
506
+ f.write(f"# {tome['title']}\n\n*{tokens} tokens, ~{tokens*4//250} pages*\n\n{content}")
507
+
508
+ def _save_book(self, content, tokens, start_time):
509
+ """Save the complete book."""
510
+ filepath = self.output_dir / "fractal_book_complete.md"
511
+ elapsed = time.time() - start_time
512
+ words = tokens * 4
513
+ pages = words // 250
514
+
515
+ with open(filepath, "w") as f:
516
+ f.write(f"# The Eternal Book: Six Tomes\n\n")
517
+ f.write(f"*Generated: {datetime.now().isoformat()}*\n")
518
+ f.write(f"*Tokens: {tokens} | Words: ~{words} | Pages: ~{pages}*\n")
519
+ f.write(f"*Time: {elapsed/3600:.1f} hours*\n")
520
+ f.write(f"*Crawler saved: {self.compressed_tokens} tokens*\n\n")
521
+ f.write(f"---\n\n{content}")
522
+
523
+ def _print_stats(self):
524
+ """Print running stats."""
525
+ print(f"\n 📊 Stats: {self.total_tokens} tokens | "
526
+ f"Speed: {self.speed_factor:.3f} | "
527
+ f"Crawler saved: {self.compressed_tokens} tokens | "
528
+ f"Chapters: {self.chapter_count}")
529
+
530
+
531
+ # ─── MAIN ─────────────────────────────────────────────────────────────────────
532
+
533
+ def main():
534
+ parser = argparse.ArgumentParser(description="Fractal Book Generator — 6 Tomes")
535
+ parser.add_argument("--tomes", type=int, default=6, help="Number of tomes (default: 6)")
536
+ parser.add_argument("--chapters", type=int, default=20, help="Chapters per tome (default: 20)")
537
+ parser.add_argument("--sections", type=int, default=5, help="Sections per chapter (default: 5)")
538
+ parser.add_argument("--output", type=str, default="./book", help="Output directory")
539
+ parser.add_argument("--no-crawler", action="store_true", help="Disable crawler compression")
540
+ parser.add_argument("--no-decay", action="store_true", help="Disable speed decay")
541
+ args = parser.parse_args()
542
+
543
+ global CHAPTERS_PER_TOME, SECTIONS_PER_CHAPTER, CRAWLER_ENABLED
544
+ CHAPTERS_PER_TOME = args.chapters
545
+ SECTIONS_PER_CHAPTER = args.sections
546
+ if args.no_crawler:
547
+ global CRAWLER_ENABLED
548
+ CRAWLER_ENABLED = False
549
+
550
+ # Build nodes
551
+ nodes = [InferenceNode(ep["url"], ep["model"]) for ep in ENDPOINTS]
552
+
553
+ # Create generator
554
+ gen = FractalBookGenerator(nodes, output_dir=args.output)
555
+
556
+ if args.no_decay:
557
+ gen.speed_factor = 1.0
558
+ global SPEED_DECAY_PER_CHAPTER
559
+ SPEED_DECAY_PER_CHAPTER = 1.0
560
+
561
+ # Generate!
562
+ gen.generate_all(num_tomes=args.tomes)
563
+
564
+
565
+ if __name__ == "__main__":
566
+ main()
scripts/prior_art_generator.py ADDED
@@ -0,0 +1,624 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Prior Art Research Generator
4
+
5
+ Identifies the most needed category of LLM research by searching:
6
+ 1. GitHub — existing systems, repos, implementations
7
+ 2. arXiv — published papers
8
+ 3. Hugging Face — deployed models, spaces, demos
9
+
10
+ Then drafts what DOESN'T exist yet — novel system proposals backed
11
+ by real prior art, with defensible novelty claims.
12
+
13
+ This is NOT creative writing. This is research synthesis.
14
+
15
+ Usage:
16
+ python3 prior_art_generator.py [--topic "LLM inference scaling"] [--output ./research]
17
+ python3 prior_art_generator.py --auto # auto-discover hottest underexplored topic
18
+ """
19
+
20
+ import requests
21
+ import json
22
+ import time
23
+ import re
24
+ import os
25
+ import argparse
26
+ import random
27
+ from datetime import datetime
28
+ from pathlib import Path
29
+ from urllib.parse import quote
30
+
31
+ # ─── CONFIG ───────────────────────────────────────────────────────────────────
32
+
33
+ LLM_URL = "https://gguf-serverless-poc.vercel.app/v1/chat/completions"
34
+ LLM_MODEL = "/models/model.gguf"
35
+ REQUEST_TIMEOUT = 120
36
+ COLD_START_WAIT = 15
37
+ MAX_TOKENS_PER_REQUEST = 512
38
+
39
+ # ─── SEARCH BACKENDS ──────────────────────────────────────────────────────────
40
+
41
+ class GitHubSearch:
42
+ """Search GitHub repositories for prior art."""
43
+ BASE = "https://api.github.com/search/repositories"
44
+
45
+ def search(self, query, per_page=10):
46
+ params = {"q": query, "sort": "stars", "order": "desc", "per_page": per_page}
47
+ r = requests.get(self.BASE, params=params, timeout=15,
48
+ headers={"Accept": "application/vnd.github.v3+json"})
49
+ if not r.ok:
50
+ return []
51
+ data = r.json()
52
+ results = []
53
+ for item in data.get("items", []):
54
+ results.append({
55
+ "source": "github",
56
+ "name": item["full_name"],
57
+ "url": item["html_url"],
58
+ "stars": item["stargazers_count"],
59
+ "description": item.get("description", ""),
60
+ "language": item.get("language", ""),
61
+ "topics": item.get("topics", []),
62
+ "updated": item.get("updated_at", ""),
63
+ "license": item.get("license", {}).get("name", "") if item.get("license") else "",
64
+ })
65
+ return results
66
+
67
+ def search_code(self, query, per_page=5):
68
+ """Search code for implementation patterns."""
69
+ params = {"q": query, "per_page": per_page}
70
+ r = requests.get("https://api.github.com/search/code", params=params, timeout=15,
71
+ headers={"Accept": "application/vnd.github.v3+json"})
72
+ if not r.ok:
73
+ return []
74
+ data = r.json()
75
+ return [{"source": "github_code", "repo": item["repository"]["full_name"],
76
+ "path": item["path"], "url": item["html_url"]}
77
+ for item in data.get("items", [])]
78
+
79
+
80
+ class ArxivSearch:
81
+ """Search arXiv for published papers."""
82
+ BASE = "http://export.arxiv.org/api/query"
83
+
84
+ def search(self, query, max_results=10):
85
+ params = {"search_query": f"all:{query}", "max_results": max_results,
86
+ "sortBy": "relevance", "sortOrder": "descending"}
87
+ r = requests.get(self.BASE, params=params, timeout=15)
88
+ if not r.ok:
89
+ return []
90
+ text = r.text
91
+ entries = []
92
+ # Parse Atom feed
93
+ entry_blocks = re.findall(r'<entry>(.*?)</entry>', text, re.DOTALL)
94
+ for block in entry_blocks:
95
+ title = re.search(r'<title>(.*?)</title>', block, re.DOTALL)
96
+ summary = re.search(r'<summary>(.*?)</summary>', block, re.DOTALL)
97
+ link = re.search(r'<id>(.*?)</id>', block, re.DOTALL)
98
+ published = re.search(r'<published>(.*?)</published>', block, re.DOTALL)
99
+ authors = re.findall(r'<name>(.*?)</name>', block, re.DOTALL)
100
+ entries.append({
101
+ "source": "arxiv",
102
+ "title": title.group(1).strip().replace("\n", " ") if title else "",
103
+ "summary": summary.group(1).strip()[:500] if summary else "",
104
+ "url": link.group(1).strip() if link else "",
105
+ "published": published.group(1).strip() if published else "",
106
+ "authors": authors[:5],
107
+ })
108
+ return entries
109
+
110
+
111
+ class HuggingFaceSearch:
112
+ """Search Hugging Face for models, spaces, and datasets."""
113
+ def search_models(self, query, limit=10):
114
+ r = requests.get(f"https://huggingface.co/api/models",
115
+ params={"search": query, "limit": limit}, timeout=15)
116
+ if not r.ok:
117
+ return []
118
+ data = r.json()
119
+ return [{
120
+ "source": "hf_model",
121
+ "name": m["id"],
122
+ "url": f"https://huggingface.co/{m['id']}",
123
+ "downloads": m.get("downloads", 0),
124
+ "likes": m.get("likes", 0),
125
+ "tags": m.get("tags", [])[:5],
126
+ } for m in data]
127
+
128
+ def search_spaces(self, query, limit=5):
129
+ r = requests.get(f"https://huggingface.co/api/spaces",
130
+ params={"search": query, "limit": limit}, timeout=15)
131
+ if not r.ok:
132
+ return []
133
+ data = r.json()
134
+ return [{
135
+ "source": "hf_space",
136
+ "name": s["id"],
137
+ "url": f"https://huggingface.co/spaces/{s['id']}",
138
+ "tags": s.get("tags", [])[:5],
139
+ } for s in data]
140
+
141
+
142
+ # ─── LLM INFERENCE ────────────────────────────────────────────────────────────
143
+
144
+ def llm_chat(messages, max_tokens=512, temp=0.7):
145
+ """Send a chat completion request with retry on cold start."""
146
+ for attempt in range(3):
147
+ try:
148
+ r = requests.post(LLM_URL, json={
149
+ "model": LLM_MODEL, "messages": messages,
150
+ "max_tokens": max_tokens, "temperature": temp, "stream": False,
151
+ }, timeout=REQUEST_TIMEOUT)
152
+ if r.status_code == 503:
153
+ print(f" ❄ cold start, waiting...", flush=True)
154
+ time.sleep(COLD_START_WAIT)
155
+ continue
156
+ r.raise_for_status()
157
+ d = r.json()
158
+ return d["choices"][0]["message"]["content"], d["usage"]["completion_tokens"]
159
+ except Exception as e:
160
+ print(f" ⚠ {e}", flush=True)
161
+ time.sleep(10)
162
+ return "", 0
163
+
164
+
165
+ def llm_generate_long(system_prompt, user_prompt, target_tokens=2000):
166
+ """Generate long-form text via sequential requests with context pruning."""
167
+ context = [
168
+ {"role": "system", "content": system_prompt},
169
+ {"role": "user", "content": user_prompt},
170
+ ]
171
+ full_text = ""
172
+ total_tokens = 0
173
+ rotation = 0
174
+
175
+ while total_tokens < target_tokens and rotation < 30:
176
+ msgs = list(context)
177
+ if rotation > 0:
178
+ msgs.append({"role": "user", "content": "Continue. Do not repeat. Write the next section."})
179
+
180
+ content, tokens = llm_chat(msgs, max_tokens=MAX_TOKENS_PER_REQUEST, temp=0.7)
181
+ if not content.strip():
182
+ break
183
+
184
+ full_text += content
185
+ total_tokens += tokens
186
+ rotation += 1
187
+ context.append({"role": "assistant", "content": content})
188
+
189
+ # Prune context to prevent KV cache bloat
190
+ if len(context) > 6:
191
+ context = context[:2] + context[-4:]
192
+
193
+ print(f" [{rotation}] {total_tokens}/{target_tokens} tok", flush=True)
194
+
195
+ if tokens < MAX_TOKENS_PER_REQUEST * 0.3:
196
+ break # natural stop
197
+
198
+ return full_text, total_tokens
199
+
200
+
201
+ # ─── RESEARCH CATEGORIES ──────────────────────────────────────────────────────
202
+
203
+ # Hot LLM research areas — ranked by "what's most needed but doesn't fully exist yet"
204
+ RESEARCH_CATEGORIES = [
205
+ {
206
+ "name": "Serverless LLM Inference Rotation",
207
+ "description": "Multi-node rotation of serverless inference endpoints to bypass per-container timeout limits",
208
+ "gap": "No existing system chains serverless inference nodes for continuous generation beyond platform timeout limits",
209
+ "search_terms": ["llm inference serverless", "llama.cpp serverless", "inference rotation scaling", "serverless model serving"],
210
+ },
211
+ {
212
+ "name": "KV Cache Externalization for Serverless LLMs",
213
+ "description": "Extracting and persisting KV cache across serverless invocations to avoid recomputation",
214
+ "gap": "KV cache is ephemeral in serverless — no system persists it externally for reuse across cold starts",
215
+ "search_terms": ["kv cache persistence", "llm cache external", "kv cache serverless", "transformer cache reuse"],
216
+ },
217
+ {
218
+ "name": "Fractal Token Compression via Self-Editing",
219
+ "description": "LLM that compresses its own output mid-generation to preserve context window for novel content",
220
+ "gap": "No system has the model edit/compress its own prior output during generation to free context",
221
+ "search_terms": ["llm context compression", "token reduction inference", "self-editing generation", "context window optimization"],
222
+ },
223
+ {
224
+ "name": "P2P GGUF Distribution with Chunk-Level Provenance",
225
+ "description": "BitTorrent-style distribution of GGUF model files with cryptographic chunk provenance",
226
+ "gap": "Existing model distribution is centralized (HF Hub) — no P2P system with chunk-level integrity verification",
227
+ "search_terms": ["gguf p2p distribution", "model file sharing", "torrent model distribution", "decentralized llm"],
228
+ },
229
+ {
230
+ "name": "Competitive Inference Racing for Quality Selection",
231
+ "description": "Multiple models race on the same prompt; preference signals select the best output without human review",
232
+ "gap": "Arena-style human preference exists, but no autonomous racing system selects outputs without human voting",
233
+ "search_terms": ["llm arena competitive", "model racing inference", "preference learning llm", "constitutional ai selection"],
234
+ },
235
+ {
236
+ "name": "Email-to-Execution Pipeline with LLM-Generated DAGs",
237
+ "description": "Parse email content into executable DAGs (directed acyclic graphs) that run ETL operations autonomously",
238
+ "gap": "Email parsing exists, but no system generates executable workflow DAGs from unstructured email content",
239
+ "search_terms": ["email etl pipeline", "llm workflow generation", "dag generation from text", "automated email processing"],
240
+ },
241
+ {
242
+ "name": "MCP (Model Context Protocol) for Cross-Model Tool Routing",
243
+ "description": "Standardized protocol for routing tool calls across heterogeneous LLM endpoints",
244
+ "gap": "MCP exists as a spec, but no production system routes tool calls across multiple model backends dynamically",
245
+ "search_terms": ["model context protocol", "mcp llm tools", "cross-model routing", "llm tool orchestration"],
246
+ },
247
+ {
248
+ "name": "WebRTC-Based Peer-to-Peer LLM Inference",
249
+ "description": "Browser-to-browser LLM inference using WebRTC for direct peer chunk transfer",
250
+ "gap": "WebRTC exists for media, but no system uses it for LLM model weight or inference distribution",
251
+ "search_terms": ["webrtc inference", "browser llm", "peer-to-peer model", "webgpu inference"],
252
+ },
253
+ ]
254
+
255
+
256
+ # ─── PRIOR ART RESEARCHER ─────────────────────────────────────────────────────
257
+
258
+ class PriorArtResearcher:
259
+ """Searches GitHub, arXiv, and Hugging Face for prior art on a topic."""
260
+
261
+ def __init__(self):
262
+ self.github = GitHubSearch()
263
+ self.arxiv = ArxivSearch()
264
+ self.hf = HuggingFaceSearch()
265
+
266
+ def research(self, category):
267
+ """Search all sources for prior art on a research category."""
268
+ print(f"\n{'─'*60}", flush=True)
269
+ print(f" RESEARCHING: {category['name']}", flush=True)
270
+ print(f" Gap: {category['gap']}", flush=True)
271
+ print(f"{'─'*60}", flush=True)
272
+
273
+ all_results = []
274
+ for term in category["search_terms"]:
275
+ print(f"\n Searching: \"{term}\"", flush=True)
276
+
277
+ # GitHub
278
+ print(f" GitHub...", flush=True)
279
+ gh = self.github.search(term, per_page=5)
280
+ all_results.extend(gh)
281
+ for r in gh[:3]:
282
+ print(f" ★{r['stars']:>5} {r['name']} — {(r['description'] or '')[:60]}", flush=True)
283
+
284
+ # arXiv
285
+ print(f" arXiv...", flush=True)
286
+ ax = self.arxiv.search(term, max_results=5)
287
+ all_results.extend(ax)
288
+ for r in ax[:2]:
289
+ print(f" 📄 {r['title'][:80]}", flush=True)
290
+
291
+ # HuggingFace
292
+ print(f" HuggingFace...", flush=True)
293
+ hf_models = self.hf.search_models(term, limit=3)
294
+ all_results.extend(hf_models)
295
+ for r in hf_models[:2]:
296
+ print(f" 🤗 {r['name']} ({r['downloads']} downloads)", flush=True)
297
+
298
+ time.sleep(1) # rate limit courtesy
299
+
300
+ return all_results
301
+
302
+ def auto_discover(self):
303
+ """Search all categories and rank by 'most needed but least explored'."""
304
+ print(f"\n{'='*60}", flush=True)
305
+ print(f" AUTO-DISCOVERING HOTTEST UNDEREXPLORED TOPIC", flush=True)
306
+ print(f" Searching {len(RESEARCH_CATEGORIES)} research categories...", flush=True)
307
+ print(f"{'='*60}", flush=True)
308
+
309
+ scored = []
310
+ for cat in RESEARCH_CATEGORIES:
311
+ results = self.research(cat)
312
+ github_count = len([r for r in results if r["source"] == "github"])
313
+ arxiv_count = len([r for r in results if r["source"] == "arxiv"])
314
+ hf_count = len([r for r in results if r["source"] in ("hf_model", "hf_space")])
315
+ total = len(results)
316
+
317
+ # Score: lower existing work = higher novelty potential
318
+ # But need SOME existing work to build on
319
+ if total == 0:
320
+ novelty_score = 0 # too novel — nothing to build on
321
+ else:
322
+ novelty_score = max(0, 100 - total * 3) # fewer results = more novel
323
+
324
+ scored.append({
325
+ "category": cat,
326
+ "results": results,
327
+ "github_count": github_count,
328
+ "arxiv_count": arxiv_count,
329
+ "hf_count": hf_count,
330
+ "total": total,
331
+ "novelty_score": novelty_score,
332
+ })
333
+
334
+ print(f"\n 📊 {cat['name']}: {total} results "
335
+ f"(GH:{github_count} arXiv:{arxiv_count} HF:{hf_count}) "
336
+ f"— Novelty: {novelty_score}/100", flush=True)
337
+
338
+ # Sort by novelty score (highest = most needed, least explored)
339
+ scored.sort(key=lambda x: x["novelty_score"], reverse=True)
340
+
341
+ print(f"\n{'='*60}", flush=True)
342
+ print(f" RANKING (most needed → least needed):", flush=True)
343
+ for i, s in enumerate(scored):
344
+ marker = " ◀ SELECTED" if i == 0 else ""
345
+ print(f" {i+1}. {s['category']['name']} "
346
+ f"— {s['total']} prior art, novelty={s['novelty_score']}{marker}", flush=True)
347
+ print(f"{'='*60}", flush=True)
348
+
349
+ return scored[0]
350
+
351
+
352
+ # ─── RESEARCH DRAFT GENERATOR ─────────────────────────────────────────────────
353
+
354
+ class ResearchDraftGenerator:
355
+ """Generates a research draft claiming novelty backed by prior art."""
356
+
357
+ def __init__(self, output_dir="./research"):
358
+ self.output_dir = Path(output_dir)
359
+ self.output_dir.mkdir(parents=True, exist_ok=True)
360
+
361
+ def generate_draft(self, category, prior_art):
362
+ """Generate a full research draft with defensible novelty claims."""
363
+ print(f"\n{'='*60}", flush=True)
364
+ print(f" GENERATING RESEARCH DRAFT", flush=True)
365
+ print(f" Topic: {category['name']}", flush=True)
366
+ print(f" Prior art: {len(prior_art)} sources", flush=True)
367
+ print(f"{'='*60}", flush=True)
368
+
369
+ # Format prior art for the LLM context
370
+ prior_art_text = self._format_prior_art(prior_art)
371
+
372
+ # ─── SECTION 1: ABSTRACT & PROBLEM STATEMENT ───────────
373
+ print(f"\n 📝 Section 1: Abstract & Problem Statement", flush=True)
374
+ s1_system = (
375
+ "You are a senior ML systems researcher writing a technical paper. "
376
+ "You write precise, academic prose with citations. "
377
+ "You identify gaps in existing systems and propose novel solutions backed by prior art. "
378
+ "Do not write fiction. Write research."
379
+ )
380
+ s1_prompt = (
381
+ f"Write the Abstract and Problem Statement for a research paper on: {category['name']}.\n\n"
382
+ f"Description: {category['description']}\n"
383
+ f"Identified gap: {category['gap']}\n\n"
384
+ f"Prior art (existing systems this builds on):\n{prior_art_text[:3000]}\n\n"
385
+ f"Write a rigorous abstract (200 words) and problem statement (500 words) "
386
+ f"that positions this work against the prior art. Cite specific systems by name. "
387
+ f"Clearly state what does NOT exist yet and why it matters."
388
+ )
389
+ s1_text, s1_tokens = llm_generate_long(s1_system, s1_prompt, target_tokens=1500)
390
+
391
+ # ─── SECTION 2: RELATED WORK ────────────────────────────
392
+ print(f"\n 📝 Section 2: Related Work (Prior Art Analysis)", flush=True)
393
+ s2_system = s1_system
394
+ s2_prompt = (
395
+ f"Write the Related Work section for a paper on {category['name']}.\n\n"
396
+ f"Analyze these existing systems and papers:\n{prior_art_text[:4000]}\n\n"
397
+ f"For each category of prior art:\n"
398
+ f"1. Summarize what exists\n"
399
+ f"2. Identify limitations\n"
400
+ f"3. State what gap remains\n\n"
401
+ f"Be specific — cite repo names, paper titles, and model names. "
402
+ f"This is a literature review, not a summary."
403
+ )
404
+ s2_text, s2_tokens = llm_generate_long(s2_system, s2_prompt, target_tokens=2000)
405
+
406
+ # ─── SECTION 3: PROPOSED SYSTEM ─────────────────────────
407
+ print(f"\n 📝 Section 3: Proposed System Architecture", flush=True)
408
+ s3_system = s1_system
409
+ s3_prompt = (
410
+ f"Write the Proposed System section for a paper on {category['name']}.\n\n"
411
+ f"Gap being addressed: {category['gap']}\n\n"
412
+ f"Design a novel system that fills this gap. Include:\n"
413
+ f"1. System architecture (components, data flow)\n"
414
+ f"2. Novel contributions (what doesn't exist in prior art)\n"
415
+ f"3. Technical approach (algorithms, protocols, data structures)\n"
416
+ f"4. Why this is defensibly novel (cite what exists vs what's new)\n\n"
417
+ f"Prior art for context:\n{prior_art_text[:2000]}\n\n"
418
+ f"Write technical specifications, not vague descriptions."
419
+ )
420
+ s3_text, s3_tokens = llm_generate_long(s3_system, s3_prompt, target_tokens=2000)
421
+
422
+ # Save partial draft after section 3
423
+ self._save_partial(category, prior_art, s1_text, s2_text, s3_text, "", "")
424
+
425
+ # ─── SECTION 4: NOVELTY DEFENSE ─────────────────────────
426
+ print(f"\n 📝 Section 4: Novelty Defense (Claim Analysis)", flush=True)
427
+ s4_system = s1_system
428
+ s4_prompt = (
429
+ f"Write the Novelty Analysis section for a paper on {category['name']}.\n\n"
430
+ f"Defend the novelty claims against this prior art:\n{prior_art_text[:3000]}\n\n"
431
+ f"For each novel claim:\n"
432
+ f"1. State the claim\n"
433
+ f"2. List the closest existing system\n"
434
+ f"3. Explain the specific difference\n"
435
+ f"4. Rate novelty strength (incremental/moderate/fundamental)\n\n"
436
+ f"Be honest — if something is incremental, say so. "
437
+ f"Only claim fundamental novelty where the prior art genuinely lacks it."
438
+ )
439
+ s4_text, s4_tokens = llm_generate_long(s4_system, s4_prompt, target_tokens=1500)
440
+
441
+ # Save partial after section 4
442
+ self._save_partial(category, prior_art, s1_text, s2_text, s3_text, s4_text, "")
443
+
444
+ # ─── SECTION 5: IMPLEMENTATION ROADMAP ──────────────────
445
+ print(f"\n 📝 Section 5: Implementation Roadmap", flush=True)
446
+ s5_system = s1_system
447
+ s5_prompt = (
448
+ f"Write the Implementation Roadmap for the proposed system: {category['name']}.\n\n"
449
+ f"Gap: {category['gap']}\n\n"
450
+ f"Include:\n"
451
+ f"1. MVP scope (what to build first)\n"
452
+ f"2. Dependencies (existing tools, libraries, models to use)\n"
453
+ f"3. Evaluation metrics (how to measure success)\n"
454
+ f"4. Risk assessment (what could fail)\n"
455
+ f"5. Timeline (phased approach)\n\n"
456
+ f"Reference specific tools from prior art where applicable:\n{prior_art_text[:1500]}"
457
+ )
458
+ s5_text, s5_tokens = llm_generate_long(s5_system, s5_prompt, target_tokens=1500)
459
+
460
+ # ─── ASSEMBLE ───────────────────────────────────────────
461
+ total_tokens = s1_tokens + s2_tokens + s3_tokens + s4_tokens + s5_tokens
462
+ timestamp = datetime.now().isoformat()
463
+
464
+ draft = f"""# {category['name']}
465
+ ## A Novel System Proposal Backed by Prior Art Analysis
466
+
467
+ *Generated: {timestamp}*
468
+ *Prior art sources: {len(prior_art)} (GitHub, arXiv, Hugging Face)*
469
+ *Total tokens generated: {total_tokens}*
470
+
471
+ ---
472
+
473
+ ## Prior Art Sources
474
+
475
+ {self._format_prior_art_table(prior_art)}
476
+
477
+ ---
478
+
479
+ ## Section 1: Abstract & Problem Statement
480
+
481
+ {s1_text}
482
+
483
+ ---
484
+
485
+ ## Section 2: Related Work
486
+
487
+ {s2_text}
488
+
489
+ ---
490
+
491
+ ## Section 3: Proposed System Architecture
492
+
493
+ {s3_text}
494
+
495
+ ---
496
+
497
+ ## Section 4: Novelty Defense
498
+
499
+ {s4_text}
500
+
501
+ ---
502
+
503
+ ## Section 5: Implementation Roadmap
504
+
505
+ {s5_text}
506
+
507
+ ---
508
+
509
+ ## Appendix: Raw Prior Art Data
510
+
511
+ {json.dumps(prior_art, indent=2)[:5000]}
512
+ """
513
+
514
+ # Save
515
+ filename = f"{category['name'].lower().replace(' ', '_').replace('/', '_')}.md"
516
+ filepath = self.output_dir / filename
517
+ filepath.write_text(draft)
518
+
519
+ print(f"\n{'='*60}", flush=True)
520
+ print(f" DRAFT COMPLETE", flush=True)
521
+ print(f" Sections: 5", flush=True)
522
+ print(f" Total tokens: {total_tokens}", flush=True)
523
+ print(f" Prior art cited: {len(prior_art)}", flush=True)
524
+ print(f" Saved to: {filepath}", flush=True)
525
+ print(f"{'='*60}", flush=True)
526
+
527
+ return filepath, total_tokens
528
+
529
+ def _save_partial(self, category, prior_art, s1, s2, s3, s4, s5):
530
+ """Save partial draft incrementally."""
531
+ filename = f"{category['name'].lower().replace(' ', '_').replace('/', '_')}.md"
532
+ filepath = self.output_dir / filename
533
+ sections = []
534
+ if s1: sections.append(f"## Section 1: Abstract & Problem Statement\n\n{s1}")
535
+ if s2: sections.append(f"## Section 2: Related Work\n\n{s2}")
536
+ if s3: sections.append(f"## Section 3: Proposed System Architecture\n\n{s3}")
537
+ if s4: sections.append(f"## Section 4: Novelty Defense\n\n{s4}")
538
+ if s5: sections.append(f"## Section 5: Implementation Roadmap\n\n{s5}")
539
+
540
+ content = f"# {category['name']}\n## Novel System Proposal (PARTIAL)\n\n---\n\n" + "\n\n---\n\n".join(sections)
541
+ filepath.write_text(content)
542
+
543
+ def _format_prior_art(self, results):
544
+ """Format prior art results as text for LLM context."""
545
+ lines = []
546
+ for r in results:
547
+ if r["source"] == "github":
548
+ lines.append(f"- [GitHub] {r['name']} (★{r['stars']}) — {r['description'][:100] if r['description'] else 'no description'} | {r['url']}")
549
+ elif r["source"] == "arxiv":
550
+ lines.append(f"- [arXiv] \"{r['title']}\" by {', '.join(r['authors'][:2])} — {r['summary'][:100]} | {r['url']}")
551
+ elif r["source"] == "hf_model":
552
+ lines.append(f"- [HF Model] {r['name']} ({r['downloads']} downloads) — tags: {', '.join(r['tags'][:3])} | {r['url']}")
553
+ elif r["source"] == "hf_space":
554
+ lines.append(f"- [HF Space] {r['name']} — {r['url']}")
555
+ return "\n".join(lines[:30]) # cap at 30 sources
556
+
557
+ def _format_prior_art_table(self, results):
558
+ """Format prior art as a markdown table."""
559
+ lines = ["| Source | Name | Details | URL |", "|--------|------|---------|-----|"]
560
+ for r in results[:20]:
561
+ if r["source"] == "github":
562
+ lines.append(f"| GitHub | {r['name']} | ★{r['stars']} — {r['description'][:50] if r['description'] else ''} | {r['url']} |")
563
+ elif r["source"] == "arxiv":
564
+ lines.append(f"| arXiv | {r['title'][:40]} | {r['published'][:10]} | {r['url']} |")
565
+ elif r["source"] == "hf_model":
566
+ lines.append(f"| HF | {r['name']} | {r['downloads']} downloads | {r['url']} |")
567
+ return "\n".join(lines)
568
+
569
+
570
+ # ─── MAIN ─────────────────────────────────────────────────────────────────────
571
+
572
+ def main():
573
+ parser = argparse.ArgumentParser(description="Prior Art Research Generator")
574
+ parser.add_argument("--topic", type=str, default=None,
575
+ help="Specific research topic (default: auto-discover)")
576
+ parser.add_argument("--auto", action="store_true",
577
+ help="Auto-discover the most needed underexplored topic")
578
+ parser.add_argument("--output", type=str, default="./research",
579
+ help="Output directory")
580
+ parser.add_argument("--list", action="store_true",
581
+ help="List all research categories and exit")
582
+ args = parser.parse_args()
583
+
584
+ if args.list:
585
+ print("\nResearch Categories:")
586
+ for i, cat in enumerate(RESEARCH_CATEGORIES, 1):
587
+ print(f"\n {i}. {cat['name']}")
588
+ print(f" {cat['description']}")
589
+ print(f" Gap: {cat['gap']}")
590
+ return
591
+
592
+ researcher = PriorArtResearcher()
593
+ generator = ResearchDraftGenerator(output_dir=args.output)
594
+
595
+ if args.topic:
596
+ # Find matching category or create one
597
+ category = None
598
+ for cat in RESEARCH_CATEGORIES:
599
+ if args.topic.lower() in cat["name"].lower():
600
+ category = cat
601
+ break
602
+ if not category:
603
+ category = {
604
+ "name": args.topic,
605
+ "description": args.topic,
606
+ "gap": f"No comprehensive system exists for {args.topic}",
607
+ "search_terms": [args.topic.lower()],
608
+ }
609
+ prior_art = researcher.research(category)
610
+ else:
611
+ # Auto-discover
612
+ best = researcher.auto_discover()
613
+ category = best["category"]
614
+ prior_art = best["results"]
615
+
616
+ # Generate the research draft
617
+ filepath, tokens = generator.generate_draft(category, prior_art)
618
+
619
+ print(f"\n✅ Research draft saved to: {filepath}", flush=True)
620
+ print(f" Open it to review the novel system proposal.", flush=True)
621
+
622
+
623
+ if __name__ == "__main__":
624
+ main()
src/app/(app)/discovery-ledger/page.tsx ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useState, useEffect, useCallback } from "react";
4
+
5
+ interface FairnessAudit { totalHighUpside: number; builderMissions: number; goldenNodeCredit: number; hoardingDetected: boolean; perEmployee: { employeeId: string; highUpside: number; builder: number; replications: number; usefulFailures: number; goldenNodeCredit: number; }[]; }
6
+ interface ResearchReliability { employeeId: string; level: string; metrics: { executionQuality: number; evidenceQuality: number; completionRate: number; confounderControl: number; derivativeRate: number; }; }
7
+ interface CompetitionRanking { employeeId: string; totalScore: number; categoryWins: Record<string, number>; }
8
+
9
+ export default function DiscoveryLedgerPage() {
10
+ const [audit, setAudit] = useState<FairnessAudit | null>(null);
11
+ const [reliability, setReliability] = useState<ResearchReliability[]>([]);
12
+ const [rankings, setRankings] = useState<CompetitionRanking[]>([]);
13
+ const [loading, setLoading] = useState(true);
14
+ const [error, setError] = useState<string | null>(null);
15
+
16
+ const load = useCallback(async () => {
17
+ try {
18
+ const [aRes, rRes, cRes] = await Promise.all([
19
+ fetch("/api/golden/discovery-ledger?audit=true"),
20
+ fetch("/api/golden/research-reliability"),
21
+ fetch("/api/golden/competition?rank=true"),
22
+ ]);
23
+ const aData = await aRes.json();
24
+ const rData = await rRes.json();
25
+ const cData = await cRes.json();
26
+ setAudit(aData.audit || null);
27
+ setReliability(rData.reliability || []);
28
+ setRankings(cData.rankings || []);
29
+ } catch (e) {
30
+ setError(e instanceof Error ? e.message : "Failed to load");
31
+ } finally {
32
+ setLoading(false);
33
+ }
34
+ }, []);
35
+
36
+ useEffect(() => { load(); }, [load]);
37
+
38
+ if (loading) return <div className="mx-auto max-w-4xl px-8 py-10"><p className="text-muted-foreground">Loading discovery ledger…</p></div>;
39
+ if (error) return <div className="mx-auto max-w-4xl px-8 py-10"><div className="card p-6"><p className="text-status-blocked">{error}</p><button className="btn btn-primary mt-4" onClick={load}>Retry</button></div></div>;
40
+
41
+ return (
42
+ <div className="mx-auto max-w-4xl px-8 py-10">
43
+ <h1 className="text-3xl font-bold tracking-tight text-foreground">History</h1>
44
+ <p className="mt-2 text-muted-foreground">Discovery ledger, fairness audit, research reliability, and competition rankings.</p>
45
+ {audit && (
46
+ <div className="card mt-6 p-5">
47
+ <p className="done-section-label">Fairness audit</p>
48
+ <div className="mt-3 grid grid-cols-2 gap-4 text-sm md:grid-cols-4">
49
+ <div><p className="text-muted-foreground">High-upside total</p><p className="text-lg font-bold text-foreground">{audit.totalHighUpside}</p></div>
50
+ <div><p className="text-muted-foreground">Builder missions</p><p className="text-lg font-bold text-foreground">{audit.builderMissions}</p></div>
51
+ <div><p className="text-muted-foreground">Golden node credit</p><p className="text-lg font-bold text-foreground">{audit.goldenNodeCredit}</p></div>
52
+ <div><p className="text-muted-foreground">Hoarding</p><p className={`text-lg font-bold ${audit.hoardingDetected ? "text-status-blocked" : "text-status-completed"}`}>{audit.hoardingDetected ? "Detected" : "None"}</p></div>
53
+ </div>
54
+ {audit.perEmployee.length > 0 && (
55
+ <table className="mt-4 w-full text-sm">
56
+ <thead><tr className="text-left text-xs text-muted-foreground"><th className="pb-2">Employee</th><th className="pb-2">High-upside</th><th className="pb-2">Builder</th><th className="pb-2">Replications</th><th className="pb-2">Useful failures</th><th className="pb-2">GN credit</th></tr></thead>
57
+ <tbody>
58
+ {audit.perEmployee.map((e) => (<tr key={e.employeeId} className="border-t border-border"><td className="py-2 text-foreground">{e.employeeId}</td><td className="py-2 text-foreground/90">{e.highUpside}</td><td className="py-2 text-foreground/90">{e.builder}</td><td className="py-2 text-foreground/90">{e.replications}</td><td className="py-2 text-foreground/90">{e.usefulFailures}</td><td className="py-2 text-foreground/90">{e.goldenNodeCredit}</td></tr>))}
59
+ </tbody>
60
+ </table>
61
+ )}
62
+ </div>
63
+ )}
64
+ <div className="card mt-4 p-5">
65
+ <p className="done-section-label">Research reliability</p>
66
+ {reliability.length === 0 ? (<p className="mt-3 text-sm text-muted-foreground">No reliability data yet.</p>) : (
67
+ <div className="mt-3 space-y-3">
68
+ {reliability.map((r) => (
69
+ <div key={r.employeeId} className="rounded-lg border border-border p-3">
70
+ <div className="flex items-center justify-between"><p className="font-medium text-foreground">{r.employeeId}</p><span className="rounded-full bg-foreground/10 px-2.5 py-0.5 text-xs font-medium text-foreground">{r.level}</span></div>
71
+ <div className="mt-2 grid grid-cols-2 gap-2 text-xs md:grid-cols-5">
72
+ {Object.entries(r.metrics).map(([k, v]) => (<div key={k}><p className="text-muted-foreground">{k.replace(/([A-Z])/g, " $1").trim()}</p><p className="font-medium text-foreground">{(v * 100).toFixed(0)}%</p></div>))}
73
+ </div>
74
+ </div>
75
+ ))}
76
+ </div>
77
+ )}
78
+ </div>
79
+ <div className="card mt-4 p-5">
80
+ <p className="done-section-label">Research competition</p>
81
+ {rankings.length === 0 ? (<p className="mt-3 text-sm text-muted-foreground">No rankings yet.</p>) : (
82
+ <div className="mt-3 space-y-2">
83
+ {rankings.map((r, i) => (
84
+ <div key={r.employeeId} className="flex items-center justify-between rounded-lg border border-border p-3">
85
+ <div className="flex items-center gap-3">
86
+ <span className="flex h-7 w-7 items-center justify-center rounded-full bg-foreground/10 text-sm font-bold text-foreground">{i + 1}</span>
87
+ <div><p className="font-medium text-foreground">{r.employeeId}</p><div className="mt-0.5 flex flex-wrap gap-1">{Object.entries(r.categoryWins).filter(([, v]) => v > 0).map(([k, v]) => (<span key={k} className="rounded-full border border-border px-2 py-0.5 text-[10px] text-muted-foreground">{k.replace(/_/g, " ")}: {v}</span>))}</div></div>
88
+ </div>
89
+ <p className="text-lg font-bold text-foreground">{r.totalScore.toFixed(1)}</p>
90
+ </div>
91
+ ))}
92
+ </div>
93
+ )}
94
+ </div>
95
+ </div>
96
+ );
97
+ }
src/app/(app)/experiment/page.tsx ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ import { redirect } from "next/navigation";
2
+ export default function ExperimentPage() { redirect("/experiments"); }
src/app/(app)/experiments/page.tsx ADDED
@@ -0,0 +1,140 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useState, useEffect, useCallback } from "react";
4
+ import type { HypothesisAnatomy, HypothesisAssignment, SuccessKind } from "@/types";
5
+
6
+ const EMPLOYEE_ID = "emp-001";
7
+
8
+ export default function ExperimentsPage() {
9
+ const [assignments, setAssignments] = useState<HypothesisAssignment[]>([]);
10
+ const [hypotheses, setHypotheses] = useState<Map<string, HypothesisAnatomy>>(new Map());
11
+ const [loading, setLoading] = useState(true);
12
+ const [error, setError] = useState<string | null>(null);
13
+ const [recordingFor, setRecordingFor] = useState<string | null>(null);
14
+ const [form, setForm] = useState({
15
+ successKind: "performance" as SuccessKind,
16
+ outcomeDescription: "",
17
+ falsified: false,
18
+ falsificationEvidence: "",
19
+ metricName: "",
20
+ metricValue: 0,
21
+ metricBaseline: 0,
22
+ metricUnit: "",
23
+ higherIsBetter: true,
24
+ });
25
+ const [submitting, setSubmitting] = useState(false);
26
+ const [lastResult, setLastResult] = useState<string | null>(null);
27
+ const [useLLM, setUseLLM] = useState(true);
28
+
29
+ const load = useCallback(async () => {
30
+ try {
31
+ const [aRes, hRes] = await Promise.all([
32
+ fetch(`/api/golden/assignments?employeeId=${EMPLOYEE_ID}`),
33
+ fetch("/api/golden/hypotheses"),
34
+ ]);
35
+ const aData = await aRes.json();
36
+ const hData = await hRes.json();
37
+ setAssignments(aData.assignments || []);
38
+ setHypotheses(new Map((hData.hypotheses || []).map((h: HypothesisAnatomy) => [h.id, h])));
39
+ } catch (e) {
40
+ setError(e instanceof Error ? e.message : "Failed to load");
41
+ } finally {
42
+ setLoading(false);
43
+ }
44
+ }, []);
45
+
46
+ useEffect(() => { load(); }, [load]);
47
+
48
+ async function submitOutcome(assignmentId: string) {
49
+ setSubmitting(true);
50
+ setLastResult(null);
51
+ try {
52
+ const metrics = form.metricName
53
+ ? [{ metric: form.metricName, value: Number(form.metricValue), unit: form.metricUnit, baseline: Number(form.metricBaseline), higherIsBetter: form.higherIsBetter }]
54
+ : [];
55
+ const res = await fetch("/api/golden/outcomes", {
56
+ method: "POST",
57
+ headers: { "Content-Type": "application/json" },
58
+ body: JSON.stringify({
59
+ assignmentId, successKind: form.successKind, outcomeDescription: form.outcomeDescription,
60
+ metrics, falsified: form.falsified, falsificationEvidence: form.falsificationEvidence || undefined, useLLM,
61
+ }),
62
+ });
63
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
64
+ const data = await res.json();
65
+ const llmTag = data.llmUsed ? " [LLM]" : " [deterministic]";
66
+ setLastResult(
67
+ `Recorded${llmTag}. Attribution: ${data.attribution ? data.attribution.responsibleFactor.replace(/_/g, " ") + ` (confidence ${(data.attribution.attributionConfidence * 100).toFixed(0)}%)` : "pending"}. Derivatives generated: ${data.derivatives?.length || 0}.`
68
+ );
69
+ setRecordingFor(null);
70
+ setForm({ successKind: "performance", outcomeDescription: "", falsified: false, falsificationEvidence: "", metricName: "", metricValue: 0, metricBaseline: 0, metricUnit: "", higherIsBetter: true });
71
+ await load();
72
+ } catch (e) {
73
+ setError(e instanceof Error ? e.message : "Recording failed");
74
+ } finally {
75
+ setSubmitting(false);
76
+ }
77
+ }
78
+
79
+ if (loading) return <div className="mx-auto max-w-4xl px-8 py-10"><p className="text-muted-foreground">Loading experiments…</p></div>;
80
+ if (error) return <div className="mx-auto max-w-4xl px-8 py-10"><div className="card p-6"><p className="text-status-blocked">{error}</p><button className="btn btn-primary mt-4" onClick={load}>Retry</button></div></div>;
81
+
82
+ const active = assignments.filter((a) => !["falsified", "validated", "scaled", "productized", "channel", "rejected", "completed"].includes(a.state));
83
+
84
+ return (
85
+ <div className="mx-auto max-w-4xl px-8 py-10">
86
+ <h1 className="text-3xl font-bold tracking-tight text-foreground">Experiments</h1>
87
+ <p className="mt-2 text-muted-foreground">Record outcomes from your active hypothesis missions.</p>
88
+
89
+ {lastResult && (
90
+ <div className="mt-4 rounded-lg border border-status-completed/30 bg-status-completed/10 p-4 text-sm text-foreground/90">{lastResult}</div>
91
+ )}
92
+
93
+ {active.length === 0 ? (
94
+ <div className="card mt-6 p-8 text-center"><p className="text-muted-foreground">No active experiments. Accept a hypothesis from Today's Research.</p></div>
95
+ ) : (
96
+ <div className="mt-6 space-y-4">
97
+ {active.map((a) => {
98
+ const h = hypotheses.get(a.hypothesisId);
99
+ return (
100
+ <div key={a.id} className="card p-5">
101
+ <div className="flex items-start justify-between">
102
+ <div>
103
+ <p className="font-medium text-foreground">{h?.claim || a.hypothesisId}</p>
104
+ <p className="mt-1 text-xs text-muted-foreground">{a.kind} · {a.state} · Trial {a.trialNumber} · {a.evaluationPeriodDays}d window · {a.eligibleAccountIds.length} accounts</p>
105
+ </div>
106
+ <button onClick={() => { setRecordingFor(recordingFor === a.id ? null : a.id); setLastResult(null); }} className="btn btn-primary">{recordingFor === a.id ? "Cancel" : "Record outcome"}</button>
107
+ </div>
108
+ {recordingFor === a.id && (
109
+ <div className="mt-4 space-y-3 border-t border-border pt-4">
110
+ <label className="block">
111
+ <span className="done-section-label">Success kind</span>
112
+ <select className="mt-1 w-full rounded-lg border border-border bg-background px-3 py-2 text-sm" value={form.successKind} onChange={(e) => setForm({ ...form, successKind: e.target.value as SuccessKind })}>
113
+ {["performance", "efficiency", "discovery", "boundary", "system", "channel", "falsification"].map((k) => (<option key={k} value={k}>{k}</option>))}
114
+ </select>
115
+ </label>
116
+ <label className="block">
117
+ <span className="done-section-label">Outcome description</span>
118
+ <textarea className="mt-1 w-full rounded-lg border border-border bg-background px-3 py-2 text-sm" rows={3} placeholder="What happened? What did you observe?" value={form.outcomeDescription} onChange={(e) => setForm({ ...form, outcomeDescription: e.target.value })} />
119
+ </label>
120
+ <div className="grid grid-cols-2 gap-3">
121
+ <label className="block"><span className="done-section-label">Metric name</span><input className="mt-1 w-full rounded-lg border border-border bg-background px-3 py-2 text-sm" placeholder="e.g. completion_rate" value={form.metricName} onChange={(e) => setForm({ ...form, metricName: e.target.value })} /></label>
122
+ <label className="block"><span className="done-section-label">Unit</span><input className="mt-1 w-full rounded-lg border border-border bg-background px-3 py-2 text-sm" placeholder="e.g. pct" value={form.metricUnit} onChange={(e) => setForm({ ...form, metricUnit: e.target.value })} /></label>
123
+ <label className="block"><span className="done-section-label">Value</span><input type="number" className="mt-1 w-full rounded-lg border border-border bg-background px-3 py-2 text-sm" value={form.metricValue} onChange={(e) => setForm({ ...form, metricValue: Number(e.target.value) })} /></label>
124
+ <label className="block"><span className="done-section-label">Baseline</span><input type="number" className="mt-1 w-full rounded-lg border border-border bg-background px-3 py-2 text-sm" value={form.metricBaseline} onChange={(e) => setForm({ ...form, metricBaseline: Number(e.target.value) })} /></label>
125
+ </div>
126
+ <label className="flex items-center gap-2 text-sm text-muted-foreground"><input type="checkbox" checked={form.higherIsBetter} onChange={(e) => setForm({ ...form, higherIsBetter: e.target.checked })} />Higher is better</label>
127
+ <label className="flex items-center gap-2 text-sm text-muted-foreground"><input type="checkbox" checked={form.falsified} onChange={(e) => setForm({ ...form, falsified: e.target.checked })} />This falsifies the hypothesis (useful failure)</label>
128
+ {form.falsified && (<input className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm" placeholder="Falsification evidence" value={form.falsificationEvidence} onChange={(e) => setForm({ ...form, falsificationEvidence: e.target.value })} />)}
129
+ <label className="flex items-center gap-2 text-sm text-muted-foreground"><input type="checkbox" checked={useLLM} onChange={(e) => setUseLLM(e.target.checked)} />Use LLM for attribution & derivatives (falls back to deterministic if unavailable)</label>
130
+ <button onClick={() => submitOutcome(a.id)} disabled={submitting || !form.outcomeDescription} className="btn btn-primary disabled:opacity-50">{submitting ? "Recording…" : "Submit & attribute"}</button>
131
+ </div>
132
+ )}
133
+ </div>
134
+ );
135
+ })}
136
+ </div>
137
+ )}
138
+ </div>
139
+ );
140
+ }
src/app/(app)/foundry/page.tsx ADDED
@@ -0,0 +1,279 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useMemo, useState } from "react";
4
+ import type {
5
+ SpinorOrganism,
6
+ SpinorOrganismNode,
7
+ SpinorSignatureAction,
8
+ SpinorMaturityStage,
9
+ SpinorEvidenceBadge,
10
+ } from "@/types";
11
+ import {
12
+ STAGE_LABEL,
13
+ STAGE_GLYPH,
14
+ EVIDENCE_LABEL,
15
+ ACTION_LABEL,
16
+ } from "@/lib/spinor/scoring";
17
+
18
+ const EMPLOYEE_ID = "emp-001";
19
+
20
+ const COLOR_HEX: Record<SpinorOrganismNode["color"], string> = {
21
+ blue: "#38bdf8", violet: "#a78bfa", green: "#34d399", gold: "#fbbf24", red: "#f87171", gray: "#94a3b8",
22
+ };
23
+
24
+ const STAGE_COLOR: Record<SpinorMaturityStage, string> = {
25
+ seed: "#94a3b8", sprout: "#34d399", branch: "#a78bfa", grove: "#38bdf8", golden_node: "#fbbf24", infrastructure: "#f59e0b", spinout: "#fb923c",
26
+ };
27
+
28
+ const ROLE_LABEL: Record<SpinorOrganismNode["role"], string> = {
29
+ core: "Core hypothesis", supporting_research: "Supporting research", contradicting: "Contradicting evidence",
30
+ previous_attempt: "Previous attempt", derivative: "Derivative", replication: "Replication (root)",
31
+ risk_signal: "Risk signal", expected_value: "Expected value (fruit)", golden_node: "Golden Node", compost: "Compost",
32
+ };
33
+
34
+ const ACTION_ENDPOINT: Record<SpinorSignatureAction, "accept" | "reject" | "modify"> = {
35
+ plant: "accept", observe: "accept", record: "accept", challenge: "reject",
36
+ replicate: "accept", derive: "modify", integrate: "accept", spin_out: "accept",
37
+ };
38
+
39
+ export default function FoundryPage() {
40
+ const [organism, setOrganism] = useState<SpinorOrganism | null>(null);
41
+ const [loading, setLoading] = useState(true);
42
+ const [error, setError] = useState<string | null>(null);
43
+ const [acting, setActing] = useState<SpinorSignatureAction | null>(null);
44
+ const [flash, setFlash] = useState<string | null>(null);
45
+ const [selectedNode, setSelectedNode] = useState<SpinorOrganismNode | null>(null);
46
+ const [priorArt, setPriorArt] = useState<{ id: string; hypothesisClaim: string; status: string; evidenceState: string; adjacentSupportSummary: string; sourceDomains: string[]; researchConfidence: number } | null>(null);
47
+ const [derivatives, setDerivatives] = useState<{ id: string; claim: string; modifiedDimension: string; rationale: string; status: string; origin: string }[]>([]);
48
+
49
+ const load = useCallback(async () => {
50
+ try {
51
+ const [res, paRes, derRes] = await Promise.all([
52
+ fetch(`/api/spinor/organism?employeeId=${EMPLOYEE_ID}`, { cache: "no-store" }),
53
+ fetch("/api/golden/prior-art", { cache: "no-store" }),
54
+ fetch("/api/golden/derivatives", { cache: "no-store" }),
55
+ ]);
56
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
57
+ const data = await res.json();
58
+ if (!data.organism) throw new Error("No organism available.");
59
+ setOrganism(data.organism as SpinorOrganism);
60
+ setSelectedNode(null);
61
+ const paData = await paRes.json();
62
+ const paList: { id: string; hypothesisClaim: string; status: string; evidenceState: string; adjacentSupportSummary: string; sourceDomains: string[]; researchConfidence: number }[] = paData.priorArt || [];
63
+ const paRefId = (data.organism.nodes as SpinorOrganismNode[]).find((n: SpinorOrganismNode) => n.role === "supporting_research")?.refId;
64
+ setPriorArt(paList.find((p) => p.id === paRefId) ?? paList[0] ?? null);
65
+ const derData = await derRes.json();
66
+ const derList: { id: string; claim: string; modifiedDimension: string; rationale: string; status: string; origin: string }[] = derData.derivatives || [];
67
+ setDerivatives(derList);
68
+ } catch (e) {
69
+ setError(e instanceof Error ? e.message : "Failed to load organism.");
70
+ } finally {
71
+ setLoading(false);
72
+ }
73
+ }, []);
74
+
75
+ useEffect(() => { load(); }, [load]);
76
+
77
+ async function performAction(action: SpinorSignatureAction) {
78
+ if (!organism) return;
79
+ setActing(action);
80
+ setFlash(null);
81
+ try {
82
+ const endpoint = ACTION_ENDPOINT[action];
83
+ const body: Record<string, unknown> = { action: endpoint, assignmentId: organism.assignmentId };
84
+ if (action === "derive") { body.dimension = "content_sequence"; body.rationale = `Derivative proposed from ${organism.hypothesisId} via SPINOR Derive action.`; }
85
+ if (action === "challenge") { body.note = `Challenged via SPINOR: ${organism.falsificationCondition}`; }
86
+ const res = await fetch("/api/golden/assignments", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
87
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
88
+ setFlash(`${ACTION_LABEL[action]} recorded. The organism is evolving.`);
89
+ await load();
90
+ } catch (e) {
91
+ setError(e instanceof Error ? e.message : `${ACTION_LABEL[action]} failed.`);
92
+ } finally {
93
+ setActing(null);
94
+ }
95
+ }
96
+
97
+ const canvas = useMemo(() => {
98
+ if (!organism) return null;
99
+ const W = 640, H = 640, cx = W / 2, cy = H / 2, ringScale = 110;
100
+ const placed = organism.nodes.map((n) => { const r = n.radius * ringScale; return { ...n, x: cx + Math.cos(n.angle) * r, y: cy + Math.sin(n.angle) * r }; });
101
+ return { W, H, cx, cy, placed };
102
+ }, [organism]);
103
+
104
+ if (loading) return <div className="mx-auto flex max-w-6xl flex-col items-center justify-center px-8 py-20"><div className="animate-pulse text-muted-foreground">Building your Hypothesis Organism…</div></div>;
105
+ if (error || !organism) return <div className="mx-auto max-w-6xl px-8 py-20 text-center"><p className="text-status-blocked">{error || "No organism available."}</p><button className="btn btn-primary mt-4" onClick={load}>Retry</button></div>;
106
+
107
+ const coreColor = STAGE_COLOR[organism.maturity];
108
+
109
+ return (
110
+ <div className="mx-auto max-w-6xl px-6 py-8">
111
+ <div className="grid grid-cols-1 gap-4 md:grid-cols-3">
112
+ <div className="card p-5">
113
+ <h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Today's Hypothesis</h2>
114
+ <p className="mt-2 text-sm font-medium leading-snug text-foreground">{organism.claim}</p>
115
+ <div className="mt-3 flex items-center gap-2">
116
+ <span className="rounded-full px-2.5 py-0.5 text-xs font-semibold text-white" style={{ backgroundColor: coreColor }}>{STAGE_LABEL[organism.maturity]}</span>
117
+ <span className="rounded-full px-2.5 py-0.5 text-xs font-medium" style={{ backgroundColor: COLOR_HEX.blue + "20" }}>{EVIDENCE_LABEL[organism.evidence]}</span>
118
+ </div>
119
+ </div>
120
+ <div className="card p-5">
121
+ <h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Evidence Required</h2>
122
+ <p className="mt-2 text-sm text-foreground/90">{organism.trialsCompleted} / {organism.requiredTrials} trials completed</p>
123
+ <div className="evidence-bar mt-2"><div className="evidence-bar-fill" style={{ width: `${Math.min(100, (organism.trialsCompleted / organism.requiredTrials) * 100)}%`, background: COLOR_HEX.green }} /></div>
124
+ <p className="mt-2 text-xs text-muted-foreground">Falsification: {organism.falsificationCondition}</p>
125
+ </div>
126
+ <div className="card p-5">
127
+ <h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Evolutionary Paths</h2>
128
+ <div className="mt-2 space-y-1.5">
129
+ {organism.actions.map((a) => (<div key={a} className="flex items-center gap-2 text-xs text-foreground/90"><span className="h-1.5 w-1.5 rounded-full" style={{ background: coreColor }} />{ACTION_LABEL[a]}</div>))}
130
+ </div>
131
+ </div>
132
+ </div>
133
+
134
+ <div className="mt-8 grid grid-cols-1 gap-6 lg:grid-cols-[1fr_320px]">
135
+ <section className="card p-5">
136
+ <h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Organism Canvas</h2>
137
+ {canvas && (
138
+ <svg viewBox={`0 0 ${canvas.W} ${canvas.H}`} className="mt-4 w-full" style={{ maxHeight: "640px" }}>
139
+ <defs>
140
+ <radialGradient id="coreGlow"><stop offset="0%" stopColor={coreColor} stopOpacity="0.3" /><stop offset="100%" stopColor={coreColor} stopOpacity="0" /></radialGradient>
141
+ <filter id="soft"><feGaussianBlur stdDeviation="1" /></filter>
142
+ </defs>
143
+ {[1, 2, 3].map((ring) => (<circle key={ring} cx={canvas.cx} cy={canvas.cy} r={ring * 110} fill="none" stroke="currentColor" strokeWidth="0.5" className="text-border" opacity="0.2" />))}
144
+ {canvas.placed.filter((n) => n.role !== "core").map((n) => {
145
+ const color = COLOR_HEX[n.color]; const isRoot = n.role === "replication"; const isCompost = n.role === "compost";
146
+ const mx = (canvas.cx + n.x) / 2 + (isRoot ? -40 : 20) * Math.sin(n.angle);
147
+ const my = (canvas.cy + n.y) / 2 + (isRoot ? -40 : 20) * Math.cos(n.angle);
148
+ const d = `M ${canvas.cx} ${canvas.cy} Q ${mx} ${my} ${n.x} ${n.y}`;
149
+ return <path key={`stem-${n.id}`} d={d} fill="none" stroke={color} strokeWidth={isRoot ? 2.5 : 1.5} strokeOpacity={isCompost ? 0.25 : 0.5} strokeDasharray={isRoot ? "2 5" : isCompost ? "1 6" : undefined} className={isRoot ? "spinor-root" : "spinor-stem"} />;
150
+ })}
151
+ <circle cx={canvas.cx} cy={canvas.cy} r="90" fill="url(#coreGlow)" />
152
+ {canvas.placed.map((n) => {
153
+ const color = COLOR_HEX[n.color]; const isGold = n.role === "golden_node"; const r = isGold ? 16 : n.role === "core" ? 0 : 10;
154
+ return (
155
+ <g key={n.id} className="spinor-node" style={{ cursor: "pointer" }} onClick={() => setSelectedNode(n)}>
156
+ {isGold && <circle cx={n.x} cy={n.y} r={r + 8} fill={color} opacity="0.18" className="spinor-node-pulse" />}
157
+ {n.pulse && <circle cx={n.x} cy={n.y} r={r + 6} fill="none" stroke={color} strokeWidth="1.5" opacity="0.5" className="spinor-node-pulse" />}
158
+ <circle cx={n.x} cy={n.y} r={r} fill={color} fillOpacity={selectedNode?.id === n.id ? 0.95 : 0.7} stroke={color} strokeWidth="2" filter="url(#soft)" />
159
+ <circle cx={n.x} cy={n.y} r={r} fill={color} />
160
+ <text x={n.x} y={n.y + r + 16} textAnchor="middle" className="spinor-node-label" fill="hsl(var(--muted-foreground))">{ROLE_LABEL[n.role]}</text>
161
+ </g>
162
+ );
163
+ })}
164
+ <g>
165
+ <circle cx={canvas.cx} cy={canvas.cy} r="34" fill={coreColor} fillOpacity="0.18" stroke={coreColor} strokeWidth="2" />
166
+ <circle cx={canvas.cx} cy={canvas.cy} r="22" fill={coreColor} fillOpacity="0.3" className="spinor-core-pulse" />
167
+ <text x={canvas.cx} y={canvas.cy + 5} textAnchor="middle" fontSize="20" fill={coreColor} fontWeight="700">{STAGE_GLYPH[organism.maturity]}</text>
168
+ </g>
169
+ </svg>
170
+ )}
171
+ <div className="mt-4 px-4">
172
+ <p className="text-base font-semibold leading-snug text-foreground">{organism.claim}</p>
173
+ <p className="mt-2 text-sm text-muted-foreground"><span className="font-medium text-foreground/80">Why this reached you: </span>{organism.allocationReason}</p>
174
+ </div>
175
+ {selectedNode && (
176
+ <div className="mt-4 animate-fade-in-up rounded-xl border border-border bg-muted/30 p-4 text-sm">
177
+ <div className="flex items-center justify-between">
178
+ <p className="font-semibold" style={{ color: COLOR_HEX[selectedNode.color] }}>{ROLE_LABEL[selectedNode.role]}</p>
179
+ <button className="text-xs text-muted-foreground hover:text-foreground" onClick={() => setSelectedNode(null)}>close</button>
180
+ </div>
181
+ <p className="mt-1 text-foreground/90">{selectedNode.detail}</p>
182
+ <p className="mt-2 text-xs text-muted-foreground">Evidence: {EVIDENCE_LABEL[selectedNode.evidence]} · Maturity: {STAGE_LABEL[selectedNode.maturity]}</p>
183
+ </div>
184
+ )}
185
+ </section>
186
+
187
+ <aside className="space-y-5">
188
+ <div className="card p-5">
189
+ <div className="flex items-baseline justify-between">
190
+ <h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Discovery Contribution Score</h2>
191
+ {organism.dcs.provisional && <span className="text-[10px] font-semibold uppercase tracking-wider text-violet-400">provisional</span>}
192
+ </div>
193
+ <div className="mt-3 flex items-end gap-2"><span className="text-5xl font-bold tabular-nums text-foreground">{organism.dcs.score}</span><span className="mb-1 text-sm text-muted-foreground">/ 100</span></div>
194
+ <div className="mt-1 text-xs text-muted-foreground">DCS = (I × C × R × V × T) ÷ H · confidence {Math.round(organism.dcs.scoreConfidence * 100)}%</div>
195
+ <div className="mt-4 space-y-2">
196
+ {organism.dcs.components.map((c) => (
197
+ <div key={c.symbol}>
198
+ <div className="flex items-center justify-between text-xs"><span className="font-medium text-foreground/80"><span className="text-foreground">{c.symbol}</span> · {c.name}</span><span className="tabular-nums text-muted-foreground">{Math.round(c.value * 100)}</span></div>
199
+ <div className="evidence-bar mt-1"><div className="evidence-bar-fill" style={{ width: `${Math.round(c.value * 100)}%`, background: c.symbol === "H" ? COLOR_HEX.red : COLOR_HEX.blue }} /></div>
200
+ <p className="mt-0.5 text-[11px] text-muted-foreground">{c.rationale}</p>
201
+ </div>
202
+ ))}
203
+ </div>
204
+ </div>
205
+
206
+ <div className="card p-5">
207
+ <h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Experiment design</h2>
208
+ <dl className="mt-3 space-y-2 text-sm">
209
+ <div className="flex justify-between"><dt className="text-muted-foreground">Trials completed</dt><dd className="font-medium tabular-nums text-foreground">{organism.trialsCompleted} / {organism.requiredTrials}</dd></div>
210
+ <div className="evidence-bar"><div className="evidence-bar-fill" style={{ width: `${Math.min(100, (organism.trialsCompleted / organism.requiredTrials) * 100)}%`, background: COLOR_HEX.green }} /></div>
211
+ <div className="flex justify-between pt-1"><dt className="text-muted-foreground">Time window</dt><dd className="font-medium text-foreground">{organism.timeWindowDays} days</dd></div>
212
+ </dl>
213
+ <div className="mt-3 rounded-lg border border-border bg-muted/30 p-3 text-xs"><p className="font-semibold text-red-400/90">Compliance boundary</p><p className="mt-1 text-foreground/80">{organism.complianceBoundary}</p></div>
214
+ <div className="mt-2 rounded-lg border border-border bg-muted/30 p-3 text-xs"><p className="font-semibold text-violet-400/90">Falsification condition</p><p className="mt-1 text-foreground/80">{organism.falsificationCondition}</p></div>
215
+ </div>
216
+
217
+ <div className="card p-5">
218
+ <h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Signature actions</h2>
219
+ <p className="mt-1 text-xs text-muted-foreground">You do not complete tasks. You cultivate evidence.</p>
220
+ <div className="mt-3 grid grid-cols-2 gap-2">
221
+ {organism.actions.map((a) => (<button key={a} disabled={acting !== null} onClick={() => performAction(a)} className="btn btn-outline h-9 px-3 text-xs disabled:opacity-40">{acting === a ? "…" : ACTION_LABEL[a]}</button>))}
222
+ </div>
223
+ {flash && <p className="mt-3 animate-fade-in-up text-xs font-medium text-green-400">{flash}</p>}
224
+ {error && <p className="mt-3 text-xs text-red-400">{error}</p>}
225
+ </div>
226
+ </aside>
227
+ </div>
228
+
229
+ <section className="mt-8 grid grid-cols-1 gap-6 md:grid-cols-2">
230
+ <div className="card p-5">
231
+ <h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Prior art</h2>
232
+ {priorArt ? (
233
+ <div className="mt-3 space-y-2 text-sm">
234
+ <p className="text-foreground/90">{priorArt.hypothesisClaim}</p>
235
+ <div className="flex flex-wrap gap-2 text-xs">
236
+ <span className="badge border-border text-muted-foreground">{priorArt.status.replace(/_/g, " ")}</span>
237
+ <span className="badge border-border text-muted-foreground">evidence: {priorArt.evidenceState}</span>
238
+ <span className="badge border-border text-muted-foreground">confidence {Math.round(priorArt.researchConfidence * 100)}%</span>
239
+ </div>
240
+ <p className="text-xs text-muted-foreground">{priorArt.adjacentSupportSummary}</p>
241
+ <p className="text-[11px] text-muted-foreground">Sources: {priorArt.sourceDomains.join(", ")}</p>
242
+ </div>
243
+ ) : <p className="mt-3 text-sm text-muted-foreground">No prior-art record linked to this hypothesis.</p>}
244
+ </div>
245
+
246
+ <div className="card p-5">
247
+ <h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Novelty</h2>
248
+ <p className="mt-3 text-sm text-foreground/90">The genuinely new element: <span className="font-medium text-violet-400">{organism.nodes.find((n) => n.role === "expected_value")?.detail ?? "to be isolated by Dissect → Novelty Magnify"}</span></p>
249
+ <p className="mt-2 text-xs text-muted-foreground">DCS Novelty (V) = {Math.round((organism.dcs.components.find((c) => c.symbol === "V")?.value ?? 0) * 100)}%. Duplicates and near-duplicates receive reduced credit.</p>
250
+ </div>
251
+
252
+ <div className="card p-5">
253
+ <h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Variations</h2>
254
+ {derivatives.length > 0 ? (
255
+ <ul className="mt-3 space-y-2 text-sm">
256
+ {derivatives.slice(0, 5).map((d) => (<li key={d.id} className="rounded-lg border border-border bg-muted/20 p-3"><p className="text-foreground/90">{d.claim}</p><p className="mt-1 text-[11px] text-muted-foreground">dimension: {d.modifiedDimension.replace(/_/g, " ")} · origin: {d.origin.replace(/_/g, " ")} · status: {d.status}</p></li>))}
257
+ </ul>
258
+ ) : <p className="mt-3 text-sm text-muted-foreground">No derivatives yet. Use <span className="font-medium text-foreground">Derive</span> to branch this hypothesis.</p>}
259
+ </div>
260
+
261
+ <div className="card p-5">
262
+ <h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">Mutation controls</h2>
263
+ <p className="mt-3 text-xs text-muted-foreground">Dimensions an employee may modify within their innovation window.</p>
264
+ <div className="mt-3 flex flex-wrap gap-2">
265
+ {["stakeholder", "timing", "channel", "content_sequence", "automation_step", "followup_interval"].map((dim) => (<span key={dim} className="badge border-violet-500/30 bg-violet-500/10 text-violet-300">{dim.replace(/_/g, " ")}</span>))}
266
+ </div>
267
+ <p className="mt-3 text-[11px] text-muted-foreground">Fixed constraints and compliance boundaries cannot be mutated. Mutations are versioned — the parent hypothesis is preserved.</p>
268
+ </div>
269
+ </section>
270
+
271
+ <footer className="mt-6 flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
272
+ <span className="font-semibold uppercase tracking-wider">Color system:</span>
273
+ {[["blue", "Supported"], ["violet", "Novelty"], ["green", "Replicated"], ["gold", "Golden Node"], ["red", "Risk / contradiction"], ["gray", "Unresolved"]].map(([c, label]) => (
274
+ <span key={c} className="inline-flex items-center gap-1.5"><span className="inline-block h-2.5 w-2.5 rounded-full" style={{ background: COLOR_HEX[c as SpinorOrganismNode["color"]] }} />{label}</span>
275
+ ))}
276
+ </footer>
277
+ </div>
278
+ );
279
+ }
src/app/(app)/golden-nodes/page.tsx ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import { useState, useEffect, useCallback } from "react";
3
+ import type { GoldenNode } from "@/types";
4
+ const STAGES = ["hypothesis", "local_success", "rep_owned_process", "replicated_method", "organizational_capability", "productized_service", "independent_channel"];
5
+ export default function GoldenNodesPage() {
6
+ const [nodes, setNodes] = useState<GoldenNode[]>([]);
7
+ const [ledger, setLedger] = useState<any[]>([]);
8
+ const [loading, setLoading] = useState(true);
9
+ const [error, setError] = useState<string | null>(null);
10
+ const [promoting, setPromoting] = useState<string | null>(null);
11
+ const [channelName, setChannelName] = useState("");
12
+ const load = useCallback(async () => { try { const res = await fetch("/api/golden/golden-nodes?ledger=true"); const data = await res.json(); setNodes(data.goldenNodes || []); setLedger(data.ledger || []); } catch (e) { setError(e instanceof Error ? e.message : "Failed to load"); } finally { setLoading(false); } }, []);
13
+ useEffect(() => { load(); }, [load]);
14
+ async function promote(id: string, toStage: string) { setPromoting(id); try { const body: Record<string, unknown> = { id, toStage }; if (toStage === "independent_channel" && channelName) body.channelName = channelName; const res = await fetch("/api/golden/golden-nodes", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); if (!res.ok) throw new Error(`HTTP ${res.status}`); setChannelName(""); await load(); } catch (e) { setError(e instanceof Error ? e.message : "Promotion failed"); } finally { setPromoting(null); } }
15
+ if (loading) return <div className="mx-auto max-w-4xl px-8 py-10"><p className="text-muted-foreground">Loading Golden Nodes…</p></div>;
16
+ if (error) return <div className="mx-auto max-w-4xl px-8 py-10"><div className="card p-6"><p className="text-status-blocked">{error}</p><button className="btn btn-primary mt-4" onClick={load}>Retry</button></div></div>;
17
+ return (
18
+ <div className="mx-auto max-w-4xl px-8 py-10">
19
+ <h1 className="text-3xl font-bold tracking-tight text-foreground">Golden Nodes</h1>
20
+ <p className="mt-2 text-muted-foreground">Validated strategy lineages — methods that have been replicated and promoted into reusable systems.</p>
21
+ {nodes.length === 0 ? <div className="card mt-6 p-8 text-center"><p className="text-muted-foreground">No Golden Nodes yet. Record outcomes and replicate successful hypotheses to earn promotion.</p></div> : (
22
+ <div className="mt-6 space-y-4">
23
+ {nodes.map((gn) => { const stageIdx = STAGES.indexOf(gn.stage); const ledgerEntry = ledger.find((l) => l.goldenNodeId === gn.id); return (
24
+ <div key={gn.id} className="card p-5">
25
+ <div className="flex items-start justify-between"><div className="flex-1"><p className="font-medium text-foreground">{gn.claim}</p><p className="mt-1 text-xs text-muted-foreground">{gn.id} · {gn.stage.replace(/_/g, " ")}</p></div><div className="text-right"><p className="text-lg font-bold text-status-completed">${gn.economicValue.toLocaleString()}</p><p className="text-xs text-muted-foreground">economic value</p></div></div>
26
+ <p className="mt-3 text-sm text-muted-foreground">{gn.observedResult}</p>
27
+ <div className="mt-4 flex items-center gap-1">{STAGES.map((s, i) => (<div key={s} className="flex items-center"><div className={`h-2.5 w-2.5 rounded-full ${i <= stageIdx ? "bg-status-completed" : "bg-muted"}`} />{i < STAGES.length - 1 && <div className={`h-0.5 w-6 ${i < stageIdx ? "bg-status-completed" : "bg-muted"}`} />}</div>))}</div>
28
+ <div className="mt-4 grid grid-cols-2 gap-2 text-xs md:grid-cols-3">
29
+ {[["Measurable effect", gn.measurableEffect],["Repeatability", gn.repeatability],["Portability", gn.portability],["Defensible mechanism", gn.defensibleMechanism],["Reusable process", gn.reusableProcess],["Economic value", gn.economicValue > 0]].map(([label, ok]) => (<div key={label as string} className={`rounded-lg border px-2 py-1 ${ok ? "border-status-completed/30 text-status-completed" : "border-border text-muted-foreground"}`}>{ok ? "✓" : "·"} {label}</div>))}
30
+ </div>
31
+ <div className="mt-3 text-sm"><p className="done-section-label">Primary mechanism</p><p className="mt-0.5 text-foreground/90">{gn.primaryMechanism}</p></div>
32
+ <div className="mt-2 text-sm"><p className="done-section-label">Replication</p><p className="mt-0.5 text-foreground/90">{gn.replicationCount} replications across {gn.replicationTerritories.length} territories</p></div>
33
+ {ledgerEntry && <div className="mt-3 rounded-lg border border-border bg-muted/20 p-3 text-xs"><p className="done-section-label">Attribution ledger</p><p className="mt-1 text-muted-foreground">Origin: {ledgerEntry.originalHypothesisSource} · Confidence: {(ledgerEntry.attributionConfidence * 100).toFixed(0)}% · Value created: ${ledgerEntry.economicValueCreated.toLocaleString()}</p>{ledgerEntry.recognition && <p className="mt-1 text-muted-foreground">Recognition: {ledgerEntry.recognition.join(", ")}</p>}</div>}
34
+ {stageIdx < STAGES.length - 1 && <div className="mt-4 flex items-center gap-2"><button onClick={() => promote(gn.id, STAGES[stageIdx + 1])} disabled={promoting === gn.id} className="btn btn-primary disabled:opacity-50">{promoting === gn.id ? "Promoting…" : `Promote to ${STAGES[stageIdx + 1].replace(/_/g, " ")}`}</button>{STAGES[stageIdx + 1] === "independent_channel" && <input className="rounded-lg border border-border bg-background px-3 py-2 text-sm" placeholder="Channel name" value={channelName} onChange={(e) => setChannelName(e.target.value)} />}</div>}
35
+ </div>
36
+ ); })}
37
+ </div>
38
+ )}
39
+ </div>
40
+ );
41
+ }
src/app/(app)/history/page.tsx ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ import { redirect } from "next/navigation";
2
+ export default function HistoryPage() { redirect("/discovery-ledger"); }
src/app/(app)/inbox/page.tsx ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import { useState, useEffect, useCallback } from "react";
3
+ export default function InboxPage() {
4
+ const [loading, setLoading] = useState(true);
5
+ const [emails, setEmails] = useState<any[]>([]);
6
+ const [error, setError] = useState<string | null>(null);
7
+ const load = useCallback(async () => { try { const res = await fetch("/api/mailbox/status"); const data = await res.json(); setEmails(data.recentEmails || []); } catch (e) { setError(e instanceof Error ? e.message : "Failed to load inbox"); } finally { setLoading(false); } }, []);
8
+ useEffect(() => { load(); }, [load]);
9
+ return (
10
+ <div className="mx-auto max-w-4xl px-8 py-10">
11
+ <h1 className="text-3xl font-bold tracking-tight text-foreground">Inbox Intelligence</h1>
12
+ <p className="mt-2 text-muted-foreground">Mailbox Scientific Data — evidence substrate for the Advantage Foundry.</p>
13
+ {loading ? <p className="mt-6 text-muted-foreground">Loading inbox…</p> : error ? <div className="card mt-6 p-6"><p className="text-status-blocked">{error}</p></div> : emails.length === 0 ? <div className="card mt-6 p-8 text-center"><p className="text-muted-foreground">No emails processed yet. Connect a mailbox from the control plane to begin evidence intake.</p><a href="/settings" className="btn btn-primary mt-4 inline-block">Configure mailbox</a></div> : <div className="mt-6 space-y-3">{emails.map((e) => (<div key={e.id} className="card p-4"><p className="font-medium text-foreground">{e.subject || "(no subject)"}</p><p className="mt-1 text-xs text-muted-foreground">From: {e.from} · {e.date}</p><p className="mt-2 text-sm text-muted-foreground">{e.preview?.slice(0, 200)}</p></div>))}</div>}
14
+ </div>
15
+ );
16
+ }
src/app/(app)/layout.tsx ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * (app) route group layout — pass-through.
3
+ *
4
+ * The unified Advantage Foundry shell (GameNav) is rendered by the root
5
+ * layout via AppShell. This file exists only so the route group resolves;
6
+ * it must NOT render a second nav or header.
7
+ */
8
+ export default function AppLayout({ children }: { children: React.ReactNode }) {
9
+ return <>{children}</>;
10
+ }
src/app/(app)/learnings/page.tsx ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useState, useEffect, useCallback } from "react";
4
+ import type { HypothesisDerivative } from "@/types";
5
+
6
+ const FILTERS = ["all", "proposed", "testing", "supported", "falsified"] as const;
7
+ type Filter = typeof FILTERS[number];
8
+
9
+ export default function LearningsPage() {
10
+ const [derivatives, setDerivatives] = useState<HypothesisDerivative[]>([]);
11
+ const [loading, setLoading] = useState(true);
12
+ const [error, setError] = useState<string | null>(null);
13
+ const [filter, setFilter] = useState<Filter>("all");
14
+ const [generating, setGenerating] = useState(false);
15
+
16
+ const load = useCallback(async () => {
17
+ try {
18
+ const res = await fetch("/api/golden/derivatives");
19
+ const data = await res.json();
20
+ setDerivatives(data.derivatives || []);
21
+ } catch (e) {
22
+ setError(e instanceof Error ? e.message : "Failed to load");
23
+ } finally {
24
+ setLoading(false);
25
+ }
26
+ }, []);
27
+
28
+ useEffect(() => { load(); }, [load]);
29
+
30
+ async function generateLlm(hypothesisId: string, useLLM = false) {
31
+ setGenerating(true);
32
+ try {
33
+ await fetch("/api/golden/derivatives", {
34
+ method: "POST",
35
+ headers: { "Content-Type": "application/json" },
36
+ body: JSON.stringify({ action: "llm_permutations", hypothesisId, useLLM }),
37
+ });
38
+ await load();
39
+ } catch (e) {
40
+ setError(e instanceof Error ? e.message : "Generate failed");
41
+ } finally {
42
+ setGenerating(false);
43
+ }
44
+ }
45
+
46
+ async function promote(id: string) {
47
+ try {
48
+ await fetch("/api/golden/derivatives", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id, action: "promote" }) });
49
+ await load();
50
+ } catch (e) {
51
+ setError(e instanceof Error ? e.message : "Promote failed");
52
+ }
53
+ }
54
+
55
+ const parentIds = [...new Set(derivatives.map((d) => d.parentHypothesisId))];
56
+
57
+ const filtered = filter === "all" ? derivatives : derivatives.filter((d) => d.status === filter);
58
+
59
+ if (loading) return <div className="mx-auto max-w-4xl px-8 py-10"><p className="text-muted-foreground">Loading derivatives…</p></div>;
60
+ if (error) return <div className="mx-auto max-w-4xl px-8 py-10"><div className="card p-6"><p className="text-status-blocked">{error}</p><button className="btn btn-primary mt-4" onClick={load}>Retry</button></div></div>;
61
+
62
+ return (
63
+ <div className="mx-auto max-w-4xl px-8 py-10">
64
+ <h1 className="text-3xl font-bold tracking-tight text-foreground">Derivatives</h1>
65
+ <p className="mt-2 text-muted-foreground">Hypothesis mutations — branched experiments adapted to new contexts.</p>
66
+
67
+ <div className="mt-6 flex flex-wrap items-center gap-2">
68
+ {FILTERS.map((f) => (
69
+ <button key={f} onClick={() => setFilter(f)}
70
+ className={`rounded-lg border px-3 py-1.5 text-xs font-medium transition-all ${filter === f ? "border-foreground bg-foreground/10 text-foreground" : "border-border text-muted-foreground hover:text-foreground"}`}>
71
+ {f}
72
+ </button>
73
+ ))}
74
+ {parentIds.length > 0 && (
75
+ <div className="ml-auto flex gap-2">
76
+ <button onClick={() => generateLlm(parentIds[0], false)} disabled={generating}
77
+ className="rounded-lg border border-border px-3 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground disabled:opacity-50">
78
+ {generating ? "Generating…" : "Generate permutations"}
79
+ </button>
80
+ <button onClick={() => generateLlm(parentIds[0], true)} disabled={generating}
81
+ className="rounded-lg border border-foreground/40 px-3 py-1.5 text-xs font-medium text-foreground hover:bg-foreground/10 disabled:opacity-50">
82
+ Generate with LLM
83
+ </button>
84
+ </div>
85
+ )}
86
+ </div>
87
+
88
+ {filtered.length === 0 ? (
89
+ <div className="card mt-6 p-8 text-center"><p className="text-muted-foreground">No derivatives yet. Use Generate to create permutations from your hypotheses.</p></div>
90
+ ) : (
91
+ <div className="mt-6 space-y-4">
92
+ {filtered.map((d) => (
93
+ <div key={d.id} className="card p-5">
94
+ <div className="flex items-start justify-between">
95
+ <div className="flex-1">
96
+ <p className="font-medium text-foreground">{d.claim}</p>
97
+ <p className="mt-1 text-xs text-muted-foreground">
98
+ dimension: {d.modifiedDimension.replace(/_/g, " ")} · origin: {d.origin.replace(/_/g, " ")} · status: {d.status}
99
+ </p>
100
+ {d.rationale && <p className="mt-2 text-sm text-muted-foreground">{d.rationale}</p>}
101
+ </div>
102
+ {d.status === "proposed" && (
103
+ <button onClick={() => promote(d.id)} className="btn btn-outline text-xs">Promote to hypothesis</button>
104
+ )}
105
+ </div>
106
+ </div>
107
+ ))}
108
+ </div>
109
+ )}
110
+ </div>
111
+ );
112
+ }
src/app/(app)/process-lab/page.tsx ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useState, useEffect, useCallback } from "react";
4
+ import type { ProcessDefinition } from "@/types";
5
+
6
+ const EMPLOYEE_ID = "emp-001";
7
+ const STEP_TYPES = ["trigger", "condition", "action", "wait", "measurement", "stop"] as const;
8
+ interface StepForm { type: typeof STEP_TYPES[number]; label: string; waitHours?: number; }
9
+
10
+ export default function ProcessLabPage() {
11
+ const [processes, setProcesses] = useState<ProcessDefinition[]>([]);
12
+ const [hypotheses, setHypotheses] = useState<{ id: string; claim: string }[]>([]);
13
+ const [loading, setLoading] = useState(true);
14
+ const [error, setError] = useState<string | null>(null);
15
+ const [submitting, setSubmitting] = useState(false);
16
+ const [form, setForm] = useState({ name: "", hypothesisId: "", objective: "", eligibilityRules: "", complianceBoundary: "" });
17
+ const [steps, setSteps] = useState<StepForm[]>([{ type: "trigger", label: "" }]);
18
+
19
+ const load = useCallback(async () => {
20
+ try {
21
+ const [pRes, hRes] = await Promise.all([fetch(`/api/golden/process-lab?employeeId=${EMPLOYEE_ID}`), fetch("/api/golden/hypotheses")]);
22
+ const pData = await pRes.json();
23
+ const hData = await hRes.json();
24
+ setProcesses(pData.processes || []);
25
+ setHypotheses((hData.hypotheses || []).map((h: any) => ({ id: h.id, claim: h.claim })));
26
+ } catch (e) {
27
+ setError(e instanceof Error ? e.message : "Failed to load");
28
+ } finally {
29
+ setLoading(false);
30
+ }
31
+ }, []);
32
+
33
+ useEffect(() => { load(); }, [load]);
34
+
35
+ function addStep() { setSteps([...steps, { type: "action", label: "" }]); }
36
+ function removeStep(i: number) { setSteps(steps.filter((_, idx) => idx !== i)); }
37
+ function updateStep(i: number, patch: Partial<StepForm>) { setSteps(steps.map((s, idx) => idx === i ? { ...s, ...patch } : s)); }
38
+
39
+ async function submit() {
40
+ setSubmitting(true);
41
+ setError(null);
42
+ try {
43
+ const res = await fetch("/api/golden/process-lab", {
44
+ method: "POST",
45
+ headers: { "Content-Type": "application/json" },
46
+ body: JSON.stringify({
47
+ name: form.name, hypothesisId: form.hypothesisId || undefined, objective: form.objective,
48
+ eligibilityRules: form.eligibilityRules.split("\n").filter(Boolean), complianceBoundary: form.complianceBoundary,
49
+ steps: steps.filter((s) => s.label.trim()).map((s, i) => ({ id: `step_${i + 1}`, type: s.type, label: s.label, ...(s.type === "wait" ? { waitHours: s.waitHours || 24 } : {}) })),
50
+ authorEmployeeId: EMPLOYEE_ID,
51
+ }),
52
+ });
53
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
54
+ setForm({ name: "", hypothesisId: "", objective: "", eligibilityRules: "", complianceBoundary: "" });
55
+ setSteps([{ type: "trigger", label: "" }]);
56
+ await load();
57
+ } catch (e) {
58
+ setError(e instanceof Error ? e.message : "Failed to create process");
59
+ } finally {
60
+ setSubmitting(false);
61
+ }
62
+ }
63
+
64
+ if (loading) return <div className="mx-auto max-w-4xl px-8 py-10"><p className="text-muted-foreground">Loading process lab…</p></div>;
65
+
66
+ return (
67
+ <div className="mx-auto max-w-4xl px-8 py-10">
68
+ <h1 className="text-3xl font-bold tracking-tight text-foreground">Process Lab</h1>
69
+ <p className="mt-2 text-muted-foreground">Turn validated hypotheses into reusable, executable processes.</p>
70
+ {error && <div className="mt-4 rounded-lg border border-status-blocked/30 bg-status-blocked/10 p-3 text-sm text-status-blocked">{error}</div>}
71
+ <div className="card mt-6 p-5">
72
+ <p className="done-section-label">Process builder</p>
73
+ <div className="mt-3 space-y-3">
74
+ <input className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm" placeholder="Process name" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
75
+ <select className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm" value={form.hypothesisId} onChange={(e) => setForm({ ...form, hypothesisId: e.target.value })}>
76
+ <option value="">Link to hypothesis (optional)</option>
77
+ {hypotheses.map((h) => (<option key={h.id} value={h.id}>{h.claim.slice(0, 80)}</option>))}
78
+ </select>
79
+ <textarea className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm" rows={2} placeholder="Objective" value={form.objective} onChange={(e) => setForm({ ...form, objective: e.target.value })} />
80
+ <textarea className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm" rows={3} placeholder="Eligibility rules (one per line)" value={form.eligibilityRules} onChange={(e) => setForm({ ...form, eligibilityRules: e.target.value })} />
81
+ <textarea className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm" rows={2} placeholder="Compliance boundary" value={form.complianceBoundary} onChange={(e) => setForm({ ...form, complianceBoundary: e.target.value })} />
82
+ <div>
83
+ <p className="done-section-label">Steps</p>
84
+ <div className="mt-2 space-y-2">
85
+ {steps.map((s, i) => (
86
+ <div key={i} className="flex items-center gap-2">
87
+ <span className="text-xs text-muted-foreground w-6">{i + 1}.</span>
88
+ <select className="rounded-lg border border-border bg-background px-2 py-1.5 text-xs" value={s.type} onChange={(e) => updateStep(i, { type: e.target.value as StepForm["type"] })}>
89
+ {STEP_TYPES.map((t) => (<option key={t} value={t}>{t}</option>))}
90
+ </select>
91
+ <input className="flex-1 rounded-lg border border-border bg-background px-3 py-1.5 text-sm" placeholder="Step description" value={s.label} onChange={(e) => updateStep(i, { label: e.target.value })} />
92
+ {s.type === "wait" && <input type="number" className="w-20 rounded-lg border border-border bg-background px-2 py-1.5 text-sm" placeholder="hrs" value={s.waitHours || ""} onChange={(e) => updateStep(i, { waitHours: Number(e.target.value) })} />}
93
+ {steps.length > 1 && <button onClick={() => removeStep(i)} className="text-xs text-muted-foreground hover:text-status-blocked">✕</button>}
94
+ </div>
95
+ ))}
96
+ </div>
97
+ <button onClick={addStep} className="mt-2 text-xs font-medium text-muted-foreground hover:text-foreground">+ Add step</button>
98
+ </div>
99
+ <button onClick={submit} disabled={submitting || !form.name || !form.objective} className="btn btn-primary disabled:opacity-50">{submitting ? "Creating…" : "Create process"}</button>
100
+ </div>
101
+ </div>
102
+ <div className="mt-6 space-y-4">
103
+ <p className="done-section-label">Existing processes</p>
104
+ {processes.length === 0 ? (<p className="text-sm text-muted-foreground">No processes defined yet.</p>) : (
105
+ processes.map((p) => (
106
+ <div key={p.id} className="card p-5">
107
+ <p className="font-medium text-foreground">{p.name}</p>
108
+ <p className="mt-1 text-xs text-muted-foreground">{p.id} · {p.hypothesisId || "unlinked"}</p>
109
+ <p className="mt-2 text-sm text-foreground/90">{p.objective}</p>
110
+ {p.eligibilityRules.length > 0 && (<div className="mt-2"><p className="text-xs text-muted-foreground">Eligibility:</p><ul className="ml-4 text-xs text-muted-foreground">{p.eligibilityRules.map((r, i) => (<li key={i}>· {r}</li>))}</ul></div>)}
111
+ <div className="mt-3"><p className="text-xs text-muted-foreground">Steps:</p><ol className="ml-4 mt-1 space-y-1 text-sm text-foreground/90">{p.steps.map((s, i) => (<li key={s.id}><span className="text-muted-foreground">{i + 1}.</span> [{s.type}] {s.label}{s.type === "wait" && s.waitHours ? ` (${s.waitHours}h)` : ""}</li>))}</ol></div>
112
+ {p.complianceBoundary && <p className="mt-2 text-xs text-status-needs">Compliance: {p.complianceBoundary}</p>}
113
+ </div>
114
+ ))
115
+ )}
116
+ </div>
117
+ </div>
118
+ );
119
+ }
src/app/(app)/results/page.tsx ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import { useState, useEffect, useCallback } from "react";
3
+ import type { HypothesisAttribution, HypothesisOutcome } from "@/types";
4
+ export default function ResultsPage() {
5
+ const [attributions, setAttributions] = useState<HypothesisAttribution[]>([]);
6
+ const [outcomes, setOutcomes] = useState<HypothesisOutcome[]>([]);
7
+ const [loading, setLoading] = useState(true);
8
+ const [error, setError] = useState<string | null>(null);
9
+ const load = useCallback(async () => {
10
+ try { const [aRes, oRes] = await Promise.all([fetch("/api/golden/attributions"), fetch("/api/golden/outcomes")]); const aData = await aRes.json(); const oData = await oRes.json(); setAttributions(aData.attributions || []); setOutcomes(oData.outcomes || []); } catch (e) { setError(e instanceof Error ? e.message : "Failed to load"); } finally { setLoading(false); }
11
+ }, []);
12
+ useEffect(() => { load(); }, [load]);
13
+ if (loading) return <div className="mx-auto max-w-4xl px-8 py-10"><p className="text-muted-foreground">Loading results…</p></div>;
14
+ if (error) return <div className="mx-auto max-w-4xl px-8 py-10"><div className="card p-6"><p className="text-status-blocked">{error}</p><button className="btn btn-primary mt-4" onClick={load}>Retry</button></div></div>;
15
+ const outcomeMap = new Map(outcomes.map((o) => [o.id, o]));
16
+ return (
17
+ <div className="mx-auto max-w-4xl px-8 py-10">
18
+ <h1 className="text-3xl font-bold tracking-tight text-foreground">Why It Worked</h1>
19
+ <p className="mt-2 text-muted-foreground">Causal attribution for every recorded outcome.</p>
20
+ {attributions.length === 0 ? <div className="card mt-6 p-8 text-center"><p className="text-muted-foreground">No attributions yet. Record outcomes from experiments to generate attribution analysis.</p></div> : (
21
+ <div className="mt-6 space-y-4">
22
+ {attributions.map((a) => {
23
+ const outcome = outcomeMap.get(a.outcomeId); const highVariance = a.unexplainedVariance >= 0.15;
24
+ return (
25
+ <div key={a.id} className="card p-5">
26
+ <div className="flex items-start justify-between"><div><p className="font-medium text-foreground">{a.responsibleFactor.replace(/_/g, " ")}</p><p className="mt-1 text-xs text-muted-foreground">Method: {a.method.replace(/_/g, " ")} · Employee: {a.employeeId}</p></div><div className="text-right"><p className="text-lg font-bold text-foreground">{(a.estimatedEffect * 100).toFixed(0)}%</p><p className="text-xs text-muted-foreground">estimated effect</p></div></div>
27
+ {outcome && <p className="mt-3 text-sm text-muted-foreground">{outcome.outcomeDescription}</p>}
28
+ <div className="mt-3 grid grid-cols-2 gap-3 text-sm md:grid-cols-4"><div><p className="done-section-label">Confidence</p><p className="mt-0.5 text-foreground/90">{(a.attributionConfidence * 100).toFixed(0)}%</p></div><div><p className="done-section-label">Unexplained</p><p className={`mt-0.5 ${highVariance ? "text-status-blocked font-medium" : "text-foreground/90"}`}>{(a.unexplainedVariance * 100).toFixed(0)}%</p></div><div><p className="done-section-label">Counterfactual</p><p className="mt-0.5 text-foreground/90">{a.counterfactualEstimate}</p></div><div><p className="done-section-label">Success kind</p><p className="mt-0.5 text-foreground/90">{outcome?.successKind || "—"}</p></div></div>
29
+ <p className="mt-3 text-sm text-muted-foreground">{a.reasoning}</p>
30
+ {highVariance && <div className="mt-3 rounded-lg border border-status-blocked/30 bg-status-blocked/10 p-3 text-xs text-status-blocked">High unexplained variance ({(a.unexplainedVariance * 100).toFixed(0)}%). This attribution is not yet defensible — collect more replications.</div>}
31
+ </div>
32
+ );
33
+ })}
34
+ </div>
35
+ )}
36
+ </div>
37
+ );
38
+ }
src/app/(app)/spinor/page.tsx ADDED
@@ -0,0 +1,260 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+
3
+ import { useState, useEffect, useCallback } from "react";
4
+ import type { SpinorOrganism, SpinorOrganismNode, SpinorSignatureAction, SpinorMaturityStage, SpinorEvidenceBadge } from "@/types";
5
+
6
+ const EMPLOYEE_ID = "emp-001";
7
+
8
+ const STAGE_COLOR: Record<SpinorMaturityStage, string> = {
9
+ seed: "#6b7280", sprout: "#8b5cf6", branch: "#a78bfa", grove: "#22c55e",
10
+ golden_node: "#fbbf24", infrastructure: "#f59e0b", spinout: "#ef4444",
11
+ };
12
+
13
+ const EVIDENCE_COLOR: Record<SpinorEvidenceBadge, string> = {
14
+ established: "#22c55e", supported: "#3b82f6", transferred: "#06b6d4",
15
+ plausible: "#a78bfa", untested: "#6b7280", contradicted: "#ef4444", internal_signal: "#fbbf24",
16
+ };
17
+
18
+ const NODE_COLOR_HEX: Record<string, string> = {
19
+ blue: "#3b82f6", violet: "#8b5cf6", green: "#22c55e", gold: "#fbbf24", red: "#ef4444", gray: "#6b7280",
20
+ };
21
+
22
+ const ACTION_LABEL: Record<SpinorSignatureAction, string> = {
23
+ plant: "Plant — begin the experiment",
24
+ observe: "Observe — record an interim measurement",
25
+ record: "Record — log a final outcome",
26
+ challenge: "Challenge — dispute the hypothesis or its evidence",
27
+ replicate: "Replicate — verify or falsify another's finding",
28
+ derive: "Derive — propose a derivative",
29
+ integrate: "Integrate — promote into infrastructure",
30
+ spin_out: "Spin out — propose as a separate channel/business",
31
+ };
32
+
33
+ const ROLE_LABEL: Record<string, string> = {
34
+ core: "Today's Hypothesis", supporting_research: "Supporting Research", contradicting: "Contradicting Evidence",
35
+ previous_attempt: "Previous Attempt", derivative: "Derivative", replication: "Replication",
36
+ risk_signal: "Risk Signal", expected_value: "Expected Value", golden_node: "Golden Node", compost: "Compost (Falsified)",
37
+ };
38
+
39
+ export default function SpinorPage() {
40
+ const [organism, setOrganism] = useState<SpinorOrganism | null>(null);
41
+ const [loading, setLoading] = useState(true);
42
+ const [error, setError] = useState<string | null>(null);
43
+ const [selectedNode, setSelectedNode] = useState<SpinorOrganismNode | null>(null);
44
+ const [leaderboard, setLeaderboard] = useState<{ employeeId: string; score: number; roles: string[]; breakdown: Record<string, number> }[]>([]);
45
+ const [profile, setProfile] = useState<{ dimensions: Record<string, number>; researchStreak: number } | null>(null);
46
+ const [roles, setRoles] = useState<string[]>([]);
47
+ const [tab, setTab] = useState<"organism" | "leaderboard" | "profile">("organism");
48
+
49
+ const load = useCallback(async () => {
50
+ try {
51
+ await fetch("/api/golden", { method: "POST" });
52
+ const [orgRes, lbRes, profRes] = await Promise.all([
53
+ fetch(`/api/golden/spinor?action=organism&employeeId=${EMPLOYEE_ID}`),
54
+ fetch("/api/golden/spinor?action=leaderboard"),
55
+ fetch(`/api/golden/spinor?action=profile&employeeId=${EMPLOYEE_ID}`),
56
+ ]);
57
+ if (orgRes.ok) { const d = await orgRes.json(); setOrganism(d.organism || null); }
58
+ if (lbRes.ok) { const d = await lbRes.json(); setLeaderboard(d.rankings || []); }
59
+ if (profRes.ok) { const d = await profRes.json(); setProfile(d.profile || null); setRoles(d.roles || []); }
60
+ } catch (e) { setError(e instanceof Error ? e.message : "Failed to load"); }
61
+ finally { setLoading(false); }
62
+ }, []);
63
+
64
+ useEffect(() => { load(); }, [load]);
65
+
66
+ if (loading) return <div className="mx-auto max-w-5xl px-8 py-10"><p className="text-muted-foreground">Building your Hypothesis Organism…</p></div>;
67
+ if (error) return <div className="mx-auto max-w-5xl px-8 py-10"><div className="card border-status-blocked/30 p-6"><p className="text-status-blocked">{error}</p><button className="btn btn-primary mt-4" onClick={load}>Retry</button></div></div>;
68
+
69
+ return (
70
+ <div className="mx-auto max-w-5xl px-8 py-10">
71
+ <div className="mb-6">
72
+ <h1 className="text-3xl font-bold tracking-tight text-foreground">Hypothesis Organism</h1>
73
+ <p className="mt-2 text-muted-foreground">Your daily mission is a living experimental ecosystem.</p>
74
+ </div>
75
+
76
+ <div className="mb-6 flex gap-2">
77
+ {(["organism", "leaderboard", "profile"] as const).map((t) => (
78
+ <button key={t} onClick={() => setTab(t)}
79
+ className={`rounded-lg border px-4 py-2 text-sm font-medium transition-all ${tab === t ? "border-foreground bg-foreground/10 text-foreground" : "border-border text-muted-foreground hover:text-foreground"}`}>
80
+ {t === "organism" ? "Today's Organism" : t === "leaderboard" ? "Leaderboard" : "My Profile"}
81
+ </button>
82
+ ))}
83
+ </div>
84
+
85
+ {tab === "organism" && organism && (
86
+ <div className="space-y-6">
87
+ <div className="grid grid-cols-1 gap-4 md:grid-cols-3">
88
+ <div className="card p-5">
89
+ <p className="done-section-label">Today's Hypothesis</p>
90
+ <p className="mt-2 text-sm font-medium text-foreground">{organism.claim}</p>
91
+ <div className="mt-3 flex items-center gap-2">
92
+ <span className="rounded-full px-2.5 py-0.5 text-xs font-semibold text-white" style={{ backgroundColor: STAGE_COLOR[organism.maturity] }}>{organism.maturity.replace(/_/g, " ")}</span>
93
+ <span className="rounded-full px-2.5 py-0.5 text-xs font-medium" style={{ backgroundColor: EVIDENCE_COLOR[organism.evidence] + "30", color: EVIDENCE_COLOR[organism.evidence] }}>{organism.evidence.replace(/_/g, " ")}</span>
94
+ </div>
95
+ </div>
96
+ <div className="card p-5">
97
+ <p className="done-section-label">Evidence Required</p>
98
+ <p className="mt-2 text-sm text-foreground/90">{organism.trialsCompleted} / {organism.requiredTrials} trials completed</p>
99
+ <div className="mt-2 h-2 overflow-hidden rounded-full bg-muted">
100
+ <div className="h-full rounded-full bg-foreground transition-all" style={{ width: `${Math.min(100, (organism.trialsCompleted / organism.requiredTrials) * 100)}%` }} />
101
+ </div>
102
+ <p className="mt-2 text-xs text-muted-foreground">Falsification: {organism.falsificationCondition}</p>
103
+ </div>
104
+ <div className="card p-5">
105
+ <p className="done-section-label">Evolutionary Paths</p>
106
+ <div className="mt-2 space-y-1.5">
107
+ {organism.actions.map((a) => (<div key={a} className="flex items-center gap-2 text-sm text-foreground/90"><span className="h-1.5 w-1.5 rounded-full bg-foreground" />{ACTION_LABEL[a]}</div>))}
108
+ </div>
109
+ </div>
110
+ </div>
111
+
112
+ <div className="card p-5">
113
+ <div className="flex items-center justify-between">
114
+ <p className="done-section-label">Discovery Contribution Score</p>
115
+ <span className="text-2xl font-bold text-foreground">{organism.dcs.score}</span>
116
+ </div>
117
+ <p className="mt-1 text-xs text-muted-foreground">DCS = (I × C × R × V × T) / H · {organism.dcs.provisional ? "Provisional" : "Settled"}</p>
118
+ <div className="mt-3 grid grid-cols-3 gap-3 md:grid-cols-6">
119
+ {organism.dcs.components.map((c) => (
120
+ <div key={c.symbol} className="rounded-lg border border-border p-2 text-center">
121
+ <p className="text-xs font-bold text-foreground">{c.symbol}</p>
122
+ <p className="text-xs text-muted-foreground">{c.name}</p>
123
+ <p className="mt-1 text-sm font-semibold text-foreground">{(c.value * 100).toFixed(0)}%</p>
124
+ </div>
125
+ ))}
126
+ </div>
127
+ </div>
128
+
129
+ <div className="card p-5">
130
+ <p className="done-section-label mb-4">Organism Canvas</p>
131
+ <div className="relative mx-auto" style={{ width: "500px", height: "500px", maxWidth: "100%" }}>
132
+ <svg viewBox="-250 -250 500 500" className="w-full h-full">
133
+ {[60, 120, 180, 220].map((r) => (<circle key={r} cx="0" cy="0" r={r} fill="none" stroke="currentColor" strokeWidth="0.5" className="text-border" opacity="0.3" />))}
134
+ {organism.nodes.filter((n) => n.role !== "core").map((n) => {
135
+ const x = Math.cos(n.angle) * n.radius * 100;
136
+ const y = Math.sin(n.angle) * n.radius * 100;
137
+ return <line key={`line-${n.id}`} x1="0" y1="0" x2={x} y2={y} stroke={NODE_COLOR_HEX[n.color] || "#6b7280"} strokeWidth="1" opacity="0.2" />;
138
+ })}
139
+ <circle cx="0" cy="0" r="45" fill={STAGE_COLOR[organism.maturity]} opacity="0.15" />
140
+ <circle cx="0" cy="0" r="30" fill={STAGE_COLOR[organism.maturity]} opacity="0.3" />
141
+ <circle cx="0" cy="0" r="18" fill={STAGE_COLOR[organism.maturity]} className="cursor-pointer" onClick={() => setSelectedNode(organism.nodes.find(n => n.role === "core") || null)}>
142
+ <animate attributeName="r" values="18;22;18" dur="3s" repeatCount="indefinite" />
143
+ </circle>
144
+ <text x="0" y="4" textAnchor="middle" fill="white" fontSize="9" fontWeight="bold">CORE</text>
145
+ {organism.nodes.filter((n) => n.role !== "core").map((n) => {
146
+ const x = Math.cos(n.angle) * n.radius * 100;
147
+ const y = Math.sin(n.angle) * n.radius * 100;
148
+ const color = NODE_COLOR_HEX[n.color] || "#6b7280";
149
+ const r = n.role === "golden_node" ? 16 : n.role === "compost" ? 10 : 12;
150
+ return (
151
+ <g key={n.id} className="cursor-pointer" onClick={() => setSelectedNode(n)}>
152
+ {n.pulse && (
153
+ <circle cx={x} cy={y} r={r + 4} fill={color} opacity="0.2">
154
+ <animate attributeName="r" values={`${r + 2};${r + 8};${r + 2}`} dur="2s" repeatCount="indefinite" />
155
+ <animate attributeName="opacity" values="0.2;0.05;0.2" dur="2s" repeatCount="indefinite" />
156
+ </circle>
157
+ )}
158
+ <circle cx={x} cy={y} r={r} fill={color} opacity="0.8" stroke={color} strokeWidth="1.5" />
159
+ <text x={x} y={y + 3} textAnchor="middle" fill="white" fontSize="7" fontWeight="bold">
160
+ {n.role === "golden_node" ? "GN" : n.role === "derivative" ? "D" : n.role === "replication" ? "R" : n.role === "previous_attempt" ? "T" : n.role === "supporting_research" ? "S" : n.role === "contradicting" ? "X" : n.role === "risk_signal" ? "!" : n.role === "expected_value" ? "$" : n.role === "compost" ? "C" : "•"}
161
+ </text>
162
+ </g>
163
+ );
164
+ })}
165
+ </svg>
166
+ </div>
167
+ <div className="mt-4 flex flex-wrap gap-3 text-xs text-muted-foreground">
168
+ {Object.entries(ROLE_LABEL).filter(([k]) => k !== "core").map(([k, v]) => (
169
+ <div key={k} className="flex items-center gap-1.5">
170
+ <span className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: NODE_COLOR_HEX[
171
+ k === "supporting_research" ? "blue" : k === "contradicting" ? "red" : k === "previous_attempt" ? "violet" :
172
+ k === "derivative" ? "violet" : k === "replication" ? "green" : k === "risk_signal" ? "red" :
173
+ k === "expected_value" ? "gold" : k === "golden_node" ? "gold" : k === "compost" ? "gray" : "gray"
174
+ ] }} />
175
+ {v}
176
+ </div>
177
+ ))}
178
+ </div>
179
+ </div>
180
+
181
+ {selectedNode && (
182
+ <div className="card p-5">
183
+ <div className="flex items-center gap-2">
184
+ <span className="rounded-full px-2.5 py-0.5 text-xs font-semibold text-white" style={{ backgroundColor: NODE_COLOR_HEX[selectedNode.color] }}>{ROLE_LABEL[selectedNode.role]}</span>
185
+ <span className="rounded-full px-2.5 py-0.5 text-xs" style={{ backgroundColor: EVIDENCE_COLOR[selectedNode.evidence] + "20", color: EVIDENCE_COLOR[selectedNode.evidence] }}>{selectedNode.evidence.replace(/_/g, " ")}</span>
186
+ </div>
187
+ <p className="mt-3 text-sm font-medium text-foreground">{selectedNode.label}</p>
188
+ <p className="mt-1 text-sm text-muted-foreground">{selectedNode.detail}</p>
189
+ </div>
190
+ )}
191
+
192
+ <div className="card border-status-needs/30 p-4">
193
+ <p className="text-xs text-muted-foreground"><span className="font-medium text-foreground">Compliance boundary: </span>{organism.complianceBoundary}</p>
194
+ </div>
195
+ </div>
196
+ )}
197
+
198
+ {tab === "leaderboard" && (
199
+ <div className="card p-5">
200
+ <p className="done-section-label mb-2">Node Score Leaderboard</p>
201
+ <p className="text-sm text-muted-foreground mb-4">Ranks the human–LLM–hypothesis combination, not raw sales.</p>
202
+ <div className="space-y-2">
203
+ {leaderboard.map((r, i) => (
204
+ <div key={r.employeeId} className={`flex items-center justify-between rounded-lg border p-4 ${r.employeeId === EMPLOYEE_ID ? "border-foreground/40 bg-foreground/5" : "border-border"}`}>
205
+ <div className="flex items-center gap-4">
206
+ <span className="flex h-8 w-8 items-center justify-center rounded-full bg-foreground/10 text-sm font-bold text-foreground">{i + 1}</span>
207
+ <div>
208
+ <p className="font-medium text-foreground">{r.employeeId}</p>
209
+ <div className="mt-0.5 flex flex-wrap gap-1">
210
+ {r.roles.map((role) => (<span key={role} className="rounded-full border border-border px-2 py-0.5 text-[10px] text-muted-foreground">{role}</span>))}
211
+ </div>
212
+ </div>
213
+ </div>
214
+ <div className="text-right">
215
+ <p className="text-xl font-bold text-foreground">{r.score.toFixed(1)}</p>
216
+ <p className="text-xs text-muted-foreground">lift {r.breakdown.causalLift} + info {r.breakdown.informationGained} + mut {r.breakdown.mutationValue}</p>
217
+ </div>
218
+ </div>
219
+ ))}
220
+ {leaderboard.length === 0 && <p className="text-center text-muted-foreground py-8">No scores yet.</p>}
221
+ </div>
222
+ </div>
223
+ )}
224
+
225
+ {tab === "profile" && profile && (
226
+ <div className="space-y-4">
227
+ <div className="card p-5">
228
+ <div className="flex items-center justify-between">
229
+ <div><p className="done-section-label">Participant Profile</p><p className="mt-1 text-sm text-foreground">{EMPLOYEE_ID}</p></div>
230
+ <div className="flex flex-wrap gap-1.5">
231
+ {roles.map((role) => (<span key={role} className="rounded-full bg-foreground/10 px-3 py-1 text-xs font-semibold text-foreground">{role}</span>))}
232
+ </div>
233
+ </div>
234
+ <p className="mt-3 text-xs text-muted-foreground">Research streak: {profile.researchStreak}</p>
235
+ </div>
236
+ <div className="card p-5">
237
+ <p className="done-section-label mb-3">Contribution Dimensions</p>
238
+ <div className="space-y-3">
239
+ {Object.entries(profile.dimensions).map(([key, value]) => (
240
+ <div key={key}>
241
+ <div className="flex items-center justify-between text-sm">
242
+ <span className="text-muted-foreground capitalize">{key.replace(/([A-Z])/g, " $1").trim()}</span>
243
+ <span className="font-medium text-foreground">{(value * 100).toFixed(0)}%</span>
244
+ </div>
245
+ <div className="mt-1 h-1.5 overflow-hidden rounded-full bg-muted">
246
+ <div className="h-full rounded-full bg-foreground transition-all" style={{ width: `${value * 100}%` }} />
247
+ </div>
248
+ </div>
249
+ ))}
250
+ </div>
251
+ </div>
252
+ </div>
253
+ )}
254
+
255
+ {tab === "organism" && !organism && (
256
+ <div className="card p-8 text-center"><p className="text-muted-foreground">No active hypothesis. Allocate one from Today's Research.</p></div>
257
+ )}
258
+ </div>
259
+ );
260
+ }
src/app/(app)/today/page.tsx ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import { useState, useEffect, useCallback } from "react";
3
+ import type { HypothesisAnatomy, PriorArtRecord, HypothesisAssignment } from "@/types";
4
+ const EMPLOYEE_ID = "emp-001";
5
+ export default function TodayPage() {
6
+ const [assignments, setAssignments] = useState<HypothesisAssignment[]>([]);
7
+ const [hypotheses, setHypotheses] = useState<Map<string, HypothesisAnatomy>>(new Map());
8
+ const [priorArt, setPriorArt] = useState<Map<string, PriorArtRecord>>(new Map());
9
+ const [loading, setLoading] = useState(true);
10
+ const [error, setError] = useState<string | null>(null);
11
+ const [acting, setActing] = useState(false);
12
+ const [llmResearch, setLlmResearch] = useState<{ summary: string; used: boolean; error?: string } | null>(null);
13
+ const [researching, setResearching] = useState(false);
14
+ const load = useCallback(async () => {
15
+ try {
16
+ const [aRes, hRes, paRes] = await Promise.all([fetch(`/api/golden/assignments?employeeId=${EMPLOYEE_ID}&active=true`), fetch("/api/golden/hypotheses"), fetch("/api/golden/prior-art")]);
17
+ const aData = await aRes.json(); const hData = await hRes.json(); const paData = await paRes.json();
18
+ setAssignments(aData.assignments || []);
19
+ setHypotheses(new Map((hData.hypotheses || []).map((h: HypothesisAnatomy) => [h.id, h])));
20
+ setPriorArt(new Map((paData.priorArt || []).map((p: PriorArtRecord) => [p.id, p])));
21
+ } catch (e) { setError(e instanceof Error ? e.message : "Failed to load"); } finally { setLoading(false); }
22
+ }, []);
23
+ useEffect(() => { load(); }, [load]);
24
+ const current = assignments[0];
25
+ const hypothesis = current ? hypotheses.get(current.hypothesisId) : undefined;
26
+ const pa = hypothesis ? priorArt.get(hypothesis.priorArtId) : undefined;
27
+ async function patchAssignment(action: "accept" | "reject" | "modify", extra?: Record<string, unknown>) {
28
+ if (!current) return; setActing(true);
29
+ try { const res = await fetch("/api/golden/assignments", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action, assignmentId: current.id, ...extra }) }); if (!res.ok) throw new Error(`HTTP ${res.status}`); await load(); } catch (e) { setError(e instanceof Error ? e.message : "Action failed"); } finally { setActing(false); }
30
+ }
31
+ async function runLLMResearch() {
32
+ if (!hypothesis) return; setResearching(true); setLlmResearch(null);
33
+ try { const res = await fetch("/api/golden/llm", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ action: "research", claim: hypothesis.claim }) }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); setLlmResearch({ summary: data.record?.adjacentSupportSummary || "No summary returned.", used: data.llmUsed, error: data.llmError }); } catch (e) { setLlmResearch({ summary: "", used: false, error: e instanceof Error ? e.message : "Research failed" }); } finally { setResearching(false); }
34
+ }
35
+ if (loading) return <div className="mx-auto max-w-4xl px-8 py-10"><p className="text-muted-foreground">Loading today's hypothesis…</p></div>;
36
+ if (error) return <div className="mx-auto max-w-4xl px-8 py-10"><div className="card p-6"><p className="text-status-blocked">{error}</p><button className="btn btn-primary mt-4" onClick={load}>Retry</button></div></div>;
37
+ if (!current || !hypothesis) return (<div className="mx-auto max-w-4xl px-8 py-10"><h1 className="text-3xl font-bold tracking-tight text-foreground">Today's Hypothesis</h1><div className="card mt-6 p-8 text-center"><p className="text-muted-foreground">No active hypothesis mission. Allocate one to begin.</p><button className="btn btn-primary mt-4" onClick={async () => { await fetch("/api/golden/allocate", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ employeeId: EMPLOYEE_ID }) }); load(); }}>Allocate hypothesis</button></div></div>);
38
+ return (
39
+ <div className="mx-auto max-w-4xl px-8 py-10">
40
+ <h1 className="text-3xl font-bold tracking-tight text-foreground">Today's Hypothesis</h1>
41
+ <p className="mt-2 text-muted-foreground">Trial {current.trialNumber} · {current.kind} · {current.state}</p>
42
+ <div className="card mt-6 p-6">
43
+ <p className="text-lg font-medium text-foreground">{hypothesis.claim}</p>
44
+ <p className="mt-2 text-sm text-muted-foreground"><span className="font-medium text-foreground/80">Why this reached you: </span>{current.allocationReason}</p>
45
+ {pa && (<div className="mt-4 rounded-lg border border-border bg-muted/20 p-4"><p className="done-section-label">Prior art</p><p className="mt-1 text-sm text-muted-foreground">Status: {pa.status.replace(/_/g, " ")} · Evidence: {pa.evidenceState} · Confidence: {(pa.researchConfidence * 100).toFixed(0)}%</p><p className="mt-1 text-muted-foreground">{pa.adjacentSupportSummary}</p><p className="mt-2 text-xs text-muted-foreground">Source domains: {pa.sourceDomains.join(", ")}</p></div>)}
46
+ <div className="mt-4 rounded-lg border border-border bg-muted/20 p-4"><div className="flex items-center justify-between"><p className="done-section-label">LLM prior-art research</p><button onClick={runLLMResearch} disabled={researching} className="rounded-lg border border-border px-3 py-1 text-xs font-medium text-muted-foreground transition-all hover:border-foreground hover:text-foreground disabled:opacity-50">{researching ? "Researching…" : "Run LLM research"}</button></div>{llmResearch && (<div className="mt-2 text-sm">{llmResearch.used ? <p className="text-foreground/90">{llmResearch.summary}</p> : <p className="text-muted-foreground">LLM unavailable{llmResearch.error ? `: ${llmResearch.error}` : ""}. Deterministic prior-art used instead.</p>}</div>)}</div>
47
+ <div className="mt-5 grid grid-cols-1 gap-4 md:grid-cols-2"><div><p className="done-section-label">Intervention</p><p className="mt-1 text-sm text-foreground/90">{hypothesis.intervention}</p></div><div><p className="done-section-label">Control</p><p className="mt-1 text-sm text-foreground/90">{hypothesis.control}</p></div><div><p className="done-section-label">Primary outcome</p><p className="mt-1 text-sm text-foreground/90">{hypothesis.primaryOutcome}</p></div><div><p className="done-section-label">Primary uncertainty</p><p className="mt-1 text-sm text-foreground/90">{hypothesis.primaryUncertainty}</p></div></div>
48
+ <div className="mt-5"><p className="done-section-label">Fixed constraints</p><ul className="mt-1 space-y-1 text-sm text-muted-foreground">{hypothesis.fixedConstraints.map((c, i) => (<li key={i}>· {c}</li>))}</ul></div>
49
+ <div className="mt-5"><p className="done-section-label">Innovation window (modifiable dimensions)</p><div className="mt-2 flex flex-wrap gap-2">{(hypothesis.modifiableDimensions || []).map((d) => (<span key={d} className="rounded-lg border border-violet-500/30 bg-violet-500/10 px-3 py-1 text-xs font-medium text-violet-300">{d.replace(/_/g, " ")}</span>))}</div></div>
50
+ <div className="mt-5 grid grid-cols-1 gap-4 md:grid-cols-2"><div><p className="done-section-label">Expected value</p><p className="mt-1 text-sm text-foreground/90">{hypothesis.expectedValue}</p></div><div><p className="done-section-label">Eligible accounts</p><p className="mt-1 text-sm text-foreground/90">{current.eligibleAccountIds.length} accounts · {current.evaluationPeriodDays} day window</p></div></div>
51
+ <div className="mt-5 rounded-lg border border-status-needs/30 bg-status-needs/5 p-4"><p className="done-section-label text-status-needs">Compliance boundary</p><p className="mt-1 text-sm text-foreground/80">{hypothesis.complianceBoundary}</p></div>
52
+ <div className="mt-6 flex gap-3"><button onClick={() => patchAssignment("accept")} disabled={acting} className="btn btn-primary disabled:opacity-50">{acting ? "…" : "Accept mission"}</button><button onClick={() => patchAssignment("reject")} disabled={acting} className="btn btn-outline disabled:opacity-50">Reject</button></div>
53
+ </div>
54
+ </div>
55
+ );
56
+ }
src/app/api/azure/config/route.ts ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from "next/server";
2
+ import { loadConfig } from "@/lib/config";
3
+
4
+ export const dynamic = "force-dynamic";
5
+
6
+ /**
7
+ * Returns whether Azure AD / Microsoft Graph OAuth is configured server-side.
8
+ * Exposes the clientId (not secret) so the client knows it can initiate auth.
9
+ */
10
+ export async function GET() {
11
+ let config;
12
+ try {
13
+ config = loadConfig();
14
+ } catch (e) {
15
+ console.error("[azure/config] loadConfig error:", e);
16
+ config = { graph: {} };
17
+ }
18
+
19
+ const clientId =
20
+ process.env.AZURE_AD_CLIENT_ID ||
21
+ process.env.AZURE_CLIENT_ID ||
22
+ process.env.MICROSOFT_CLIENT_ID ||
23
+ config.graph?.clientId ||
24
+ "";
25
+ const tenantId =
26
+ process.env.AZURE_AD_TENANT_ID ||
27
+ process.env.AZURE_TENANT_ID ||
28
+ process.env.MICROSOFT_TENANT_ID ||
29
+ config.graph?.tenantId ||
30
+ "";
31
+ const clientSecret =
32
+ process.env.AZURE_AD_CLIENT_SECRET ||
33
+ process.env.AZURE_CLIENT_SECRET ||
34
+ process.env.MICROSOFT_CLIENT_SECRET ||
35
+ config.graph?.clientSecret ||
36
+ "";
37
+ const mailbox =
38
+ process.env.MAILBOX_EMAIL ||
39
+ config.graph?.mailbox ||
40
+ "";
41
+
42
+ return NextResponse.json({
43
+ configured: !!(clientId && tenantId && clientSecret),
44
+ clientId,
45
+ tenantId,
46
+ hasSecret: !!clientSecret,
47
+ mailbox,
48
+ });
49
+ }
src/app/api/commitments/detect/route.ts ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { detectCommitments } from "@/lib/commitment/detector";
3
+ import { loadProcessedEmails } from "@/lib/config";
4
+ import { upsertCommitmentByEmailId, listCommitments, loadMetrics } from "@/lib/commitment/store";
5
+
6
+ export const dynamic = "force-dynamic";
7
+ export const maxDuration = 60;
8
+
9
+ export async function POST(request: NextRequest) {
10
+ try {
11
+ const body = await request.json().catch(() => ({}));
12
+ const records = Array.isArray(body.records) ? body.records : loadProcessedEmails();
13
+
14
+ const detected = detectCommitments(records);
15
+
16
+ for (const c of detected) {
17
+ try {
18
+ upsertCommitmentByEmailId(c);
19
+ } catch (e: any) {
20
+ console.error("[commitments/detect] upsert error:", e);
21
+ }
22
+ }
23
+
24
+ const commitments = listCommitments();
25
+ const metrics = loadMetrics();
26
+
27
+ return NextResponse.json({
28
+ success: true,
29
+ detected: detected.length,
30
+ count: commitments.length,
31
+ commitments,
32
+ metrics,
33
+ timestamp: new Date().toISOString(),
34
+ });
35
+ } catch (e: any) {
36
+ console.error("[commitments/detect] error:", e);
37
+ return NextResponse.json({ error: e.message }, { status: 500 });
38
+ }
39
+ }
src/app/api/commitments/execute/route.ts ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import {
3
+ getCommitmentById,
4
+ listCommitments,
5
+ loadMetrics,
6
+ upsertCommitment,
7
+ addCommitmentAuditEvent,
8
+ } from "@/lib/commitment/store";
9
+ import { executeCommitment } from "@/lib/commitment/executor";
10
+
11
+ export const dynamic = "force-dynamic";
12
+ export const maxDuration = 120;
13
+
14
+ export async function POST(request: NextRequest) {
15
+ try {
16
+ const body = await request.json().catch(() => ({}));
17
+
18
+ const executeAll = !!body.executeAll;
19
+ const id = typeof body.id === "string" ? body.id : "";
20
+
21
+ if (!executeAll && !id) {
22
+ return NextResponse.json({ error: "id is required (or executeAll=true)" }, { status: 400 });
23
+ }
24
+
25
+ let targets = [];
26
+ if (executeAll) {
27
+ targets = listCommitments().filter((c) => c.status === "detected");
28
+ } else {
29
+ const found = getCommitmentById(id);
30
+ if (!found) {
31
+ return NextResponse.json({ error: `Commitment not found: ${id}` }, { status: 404 });
32
+ }
33
+ targets = [found];
34
+ }
35
+
36
+ let metrics = loadMetrics();
37
+ const executed: any[] = [];
38
+
39
+ for (const c of targets) {
40
+ try {
41
+ addCommitmentAuditEvent(c.id, "execute_requested", executeAll ? "Bulk execution requested" : "Execution requested");
42
+ } catch (e) {
43
+ console.error("[commitments/execute] audit error:", e);
44
+ }
45
+
46
+ const result = executeCommitment(c, metrics);
47
+ metrics = result.metrics;
48
+
49
+ const saved = upsertCommitment(result.contract);
50
+ executed.push({
51
+ id: saved.id,
52
+ emailId: saved.emailId,
53
+ status: saved.status,
54
+ staged: result.staged,
55
+ deliverableCount: saved.deliverables?.length ?? 0,
56
+ });
57
+ }
58
+
59
+ return NextResponse.json({
60
+ success: true,
61
+ executedCount: executed.length,
62
+ executed,
63
+ metrics,
64
+ timestamp: new Date().toISOString(),
65
+ });
66
+ } catch (e: any) {
67
+ console.error("[commitments/execute] error:", e);
68
+ return NextResponse.json({ error: e.message }, { status: 500 });
69
+ }
70
+ }
src/app/api/commitments/outcome/route.ts ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { z } from "zod";
3
+ import {
4
+ getCommitmentById,
5
+ upsertCommitment,
6
+ addCommitmentAuditEvent,
7
+ updateCommitmentStatus,
8
+ recordAcceptanceOutcome,
9
+ } from "@/lib/commitment/store";
10
+
11
+ export const dynamic = "force-dynamic";
12
+ export const maxDuration = 30;
13
+
14
+ const OutcomeSchema = z.object({
15
+ id: z.string().min(1),
16
+ eventId: z.string().min(6),
17
+ accepted: z.boolean(),
18
+ revisionRequested: z.boolean().optional().default(false),
19
+ notes: z.string().max(2000).optional().default(""),
20
+ });
21
+
22
+ export async function POST(request: NextRequest) {
23
+ try {
24
+ const body = await request.json().catch(() => ({}));
25
+ const parsed = OutcomeSchema.safeParse(body);
26
+ if (!parsed.success) {
27
+ return NextResponse.json(
28
+ { error: "Invalid request", details: parsed.error.flatten() },
29
+ { status: 400 },
30
+ );
31
+ }
32
+
33
+ const { id, eventId, accepted, revisionRequested, notes } = parsed.data;
34
+
35
+ const existing = getCommitmentById(id);
36
+ if (!existing) {
37
+ return NextResponse.json({ error: `Commitment not found: ${id}` }, { status: 404 });
38
+ }
39
+
40
+ const already = (existing.auditEvents || []).some(
41
+ (e) => e.event === "outcome" && (e.detail || "").includes(`eventId=${eventId}`),
42
+ );
43
+
44
+ if (already) {
45
+ return NextResponse.json({
46
+ success: true,
47
+ alreadyRecorded: true,
48
+ id,
49
+ timestamp: new Date().toISOString(),
50
+ });
51
+ }
52
+
53
+ const acceptedWithoutRevision = accepted && !revisionRequested;
54
+
55
+ recordAcceptanceOutcome({ acceptedWithoutRevision });
56
+
57
+ const detail = [
58
+ `eventId=${eventId}`,
59
+ `accepted=${accepted}`,
60
+ `revisionRequested=${revisionRequested}`,
61
+ notes ? `notes=${notes}` : "",
62
+ ]
63
+ .filter(Boolean)
64
+ .join(" ");
65
+
66
+ try {
67
+ addCommitmentAuditEvent(id, "outcome", detail);
68
+ } catch (e) {
69
+ console.error("[commitments/outcome] audit error:", e);
70
+ }
71
+
72
+ // Update the contract status based on outcome feedback.
73
+ try {
74
+ if (accepted) {
75
+ updateCommitmentStatus(id, "completed", "Outcome recorded: accepted");
76
+ } else if (revisionRequested) {
77
+ updateCommitmentStatus(id, "executing", "Outcome recorded: revision requested");
78
+ } else {
79
+ updateCommitmentStatus(id, "escalated", "Outcome recorded: rejected");
80
+ }
81
+ } catch (e) {
82
+ console.error("[commitments/outcome] status update error:", e);
83
+ }
84
+
85
+ // Ensure latest contract is persisted
86
+ const updated = getCommitmentById(id);
87
+ if (updated) {
88
+ upsertCommitment(updated);
89
+ }
90
+
91
+ return NextResponse.json({
92
+ success: true,
93
+ alreadyRecorded: false,
94
+ id,
95
+ acceptedWithoutRevision,
96
+ timestamp: new Date().toISOString(),
97
+ });
98
+ } catch (e: any) {
99
+ console.error("[commitments/outcome] error:", e);
100
+ return NextResponse.json({ error: e.message }, { status: 500 });
101
+ }
102
+ }
src/app/api/commitments/route.ts ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { listCommitments, loadMetrics } from "@/lib/commitment/store";
3
+
4
+ export const dynamic = "force-dynamic";
5
+ export const maxDuration = 30;
6
+
7
+ export async function GET(request: NextRequest) {
8
+ try {
9
+ const commitments = listCommitments();
10
+ const metrics = loadMetrics();
11
+
12
+ const limitRaw = request.nextUrl.searchParams.get("limit");
13
+ const limit = limitRaw ? Math.max(1, Math.min(500, parseInt(limitRaw))) : 200;
14
+
15
+ return NextResponse.json({
16
+ success: true,
17
+ count: commitments.length,
18
+ commitments: commitments.slice(0, limit),
19
+ metrics,
20
+ timestamp: new Date().toISOString(),
21
+ });
22
+ } catch (e: any) {
23
+ console.error("[commitments] error:", e);
24
+ return NextResponse.json({ error: e.message }, { status: 500 });
25
+ }
26
+ }
src/app/api/competitive/actions/route.ts ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import {
3
+ generateCompetitivePlan,
4
+ updateActionStatus,
5
+ recordActionOutcome,
6
+ } from "@/lib/competitive/engine";
7
+ import { ActionStatus, ActionOutcome } from "@/types";
8
+
9
+ export const dynamic = "force-dynamic";
10
+
11
+ export async function GET(req: NextRequest) {
12
+ const employeeId = req.nextUrl.searchParams.get("employeeId") || "emp-001";
13
+
14
+ try {
15
+ const plan = generateCompetitivePlan(employeeId);
16
+ if (!plan) {
17
+ return NextResponse.json(
18
+ { error: `Employee not found: ${employeeId}` },
19
+ { status: 404 },
20
+ );
21
+ }
22
+ return NextResponse.json(plan.portfolio);
23
+ } catch (e) {
24
+ console.error("[api/competitive/actions] GET error:", e);
25
+ return NextResponse.json(
26
+ { error: "Failed to get actions" },
27
+ { status: 500 },
28
+ );
29
+ }
30
+ }
31
+
32
+ export async function POST(req: NextRequest) {
33
+ try {
34
+ const body = await req.json();
35
+ const { actionId, operation, feedback, outcome, context } = body;
36
+
37
+ if (!actionId || !operation) {
38
+ return NextResponse.json(
39
+ { error: "actionId and operation are required" },
40
+ { status: 400 },
41
+ );
42
+ }
43
+
44
+ const validOperations = ["accept", "modify", "replace", "decline", "complete", "explain"];
45
+ if (!validOperations.includes(operation)) {
46
+ return NextResponse.json(
47
+ { error: `Invalid operation: ${operation}. Must be one of: ${validOperations.join(", ")}` },
48
+ { status: 400 },
49
+ );
50
+ }
51
+
52
+ const statusMap: Record<string, ActionStatus> = {
53
+ accept: "accepted",
54
+ modify: "modified",
55
+ replace: "replaced",
56
+ decline: "declined",
57
+ complete: "completed",
58
+ explain: "accepted",
59
+ };
60
+
61
+ const updated = updateActionStatus(actionId, statusMap[operation], feedback);
62
+
63
+ if (operation === "complete" && outcome) {
64
+ const outcomeRecord = recordActionOutcome({
65
+ actionId,
66
+ employeeId: body.employeeId || "emp-001",
67
+ experimentId: body.experimentId,
68
+ variant: body.variant,
69
+ actionTaken: body.actionTaken || updated?.title || "Action completed",
70
+ outcome: outcome as ActionOutcome["outcome"],
71
+ timeToOutcomeHours: body.timeToOutcomeHours || 0,
72
+ context: context || {},
73
+ });
74
+ return NextResponse.json({ action: updated, outcome: outcomeRecord });
75
+ }
76
+
77
+ return NextResponse.json({ action: updated });
78
+ } catch (e) {
79
+ console.error("[api/competitive/actions] POST error:", e);
80
+ return NextResponse.json(
81
+ { error: "Failed to update action" },
82
+ { status: 500 },
83
+ );
84
+ }
85
+ }
src/app/api/competitive/experiments/route.ts ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { getEngineState } from "@/lib/competitive/engine";
3
+ import { getActiveExperiments, getCompletedExperiments } from "@/lib/competitive/experiment";
4
+
5
+ export const dynamic = "force-dynamic";
6
+
7
+ export async function GET(req: NextRequest) {
8
+ const employeeId = req.nextUrl.searchParams.get("employeeId");
9
+ const status = req.nextUrl.searchParams.get("status") || "all";
10
+
11
+ try {
12
+ const state = getEngineState();
13
+ let experiments = state.experiments;
14
+
15
+ if (status === "active") {
16
+ experiments = getActiveExperiments(state.experiments);
17
+ } else if (status === "completed") {
18
+ experiments = getCompletedExperiments(state.experiments);
19
+ }
20
+
21
+ if (employeeId) {
22
+ const empOutcomes = state.outcomes.filter((o) => o.employeeId === employeeId);
23
+ const empExperimentIds = new Set(empOutcomes.map((o) => o.experimentId).filter(Boolean));
24
+ experiments = experiments.filter(
25
+ (e) => empExperimentIds.has(e.id) || e.status === "running",
26
+ );
27
+ }
28
+
29
+ return NextResponse.json({ experiments, total: experiments.length });
30
+ } catch (e) {
31
+ console.error("[api/competitive/experiments] error:", e);
32
+ return NextResponse.json(
33
+ { error: "Failed to get experiments" },
34
+ { status: 500 },
35
+ );
36
+ }
37
+ }
src/app/api/competitive/learnings/route.ts ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { getEngineState } from "@/lib/competitive/engine";
3
+ import {
4
+ getValidatedStrategies,
5
+ getExperimentalStrategies,
6
+ } from "@/lib/competitive/learning";
7
+
8
+ export const dynamic = "force-dynamic";
9
+
10
+ export async function GET(req: NextRequest) {
11
+ const filter = req.nextUrl.searchParams.get("filter") || "all";
12
+
13
+ try {
14
+ const state = getEngineState();
15
+ let strategies = state.strategies;
16
+
17
+ if (filter === "validated") {
18
+ strategies = getValidatedStrategies(state.strategies);
19
+ } else if (filter === "experimental") {
20
+ strategies = getExperimentalStrategies(state.strategies);
21
+ }
22
+
23
+ strategies = [...strategies].sort((a, b) => b.confidence - a.confidence);
24
+
25
+ return NextResponse.json({ strategies, total: strategies.length });
26
+ } catch (e) {
27
+ console.error("[api/competitive/learnings] error:", e);
28
+ return NextResponse.json(
29
+ { error: "Failed to get learnings" },
30
+ { status: 500 },
31
+ );
32
+ }
33
+ }
src/app/api/competitive/manager/route.ts ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { getManagerLabView } from "@/lib/competitive/engine";
3
+
4
+ export const dynamic = "force-dynamic";
5
+
6
+ export async function GET(req: NextRequest) {
7
+ const managerId = req.nextUrl.searchParams.get("managerId") || "emp-007";
8
+
9
+ try {
10
+ const view = getManagerLabView(managerId);
11
+ if (!view) {
12
+ return NextResponse.json(
13
+ { error: `Manager not found or not a manager role: ${managerId}` },
14
+ { status: 404 },
15
+ );
16
+ }
17
+ return NextResponse.json(view);
18
+ } catch (e) {
19
+ console.error("[api/competitive/manager] error:", e);
20
+ return NextResponse.json(
21
+ { error: "Failed to get manager lab view" },
22
+ { status: 500 },
23
+ );
24
+ }
25
+ }
src/app/api/competitive/plan/route.ts ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { generateCompetitivePlan } from "@/lib/competitive/engine";
3
+
4
+ export const dynamic = "force-dynamic";
5
+
6
+ export async function GET(req: NextRequest) {
7
+ const employeeId = req.nextUrl.searchParams.get("employeeId") || "emp-001";
8
+
9
+ try {
10
+ const plan = generateCompetitivePlan(employeeId);
11
+ if (!plan) {
12
+ return NextResponse.json(
13
+ { error: `Employee not found: ${employeeId}` },
14
+ { status: 404 },
15
+ );
16
+ }
17
+ return NextResponse.json(plan);
18
+ } catch (e) {
19
+ console.error("[api/competitive/plan] error:", e);
20
+ return NextResponse.json(
21
+ { error: "Failed to generate competitive plan" },
22
+ { status: 500 },
23
+ );
24
+ }
25
+ }
src/app/api/competitive/results/route.ts ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { getEngineState } from "@/lib/competitive/engine";
3
+
4
+ export const dynamic = "force-dynamic";
5
+
6
+ export async function GET(req: NextRequest) {
7
+ const employeeId = req.nextUrl.searchParams.get("employeeId");
8
+
9
+ try {
10
+ const state = getEngineState();
11
+ let outcomes = state.outcomes;
12
+
13
+ if (employeeId) {
14
+ outcomes = outcomes.filter((o) => o.employeeId === employeeId);
15
+ }
16
+
17
+ outcomes = [...outcomes].sort(
18
+ (a, b) => new Date(b.capturedAt).getTime() - new Date(a.capturedAt).getTime(),
19
+ );
20
+
21
+ return NextResponse.json({ outcomes, total: outcomes.length });
22
+ } catch (e) {
23
+ console.error("[api/competitive/results] error:", e);
24
+ return NextResponse.json(
25
+ { error: "Failed to get results" },
26
+ { status: 500 },
27
+ );
28
+ }
29
+ }
src/app/api/competitive/score/route.ts ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { generateCompetitivePlan } from "@/lib/competitive/engine";
3
+
4
+ export const dynamic = "force-dynamic";
5
+
6
+ export async function GET(req: NextRequest) {
7
+ const employeeId = req.nextUrl.searchParams.get("employeeId") || "emp-001";
8
+
9
+ try {
10
+ const plan = generateCompetitivePlan(employeeId);
11
+ if (!plan) {
12
+ return NextResponse.json(
13
+ { error: `Employee not found: ${employeeId}` },
14
+ { status: 404 },
15
+ );
16
+ }
17
+ return NextResponse.json(plan.score);
18
+ } catch (e) {
19
+ console.error("[api/competitive/score] error:", e);
20
+ return NextResponse.json(
21
+ { error: "Failed to compute score" },
22
+ { status: 500 },
23
+ );
24
+ }
25
+ }
src/app/api/competitive/trajectory/route.ts ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { generateCompetitivePlan } from "@/lib/competitive/engine";
3
+
4
+ export const dynamic = "force-dynamic";
5
+
6
+ export async function GET(req: NextRequest) {
7
+ const employeeId = req.nextUrl.searchParams.get("employeeId") || "emp-001";
8
+
9
+ try {
10
+ const plan = generateCompetitivePlan(employeeId);
11
+ if (!plan) {
12
+ return NextResponse.json(
13
+ { error: `Employee not found: ${employeeId}` },
14
+ { status: 404 },
15
+ );
16
+ }
17
+ return NextResponse.json(plan.trajectory);
18
+ } catch (e) {
19
+ console.error("[api/competitive/trajectory] error:", e);
20
+ return NextResponse.json(
21
+ { error: "Failed to generate trajectory" },
22
+ { status: 500 },
23
+ );
24
+ }
25
+ }
src/app/api/config/route.ts ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { loadConfig, saveConfig } from "@/lib/config";
3
+ import { AppConfig } from "@/types";
4
+
5
+ export const dynamic = "force-dynamic";
6
+
7
+ export async function GET() {
8
+ const config = loadConfig();
9
+ const safeConfig = {
10
+ ...config,
11
+ graph: {
12
+ ...config.graph,
13
+ clientSecret: config.graph.clientSecret ? "***" : "",
14
+ },
15
+ llm: {
16
+ ...config.llm,
17
+ apiKey: config.llm.apiKey ? "***" : "",
18
+ },
19
+ };
20
+ return NextResponse.json(safeConfig);
21
+ }
22
+
23
+ export async function PUT(request: NextRequest) {
24
+ try {
25
+ const body = await request.json() as Partial<AppConfig>;
26
+
27
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
28
+ return NextResponse.json({ error: "Request body must be a valid configuration object" }, { status: 400 });
29
+ }
30
+
31
+ const current = loadConfig();
32
+
33
+ const updated: AppConfig = {
34
+ graph: {
35
+ clientId: body.graph?.clientId ?? current.graph.clientId,
36
+ clientSecret: body.graph?.clientSecret && body.graph.clientSecret !== "***"
37
+ ? body.graph.clientSecret
38
+ : current.graph.clientSecret,
39
+ tenantId: body.graph?.tenantId ?? current.graph.tenantId,
40
+ mailbox: body.graph?.mailbox ?? current.graph.mailbox,
41
+ },
42
+ llm: {
43
+ provider: body.llm?.provider ?? current.llm.provider,
44
+ apiKey: body.llm?.apiKey && body.llm.apiKey !== "***"
45
+ ? body.llm.apiKey
46
+ : current.llm.apiKey,
47
+ model: body.llm?.model ?? current.llm.model,
48
+ endpoint: body.llm?.endpoint ?? current.llm.endpoint,
49
+ },
50
+ processing: {
51
+ autoProcess: body.processing?.autoProcess ?? current.processing.autoProcess,
52
+ pollInterval: body.processing?.pollInterval ?? current.processing.pollInterval,
53
+ maxEmailsPerSync: body.processing?.maxEmailsPerSync ?? current.processing.maxEmailsPerSync,
54
+ categories: body.processing?.categories ?? current.processing.categories,
55
+ extractionPrompt: body.processing?.extractionPrompt ?? current.processing.extractionPrompt,
56
+ },
57
+ export: {
58
+ format: body.export?.format ?? current.export.format,
59
+ outputPath: body.export?.outputPath ?? current.export.outputPath,
60
+ },
61
+ };
62
+
63
+ saveConfig(updated);
64
+ return NextResponse.json({ success: true });
65
+ } catch (e: any) {
66
+ return NextResponse.json({ error: e.message }, { status: 500 });
67
+ }
68
+ }
src/app/api/demo/seed/route.ts ADDED
@@ -0,0 +1,350 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from "next/server";
2
+ import { ProcessedEmailRecord, ParsedAttachmentData } from "@/types";
3
+ import { generateAnalysis } from "@/lib/analysis/generator";
4
+ import { saveProcessedEmails, loadProcessedEmails, saveSyncStatus } from "@/lib/config";
5
+ import { generateSampleCommitments } from "@/lib/commitment/detector";
6
+ import { listCommitments, upsertCommitment } from "@/lib/commitment/store";
7
+ import { nanoid } from "nanoid";
8
+
9
+ export const dynamic = "force-dynamic";
10
+
11
+ interface SampleEmail {
12
+ subject: string;
13
+ sender: string;
14
+ receivedDate: string;
15
+ body: string;
16
+ attachments: { name: string; type: string; parsedData: ParsedAttachmentData }[];
17
+ }
18
+
19
+ const SAMPLE_EMAILS: SampleEmail[] = [
20
+ {
21
+ subject: "Q3 Environmental Field Study Results — Lake Ontario Basin",
22
+ sender: "Dr. Sarah Chen",
23
+ receivedDate: "2026-07-28T14:30:00Z",
24
+ body: `Hi team,
25
+
26
+ Please find attached the Q3 environmental monitoring results for the Lake Ontario basin. The field study covered 12 sampling sites across the shoreline.
27
+
28
+ Key findings:
29
+ - pH levels averaged 7.8 (within normal range)
30
+ - Dissolved oxygen: 8.2 mg/L (improved from Q2's 7.1 mg/L)
31
+ - Phosphorus concentration: 0.045 mg/L (exceeds EPA threshold of 0.025 mg/L)
32
+ - Water temperature: 22.4°C average (2.1°C above historical mean)
33
+
34
+ The elevated phosphorus levels near site 7 require immediate attention. I've included the full CSV dataset and a PDF summary report.
35
+
36
+ Please review and let me know if you need any clarification.
37
+
38
+ Best regards,
39
+ Dr. Sarah Chen
40
+ Environmental Science Division`,
41
+ attachments: [
42
+ {
43
+ name: "field_data_q3.csv",
44
+ type: "csv",
45
+ parsedData: {
46
+ type: "csv",
47
+ rows: [
48
+ { site: "S1", ph: 7.6, dissolved_oxygen_mgL: 8.5, phosphorus_mgL: 0.021, temperature_C: 21.2, turbidity_NTU: 3.2 },
49
+ { site: "S2", ph: 7.9, dissolved_oxygen_mgL: 8.3, phosphorus_mgL: 0.028, temperature_C: 21.8, turbidity_NTU: 2.8 },
50
+ { site: "S3", ph: 7.7, dissolved_oxygen_mgL: 8.1, phosphorus_mgL: 0.033, temperature_C: 22.0, turbidity_NTU: 4.1 },
51
+ { site: "S4", ph: 7.8, dissolved_oxygen_mgL: 8.4, phosphorus_mgL: 0.019, temperature_C: 21.5, turbidity_NTU: 2.5 },
52
+ { site: "S5", ph: 8.0, dissolved_oxygen_mgL: 7.9, phosphorus_mgL: 0.041, temperature_C: 22.3, turbidity_NTU: 5.6 },
53
+ { site: "S6", ph: 7.5, dissolved_oxygen_mgL: 8.6, phosphorus_mgL: 0.024, temperature_C: 21.0, turbidity_NTU: 3.0 },
54
+ { site: "S7", ph: 7.3, dissolved_oxygen_mgL: 7.2, phosphorus_mgL: 0.067, temperature_C: 23.1, turbidity_NTU: 8.9 },
55
+ { site: "S8", ph: 7.8, dissolved_oxygen_mgL: 8.2, phosphorus_mgL: 0.030, temperature_C: 22.1, turbidity_NTU: 3.5 },
56
+ ],
57
+ metadata: { rowCount: 8, headers: ["site", "ph", "dissolved_oxygen_mgL", "phosphorus_mgL", "temperature_C", "turbidity_NTU"] },
58
+ },
59
+ },
60
+ {
61
+ name: "summary_report.pdf",
62
+ type: "pdf",
63
+ parsedData: {
64
+ type: "pdf",
65
+ text: `Environmental Field Study Summary — Q3 2026\nLake Ontario Basin Monitoring Program\n\nExecutive Summary:\nThis report presents water quality data from 8 sampling sites along the Lake Ontario shoreline. The study was conducted between July 1-15, 2026.\n\nKey Findings:\n1. Overall water quality is acceptable with pH within normal range (7.3-8.0)\n2. Dissolved oxygen levels improved 15% compared to Q2 2026\n3. Site S7 shows critical phosphorus levels (0.067 mg/L) — 2.7x EPA threshold\n4. Water temperatures are 2.1°C above historical mean, consistent with climate trends\n\nRecommendations:\n- Immediate investigation of phosphorus source near Site S7\n- Continued monthly monitoring at all sites\n- Expansion to include nitrogen compounds in Q4\n\nPrepared by: Dr. Sarah Chen, Environmental Science Division`,
66
+ metadata: { pages: 3 },
67
+ },
68
+ },
69
+ ],
70
+ },
71
+ {
72
+ subject: "Clinical Trial Phase II — Patient Response Data Batch 14",
73
+ sender: "Dr. Michael Rodriguez",
74
+ receivedDate: "2026-07-26T09:15:00Z",
75
+ body: `Team,
76
+
77
+ Batch 14 patient response data is attached. This batch covers patients 201-240 in the Phase II trial for compound NRX-417.
78
+
79
+ Summary:
80
+ - 40 patients evaluated
81
+ - 28 showed positive response (70%)
82
+ - 8 showed no significant change (20%)
83
+ - 4 showed adverse reactions (10%) — all mild, none severe
84
+ - Average response time: 6.3 days
85
+
86
+ The CSV contains per-patient metrics. Please analyze for statistical significance and prepare for the safety board review next week.
87
+
88
+ Dr. Rodriguez
89
+ Clinical Research Unit`,
90
+ attachments: [
91
+ {
92
+ name: "patient_batch14.csv",
93
+ type: "csv",
94
+ parsedData: {
95
+ type: "csv",
96
+ rows: [
97
+ { patient_id: "P201", age: 54, sex: "F", dose_mg: 50, response: "positive", response_days: 5, side_effects: "none" },
98
+ { patient_id: "P202", age: 61, sex: "M", dose_mg: 50, response: "positive", response_days: 7, side_effects: "mild_nausea" },
99
+ { patient_id: "P203", age: 47, sex: "F", dose_mg: 75, response: "positive", response_days: 4, side_effects: "none" },
100
+ { patient_id: "P204", age: 58, sex: "M", dose_mg: 50, response: "none", response_days: 14, side_effects: "none" },
101
+ { patient_id: "P205", age: 63, sex: "F", dose_mg: 75, response: "positive", response_days: 6, side_effects: "mild_headache" },
102
+ { patient_id: "P206", age: 52, sex: "M", dose_mg: 50, response: "positive", response_days: 8, side_effects: "none" },
103
+ { patient_id: "P207", age: 49, sex: "F", dose_mg: 100, response: "adverse", response_days: 3, side_effects: "mild_rash" },
104
+ { patient_id: "P208", age: 67, sex: "M", dose_mg: 75, response: "positive", response_days: 5, side_effects: "none" },
105
+ ],
106
+ metadata: { rowCount: 8, headers: ["patient_id", "age", "sex", "dose_mg", "response", "response_days", "side_effects"] },
107
+ },
108
+ },
109
+ ],
110
+ },
111
+ {
112
+ subject: "Lab Results — Spectroscopy Analysis of Compound XJ-22",
113
+ sender: "Lab Technician James Park",
114
+ receivedDate: "2026-07-25T16:45:00Z",
115
+ body: `Dr. Chen,
116
+
117
+ The spectroscopy analysis for compound XJ-22 is complete. Here are the key results:
118
+
119
+ - Molecular weight: 342.4 g/mol
120
+ - Melting point: 187°C
121
+ - Purity: 98.7% (HPLC)
122
+ - UV-Vis peak: 278 nm
123
+ - IR peaks: 3400, 2920, 1720, 1650, 1540 cm⁻¹
124
+
125
+ The full spectral data is in the attached text file. The compound appears to match the expected structure with high confidence.
126
+
127
+ James
128
+ Analytical Chemistry Lab`,
129
+ attachments: [
130
+ {
131
+ name: "spectral_data.txt",
132
+ type: "text",
133
+ parsedData: {
134
+ type: "text",
135
+ text: `SPECTROSCOPY REPORT — Compound XJ-22\nDate: 2026-07-25\nOperator: James Park\n\nMolecular Formula: C18H22O5N2\nMolecular Weight: 342.37 g/mol\nMelting Point: 187.2°C\nPurity (HPLC): 98.7%\n\nUV-Vis Spectroscopy:\n Peak 1: 278 nm (absorbance 0.847)\n Peak 2: 215 nm (absorbance 1.234)\n\nIR Spectroscopy (cm⁻¹):\n 3400 — O-H stretch\n 2920 — C-H stretch\n 1720 — C=O stretch (ester)\n 1650 — C=O stretch (amide)\n 1540 — N-H bend\n\nNMR (1H, 400MHz, DMSO-d6):\n δ 9.2 (s, 1H, NH)\n δ 7.3 (d, 2H, ArH)\n δ 6.8 (d, 2H, ArH)\n δ 4.1 (q, 2H, OCH2)\n δ 3.5 (s, 2H, CH2)\n δ 1.2 (t, 3H, CH3)\n\nConclusion: Structure confirmed with 98.7% confidence.`,
136
+ metadata: {},
137
+ },
138
+ },
139
+ ],
140
+ },
141
+ {
142
+ subject: "Research Collaboration Proposal — Marine Biodiversity Survey",
143
+ sender: "Prof. Elena Volkov",
144
+ receivedDate: "2026-07-24T11:00:00Z",
145
+ body: `Dear Colleagues,
146
+
147
+ I'm writing to propose a collaborative marine biodiversity survey in the Gulf of Maine, scheduled for September 2026.
148
+
149
+ Project scope:
150
+ - 15-day expedition covering 30 sampling stations
151
+ - Multi-depth trawl and eDNA sampling
152
+ - Target species: Atlantic cod, haddock, lobster, and 20+ indicator species
153
+ - Budget: $285,000 (shared between 3 institutions)
154
+ - Team: 12 researchers + 4 crew
155
+
156
+ The attached proposal document contains the full methodology, timeline, and budget breakdown. We'd need your institution's commitment by August 10th.
157
+
158
+ Looking forward to your response.
159
+
160
+ Prof. Elena Volkov
161
+ Marine Biology Department`,
162
+ attachments: [
163
+ {
164
+ name: "proposal_summary.txt",
165
+ type: "text",
166
+ parsedData: {
167
+ type: "text",
168
+ text: `MARINE BIODIVERSITY SURVEY — GULF OF MAINE\nProposal Date: July 24, 2026\nPrincipal Investigator: Prof. Elena Volkov\n\nDuration: 15 days (September 5-19, 2026)\nSampling Stations: 30\nBudget: $285,000\nTeam Size: 16 (12 researchers + 4 crew)\n\nObjectives:\n1. Assess population health of key commercial species\n2. Document biodiversity changes since 2024 survey\n3. Collect eDNA samples for metagenomic analysis\n4. Monitor ocean acidification impacts on shellfish\n\nMethodology:\n- Multi-depth trawl surveys (0-200m)\n- Environmental DNA sampling at each station\n- Benthic sediment core sampling\n- CTD profiler casts for water chemistry\n\nDeliverables:\n- Comprehensive biodiversity report\n- Species distribution maps\n- eDNA reference database\n- Policy recommendations for fisheries management`,
169
+ metadata: {},
170
+ },
171
+ },
172
+ ],
173
+ },
174
+ ];
175
+
176
+ export async function POST() {
177
+ try {
178
+ let existing: ProcessedEmailRecord[] = [];
179
+ try {
180
+ existing = loadProcessedEmails();
181
+ } catch (e) {
182
+ console.error("[demo/seed] loadProcessedEmails error:", e);
183
+ }
184
+ const records: ProcessedEmailRecord[] = [...existing];
185
+
186
+ for (const email of SAMPLE_EMAILS) {
187
+ if (records.some((r) => r.subject === email.subject)) continue;
188
+
189
+ const analysis = generateAnalysis({
190
+ subject: email.subject,
191
+ sender: email.sender,
192
+ body: email.body,
193
+ attachments: email.attachments,
194
+ });
195
+
196
+ // Demo data uses pre-built analysis (wikitree, mindmap, execution)
197
+ // LLM extraction is skipped — it's available via the /api/llm/infer endpoint
198
+ const llmSummary = "";
199
+ const llmCategory = categorizeEmail(email);
200
+ const llmConfidence = 0.85;
201
+
202
+ const fields: any[] = [];
203
+ const tables: any[] = [];
204
+
205
+ for (const att of email.attachments) {
206
+ if (att.parsedData?.rows && att.parsedData.rows.length > 0) {
207
+ const firstRow = att.parsedData.rows[0];
208
+ for (const [key, value] of Object.entries(firstRow)) {
209
+ fields.push({
210
+ key,
211
+ value: String(value || ""),
212
+ type: typeof value === "number" ? "number" : "string",
213
+ confidence: 0.9,
214
+ });
215
+ }
216
+ const headers = Object.keys(firstRow);
217
+ tables.push({
218
+ name: att.name,
219
+ headers,
220
+ rows: att.parsedData.rows.slice(0, 100),
221
+ source: att.name,
222
+ });
223
+ }
224
+ if (att.parsedData?.text) {
225
+ const lines = att.parsedData.text.split("\n").slice(0, 20);
226
+ for (const line of lines) {
227
+ const match = line.match(/^([A-Za-z][A-Za-z0-9\s]+):\s*(.+)$/);
228
+ if (match) {
229
+ fields.push({
230
+ key: match[1].trim(),
231
+ value: match[2].trim(),
232
+ type: "string",
233
+ confidence: 0.7,
234
+ });
235
+ }
236
+ }
237
+ }
238
+ }
239
+
240
+ const category = llmCategory;
241
+
242
+ records.push({
243
+ id: nanoid(),
244
+ emailId: `demo-${nanoid(8)}`,
245
+ subject: email.subject,
246
+ sender: email.sender,
247
+ receivedDate: email.receivedDate,
248
+ processedAt: new Date().toISOString(),
249
+ category,
250
+ confidence: llmConfidence,
251
+ fieldCount: fields.length,
252
+ tableCount: tables.length,
253
+ extractedData: {
254
+ emailId: `demo-${nanoid(8)}`,
255
+ extractedAt: new Date().toISOString(),
256
+ fields: fields.slice(0, 50),
257
+ tables,
258
+ summary: llmSummary || analysis.execution.summary,
259
+ category,
260
+ confidence: llmConfidence,
261
+ source: email.attachments.length > 0 ? "attachment" : "email_body",
262
+ },
263
+ analysis,
264
+ });
265
+ }
266
+
267
+ // Try to persist to filesystem (works locally, silently fails on serverless)
268
+ try {
269
+ saveProcessedEmails(records);
270
+ saveSyncStatus({
271
+ lastSync: new Date().toISOString(),
272
+ totalEmails: records.length,
273
+ processedEmails: records.length,
274
+ pendingEmails: 0,
275
+ isSyncing: false,
276
+ errors: [],
277
+ });
278
+ } catch (e) {
279
+ console.error("[demo/seed] persist error:", e);
280
+ }
281
+
282
+ let commitments: any[] = [];
283
+ let commitmentsSeeded = 0;
284
+ try {
285
+ const existingCommitments = listCommitments();
286
+ if (existingCommitments.length === 0) {
287
+ const samples = generateSampleCommitments();
288
+ for (const c of samples) {
289
+ try {
290
+ upsertCommitment(c);
291
+ commitmentsSeeded++;
292
+ } catch (e: any) {
293
+ console.error("[demo/seed] commitment seed error:", e.message);
294
+ }
295
+ }
296
+ commitments = samples;
297
+ } else {
298
+ commitments = existingCommitments;
299
+ }
300
+ } catch (e: any) {
301
+ console.error("[demo/seed] commitments error:", e.message);
302
+ }
303
+
304
+ return NextResponse.json({
305
+ seeded: records.length - existing.length,
306
+ total: records.length,
307
+ message: `Loaded ${records.length - existing.length} real processed emails through the analysis pipeline`,
308
+ records, // Include records so frontend can store them in localStorage
309
+ commitments,
310
+ commitmentsSeeded,
311
+ status: {
312
+ lastSync: new Date().toISOString(),
313
+ totalEmails: records.length,
314
+ processedEmails: records.length,
315
+ pendingEmails: 0,
316
+ isSyncing: false,
317
+ errors: [],
318
+ },
319
+ });
320
+ } catch (e: any) {
321
+ console.error("[demo/seed] error:", e);
322
+ return NextResponse.json({ error: e.message }, { status: 500 });
323
+ }
324
+ }
325
+
326
+ function categorizeEmail(email: SampleEmail): string {
327
+ const subject = email.subject.toLowerCase();
328
+ if (subject.includes("field study") || subject.includes("environmental")) return "Environmental Data";
329
+ if (subject.includes("clinical") || subject.includes("trial")) return "Clinical Trial";
330
+ if (subject.includes("lab") || subject.includes("spectroscopy")) return "Lab Results";
331
+ if (subject.includes("research") || subject.includes("proposal")) return "Research Data";
332
+ return "Other";
333
+ }
334
+
335
+ export async function DELETE() {
336
+ try {
337
+ saveProcessedEmails([]);
338
+ saveSyncStatus({
339
+ lastSync: null,
340
+ totalEmails: 0,
341
+ processedEmails: 0,
342
+ pendingEmails: 0,
343
+ isSyncing: false,
344
+ errors: [],
345
+ });
346
+ } catch (e) {
347
+ console.error("[demo/seed] delete error:", e);
348
+ }
349
+ return NextResponse.json({ cleared: true });
350
+ }
src/app/api/gmail/action/route.ts ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import {
3
+ markAsReadREST,
4
+ markAsUnreadREST,
5
+ starEmailREST,
6
+ archiveEmailREST,
7
+ trashEmailREST,
8
+ snoozeEmailREST,
9
+ modifyLabelsREST,
10
+ } from "@/lib/gmail/rest-client";
11
+ import { normalizeOrigin, getRequestOrigin } from "@/lib/utils";
12
+
13
+ export const dynamic = "force-dynamic";
14
+ export const maxDuration = 30;
15
+
16
+ /**
17
+ * Generic email action endpoint.
18
+ * POST /api/gmail/action
19
+ * Body: { action: "read"|"unread"|"star"|"archive"|"trash"|"snooze"|"modify", messageId, ... }
20
+ */
21
+ export async function POST(request: NextRequest) {
22
+ try {
23
+ const body = await request.json().catch(() => ({}));
24
+ const clientId = process.env.GMAIL_CLIENT_ID || "";
25
+ const clientSecret = process.env.GMAIL_CLIENT_SECRET || "";
26
+ const refreshToken = body.refreshToken || request.cookies.get("gmail-refresh-token")?.value || "";
27
+
28
+ if (!clientId || !refreshToken) {
29
+ return NextResponse.json({ error: "Gmail not connected" }, { status: 401 });
30
+ }
31
+
32
+ if (!body.messageId) {
33
+ return NextResponse.json({ error: "messageId is required" }, { status: 400 });
34
+ }
35
+
36
+ const redirectUri = `${normalizeOrigin(getRequestOrigin(request))}/api/gmail/callback`;
37
+ const config = { clientId, clientSecret, redirectUri, refreshToken, emailAddress: "" };
38
+
39
+ switch (body.action) {
40
+ case "read":
41
+ await markAsReadREST(config, body.messageId);
42
+ return NextResponse.json({ success: true, action: "read" });
43
+
44
+ case "unread":
45
+ await markAsUnreadREST(config, body.messageId);
46
+ return NextResponse.json({ success: true, action: "unread" });
47
+
48
+ case "star":
49
+ await starEmailREST(config, body.messageId);
50
+ return NextResponse.json({ success: true, action: "star" });
51
+
52
+ case "archive":
53
+ await archiveEmailREST(config, body.messageId);
54
+ return NextResponse.json({ success: true, action: "archive" });
55
+
56
+ case "trash":
57
+ await trashEmailREST(config, body.messageId);
58
+ return NextResponse.json({ success: true, action: "trash" });
59
+
60
+ case "snooze":
61
+ if (!body.snoozeUntil) {
62
+ return NextResponse.json({ error: "snoozeUntil is required for snooze action" }, { status: 400 });
63
+ }
64
+ await snoozeEmailREST(config, body.messageId, body.snoozeUntil);
65
+ return NextResponse.json({ success: true, action: "snooze", snoozeUntil: body.snoozeUntil });
66
+
67
+ case "modify":
68
+ await modifyLabelsREST(config, body.messageId, {
69
+ addLabelIds: body.addLabelIds,
70
+ removeLabelIds: body.removeLabelIds,
71
+ });
72
+ return NextResponse.json({ success: true, action: "modify" });
73
+
74
+ default:
75
+ return NextResponse.json({
76
+ error: `Unknown action: ${body.action}`,
77
+ availableActions: ["read", "unread", "star", "archive", "trash", "snooze", "modify"],
78
+ }, { status: 400 });
79
+ }
80
+ } catch (e: any) {
81
+ return NextResponse.json({ error: e.message }, { status: 500 });
82
+ }
83
+ }
src/app/api/gmail/auth/route.ts ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { getAuthUrl } from "@/lib/gmail/client";
3
+ import { loadConfig } from "@/lib/config";
4
+ import { normalizeOrigin, getRequestOrigin } from "@/lib/utils";
5
+
6
+ export const dynamic = "force-dynamic";
7
+
8
+ /**
9
+ * Returns the Google OAuth authorization URL.
10
+ * Credentials are read exclusively from server environment variables —
11
+ * the client never sees or handles the client secret.
12
+ */
13
+ export async function GET(request: NextRequest) {
14
+ let config;
15
+ try {
16
+ config = loadConfig();
17
+ } catch (e) {
18
+ console.error("[gmail/auth] loadConfig error:", e);
19
+ config = { graph: {} };
20
+ }
21
+
22
+ // Only use server-side env vars or config file — never accept credentials from query params
23
+ const clientId =
24
+ process.env.GMAIL_CLIENT_ID ||
25
+ config.graph?.clientId ||
26
+ "";
27
+ const clientSecret =
28
+ process.env.GMAIL_CLIENT_SECRET ||
29
+ config.graph?.clientSecret ||
30
+ "";
31
+
32
+ if (!clientId || !clientSecret) {
33
+ return NextResponse.json({
34
+ error: "Gmail OAuth is not configured on the server. Ask your administrator to set GMAIL_CLIENT_ID and GMAIL_CLIENT_SECRET environment variables.",
35
+ }, { status: 500 });
36
+ }
37
+
38
+ const redirectUri = `${normalizeOrigin(getRequestOrigin(request))}/api/gmail/callback`;
39
+
40
+ const authUrl = getAuthUrl({
41
+ clientId,
42
+ clientSecret,
43
+ redirectUri,
44
+ refreshToken: "",
45
+ emailAddress: "",
46
+ });
47
+
48
+ return NextResponse.json({ authUrl });
49
+ }
src/app/api/gmail/body/route.ts ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { fetchEmailBodyREST } from "@/lib/gmail/rest-client";
3
+ import { normalizeOrigin, getRequestOrigin } from "@/lib/utils";
4
+
5
+ export const dynamic = "force-dynamic";
6
+ export const maxDuration = 30;
7
+
8
+ /**
9
+ * POST /api/gmail/body
10
+ * Fetches the full body of a single email on demand.
11
+ * Body: { messageId, refreshToken? }
12
+ */
13
+ export async function POST(request: NextRequest) {
14
+ try {
15
+ const body = await request.json().catch(() => ({}));
16
+ const clientId = process.env.GMAIL_CLIENT_ID || "";
17
+ const clientSecret = process.env.GMAIL_CLIENT_SECRET || "";
18
+ const refreshToken = body.refreshToken || request.cookies.get("gmail-refresh-token")?.value || "";
19
+
20
+ if (!clientId || !refreshToken) {
21
+ return NextResponse.json({ error: "Gmail not connected" }, { status: 401 });
22
+ }
23
+
24
+ if (!body.messageId) {
25
+ return NextResponse.json({ error: "messageId required" }, { status: 400 });
26
+ }
27
+
28
+ const redirectUri = `${normalizeOrigin(getRequestOrigin(request))}/api/gmail/callback`;
29
+ const rawBody = await fetchEmailBodyREST(
30
+ { clientId, clientSecret, redirectUri, refreshToken, emailAddress: "" },
31
+ body.messageId
32
+ );
33
+
34
+ // Strip HTML tags if the body is HTML
35
+ const cleanBody = rawBody.includes("<") && rawBody.includes(">")
36
+ ? rawBody
37
+ .replace(/<style[^>]*>[\s\S]*?<\/style>/gi, "")
38
+ .replace(/<script[^>]*>[\s\S]*?<\/script>/gi, "")
39
+ .replace(/<[^>]+>/g, "")
40
+ .replace(/&nbsp;/g, " ")
41
+ .replace(/&amp;/g, "&")
42
+ .replace(/&lt;/g, "<")
43
+ .replace(/&gt;/g, ">")
44
+ .replace(/&#39;/g, "'")
45
+ .replace(/&quot;/g, '"')
46
+ .replace(/\n{3,}/g, "\n\n")
47
+ .trim()
48
+ : rawBody;
49
+
50
+ return NextResponse.json({ body: cleanBody || "(no body)" });
51
+ } catch (e: any) {
52
+ return NextResponse.json({ error: e.message }, { status: 500 });
53
+ }
54
+ }
src/app/api/gmail/callback/route.ts ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextRequest, NextResponse } from "next/server";
2
+ import { getRequestOrigin } from "@/lib/utils";
3
+
4
+ export const dynamic = "force-dynamic";
5
+
6
+ export async function GET(request: NextRequest) {
7
+ const code = request.nextUrl.searchParams.get("code");
8
+ const error = request.nextUrl.searchParams.get("error");
9
+ const origin = getRequestOrigin(request);
10
+
11
+ if (error) {
12
+ return NextResponse.redirect(`${origin}/?gmail_error=${error}`);
13
+ }
14
+
15
+ if (!code) {
16
+ return NextResponse.redirect(`${origin}/?gmail_error=no_code`);
17
+ }
18
+
19
+ // Redirect to client-side page that will exchange the code using localStorage credentials
20
+ return NextResponse.redirect(`${origin}/gmail/connect?code=${encodeURIComponent(code)}`);
21
+ }
src/app/api/gmail/config/route.ts ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NextResponse } from "next/server";
2
+ import { loadConfig } from "@/lib/config";
3
+
4
+ export const dynamic = "force-dynamic";
5
+
6
+ /**
7
+ * Returns whether Gmail OAuth is configured server-side (env vars or config file).
8
+ * Exposes the clientId (not secret) so the client can build auth URLs without
9
+ * requiring the user to enter credentials manually.
10
+ */
11
+ export async function GET() {
12
+ let config;
13
+ try {
14
+ config = loadConfig();
15
+ } catch (e) {
16
+ console.error("[gmail/config] loadConfig error:", e);
17
+ config = { graph: {} };
18
+ }
19
+
20
+ const clientId =
21
+ process.env.GMAIL_CLIENT_ID ||
22
+ config.graph?.clientId ||
23
+ "";
24
+ const clientSecret =
25
+ process.env.GMAIL_CLIENT_SECRET ||
26
+ config.graph?.clientSecret ||
27
+ "";
28
+
29
+ return NextResponse.json({
30
+ configured: !!(clientId && clientSecret),
31
+ clientId, // not secret — needed to build auth URL client-side
32
+ hasSecret: !!clientSecret,
33
+ });
34
+ }