Deploy mailbox-automation Next.js app
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +3 -35
- .gitignore +5 -1
- .npmrc +1 -0
- Dockerfile +36 -11
- README.md +96 -8
- fly.toml +16 -0
- netlify.toml +14 -0
- next-env.d.ts +5 -0
- next.config.js +12 -0
- package-lock.json +0 -0
- package.json +45 -31
- postcss.config.js +6 -0
- public/.gitkeep +0 -0
- scripts/__pycache__/prior_art_generator.cpython-313.pyc +0 -0
- scripts/cicd_research.py +157 -0
- scripts/fractal_book.py +566 -0
- scripts/prior_art_generator.py +624 -0
- src/app/(app)/discovery-ledger/page.tsx +97 -0
- src/app/(app)/experiment/page.tsx +2 -0
- src/app/(app)/experiments/page.tsx +140 -0
- src/app/(app)/foundry/page.tsx +279 -0
- src/app/(app)/golden-nodes/page.tsx +41 -0
- src/app/(app)/history/page.tsx +2 -0
- src/app/(app)/inbox/page.tsx +16 -0
- src/app/(app)/layout.tsx +10 -0
- src/app/(app)/learnings/page.tsx +112 -0
- src/app/(app)/process-lab/page.tsx +119 -0
- src/app/(app)/results/page.tsx +38 -0
- src/app/(app)/spinor/page.tsx +260 -0
- src/app/(app)/today/page.tsx +56 -0
- src/app/api/azure/config/route.ts +49 -0
- src/app/api/commitments/detect/route.ts +39 -0
- src/app/api/commitments/execute/route.ts +70 -0
- src/app/api/commitments/outcome/route.ts +102 -0
- src/app/api/commitments/route.ts +26 -0
- src/app/api/competitive/actions/route.ts +85 -0
- src/app/api/competitive/experiments/route.ts +37 -0
- src/app/api/competitive/learnings/route.ts +33 -0
- src/app/api/competitive/manager/route.ts +25 -0
- src/app/api/competitive/plan/route.ts +25 -0
- src/app/api/competitive/results/route.ts +29 -0
- src/app/api/competitive/score/route.ts +25 -0
- src/app/api/competitive/trajectory/route.ts +25 -0
- src/app/api/config/route.ts +68 -0
- src/app/api/demo/seed/route.ts +350 -0
- src/app/api/gmail/action/route.ts +83 -0
- src/app/api/gmail/auth/route.ts +49 -0
- src/app/api/gmail/body/route.ts +54 -0
- src/app/api/gmail/callback/route.ts +21 -0
- src/app/api/gmail/config/route.ts +34 -0
.gitattributes
CHANGED
|
@@ -1,37 +1,5 @@
|
|
| 1 |
-
*.
|
| 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 |
-
*.
|
| 24 |
-
*.
|
| 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
|
| 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 |
-
#
|
| 4 |
-
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
-
# Build the app
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
|
|
|
|
|
|
| 11 |
|
| 12 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
EXPOSE 7860
|
| 14 |
|
| 15 |
-
|
| 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:
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
---
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
[](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 |
+
[](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": "
|
| 3 |
-
"version": "
|
|
|
|
| 4 |
"scripts": {
|
| 5 |
-
"
|
| 6 |
-
"build
|
| 7 |
-
"
|
| 8 |
-
"
|
| 9 |
-
"
|
| 10 |
-
"
|
| 11 |
},
|
| 12 |
-
"engines": {
|
| 13 |
-
"npm": ">=7.0.0",
|
| 14 |
-
"node": ">=18.0.0"
|
| 15 |
-
},
|
| 16 |
-
"type": "module",
|
| 17 |
"dependencies": {
|
| 18 |
-
"@
|
| 19 |
-
"@
|
| 20 |
-
"@
|
| 21 |
-
"@
|
| 22 |
-
"@
|
| 23 |
-
"
|
| 24 |
-
"
|
| 25 |
-
"
|
| 26 |
-
"
|
| 27 |
-
"
|
| 28 |
-
"
|
| 29 |
-
"
|
| 30 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
},
|
| 32 |
-
"
|
| 33 |
-
"
|
| 34 |
-
"
|
| 35 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(/ /g, " ")
|
| 41 |
+
.replace(/&/g, "&")
|
| 42 |
+
.replace(/</g, "<")
|
| 43 |
+
.replace(/>/g, ">")
|
| 44 |
+
.replace(/'/g, "'")
|
| 45 |
+
.replace(/"/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 |
+
}
|