Skip to content

Latest commit

 

History

History
296 lines (217 loc) · 9.95 KB

build-guide.md

File metadata and controls

296 lines (217 loc) · 9.95 KB

Tension Map — Cursor Build Guide

What you are building

A deployable Next.js web app called Tension Map. It takes any argumentative text, calls an LLM API via a server-side proxy route (so the API key never touches the client), and renders the argument's claims, assumptions, and tensions as an interactive D3 force-directed graph. Clicking a tension node opens a panel showing both poles, the shared assumption, and one open question addressed to the reader — but no verdict, no score, no resolution.

Three pre-loaded example texts work instantly with no API call (mock graphs are bundled). Custom text calls the server route.

The project will be open-sourced on GitHub with no secrets in the repo. API keys live only in Vercel environment variables.


Tech stack

  • Next.js 14 with the App Router
  • React (client components where needed)
  • D3 v7 for the force-directed graph
  • No CSS framework — all styles are inline or in globals.css
  • No database — fully stateless
  • API key: stored in a Vercel environment variable, used only in the server-side API route

File structure to create

tension-map/
├── app/
│   ├── layout.jsx          ← root layout, sets metadata and globals
│   ├── page.jsx            ← imports and renders TensionMap component
│   ├── globals.css         ← minimal reset only
│   └── api/
│       └── extract/
│           └── route.js    ← server-side POST handler, calls LLM with API key
├── components/
│   └── TensionMap.jsx      ← THE MAIN COMPONENT (provided — copy from folder)
├── .env.local              ← NOT committed; holds API key for local dev
├── .env.example            ← committed; shows required var names with empty values
├── .gitignore
├── next.config.js
├── package.json
└── README.md

Step-by-step instructions

1. Initialise the project

npx create-next-app@latest tension-map --app --js --no-tailwind --no-eslint --no-src-dir
cd tension-map
npm install d3

When prompted, accept the defaults. The --app flag enables the App Router.

2. Copy the component

Copy TensionMap.jsx (provided in this folder) into components/TensionMap.jsx. Do not modify it yet — get the scaffold working first.

3. Create app/page.jsx

import TensionMap from '@/components/TensionMap';

export const metadata = {
  title: 'Tension Map',
  description: 'Paste any argument — see what it isn\'t resolving',
};

export default function Home() {
  return <TensionMap />;
}

4. Create app/layout.jsx

import './globals.css';

export default function RootLayout({ children }) {
  return (
    <html lang="en">
      <body style={{ margin: 0, padding: 0 }}>{children}</body>
    </html>
  );
}

5. Create app/globals.css

*, *::before, *::after { box-sizing: border-box; }
body { margin: 0; font-family: system-ui, -apple-system, sans-serif; }

6. Create the API proxy route — app/api/extract/route.js

This is the only file that touches the API key. The key is read from process.env.LLM_API_KEY at runtime on the server. It is never exposed to the browser.

import { NextResponse } from 'next/server';

const EXTRACTION_PROMPT = `Analyze this argument and extract its logical structure. Return ONLY valid JSON, no preamble, no markdown fences.

Extract:
- 5-7 "nodes" of type "claim" (concrete assertions, max 12 words) or "assumption" (unstated premises, max 10 words)
- 3-5 "edges" with types: "supports", "contradicts", or "presupposes"
- 2-3 "tensions" where claims or assumptions genuinely pull against each other

Each tension must include: pole_a and pole_b (node ids in conflict), shared_assumption (1 sentence — the premise making both poles seem valid simultaneously), open_question (1 sentence starting "What would have to be true…" addressed to the reader).

Schema:
{"nodes":[{"id":"c1","type":"claim","text":"Short text"},{"id":"a1","type":"assumption","text":"Unstated premise"}],"edges":[{"from":"c1","to":"c2","type":"supports"}],"tensions":[{"id":"t1","pole_a":"c1","pole_b":"c2","shared_assumption":"One sentence.","open_question":"What would have to be true…?"}]}

Text:
`;

export async function POST(request) {
  try {
    const { text } = await request.json();
    if (!text || text.trim().length < 50) {
      return NextResponse.json({ error: 'Text too short — paste a full paragraph.' }, { status: 400 });
    }

    // --- Anthropic ---
    // To use Anthropic instead of DeepSeek: swap the URL and headers below.
    // Anthropic URL:  https://api.anthropic.com/v1/messages
    // Anthropic headers: { 'x-api-key': process.env.LLM_API_KEY, 'anthropic-version': '2023-06-01', 'Content-Type': 'application/json' }
    // Anthropic body model: "claude-sonnet-4-6"

    // --- DeepSeek (default — cheaper for the grant period) ---
    const response = await fetch('https://api.deepseek.com/v1/chat/completions', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${process.env.LLM_API_KEY}`,
      },
      body: JSON.stringify({
        model: 'deepseek-chat',
        max_tokens: 1200,
        messages: [
          { role: 'user', content: EXTRACTION_PROMPT + text }
        ],
      }),
    });

    if (!response.ok) {
      const err = await response.text();
      console.error('LLM API error:', err);
      return NextResponse.json({ error: 'LLM API call failed.' }, { status: 502 });
    }

    const data = await response.json();

    // DeepSeek returns choices[0].message.content
    // Anthropic returns content[0].text — swap this line if switching
    const raw = data.choices?.[0]?.message?.content || '';

    const clean = raw.replace(/```json|```/g, '').trim();
    const parsed = JSON.parse(clean);

    // Validate minimum shape
    if (!parsed.nodes || !parsed.tensions) {
      throw new Error('Unexpected response shape');
    }

    return NextResponse.json(parsed);
  } catch (e) {
    console.error('Extract error:', e);
    return NextResponse.json({ error: 'Could not parse argument structure.' }, { status: 500 });
  }
}

7. Create .env.local (not committed)

LLM_API_KEY=your_deepseek_or_anthropic_key_here

8. Create .env.example (committed — shows required vars with no values)

LLM_API_KEY=

9. Create .gitignore

.env.local
.env*.local
node_modules/
.next/

10. Create next.config.js

/** @type {import('next').NextConfig} */
const nextConfig = {};
module.exports = nextConfig;

11. Test locally

npm run dev

Open http://localhost:3000. The three example buttons should work instantly (no API call). Pasting custom text should call the API route. Check the terminal for any errors from the route handler.


Deploying to Vercel

Option A — Vercel CLI (fastest)

npm install -g vercel
vercel login
vercel

Follow the prompts. When asked about environment variables, skip for now — you'll add them in the dashboard.

Go to vercel.com → your project → Settings → Environment Variables and add:

Name Value Environment
LLM_API_KEY your actual key Production, Preview, Development

Then redeploy:

vercel --prod

Option B — GitHub integration

  1. Push the repo to GitHub (no secrets in the repo — .env.local is gitignored).
  2. Go to vercel.com → New Project → Import from GitHub.
  3. Add the LLM_API_KEY environment variable in the Vercel UI before deploying.
  4. Deploy. Vercel detects Next.js automatically.

Switching between DeepSeek and Anthropic

In app/api/extract/route.js, the comments mark exactly what to swap. The only differences are:

  • The URL (api.deepseek.com vs api.anthropic.com)
  • The auth header (Authorization: Bearer vs x-api-key)
  • One extra header for Anthropic (anthropic-version: 2023-06-01)
  • How you read the response (choices[0].message.content vs content[0].text)
  • The model name (deepseek-chat vs claude-sonnet-4-6)

What the grant period will build on top of this prototype

The 90-day plan, in order:

Month 1 (now → end of July)

  • Polish the extraction schema — better prompts, better short-text fallbacks
  • Add a shareable URL: encode the graph as a compressed URL param so a tension map can be sent as a link
  • Clean up the graph layout: pin tension nodes more centrally, improve label positioning for dense graphs

Month 2 (August)

  • Tension annotation: user can type their own answer to the open question directly in the panel; it persists in localStorage
  • "Resolved by me" toggle: mark a tension as resolved with a note; the node visually changes state
  • Export: download the tension map as JSON (the schema) or as a PNG

Month 3 (September)

  • Design partners: 10+ external users; document their cases
  • "What did the AI decide for you?" mode: paste an AI response specifically; the tool surfaces the value-choices the model made without declaring them
  • Open repo write-up: the extraction schema published as a standalone spec, with contribution guide

What NOT to add yet

  • User accounts, authentication, a database
  • Saving maps server-side
  • Mobile-native layout (the current layout works on mobile but is not optimised — that's month 2 scope)
  • Any pricing or paywall

Notes for Cursor

  • The TensionMap.jsx component is a client component ('use client' at top). Do not move its logic to a server component.
  • D3's forceLink requires source and target properties on link objects. The data uses from/to — the component maps these before passing to D3. Do not remove this mapping.
  • All styles are inline. Do not add Tailwind or a CSS framework — keep it portable.
  • The API route is the only place the API key is used. Never import process.env.LLM_API_KEY in client components.
  • The mock graphs in the component are intentional — they make the three example texts work instantly without an API round-trip. Keep them.