This commit is contained in:
2026-04-05 00:43:23 +05:30
commit 8be37d3e92
425 changed files with 101853 additions and 0 deletions

54
negot8/.env.example Normal file
View File

@@ -0,0 +1,54 @@
# ─────────────────────────────────────────────────────────────────────────────
# negoT8 — Environment Variables Template
# Copy this file to .env and fill in your values:
# cp .env.example .env
#
# ⚠️ NEVER commit .env to Git — it is listed in .gitignore
# ─────────────────────────────────────────────────────────────────────────────
# ── AI ────────────────────────────────────────────────────────────────────────
# Get from: https://aistudio.google.com/app/apikey
GEMINI_API_KEY=your_gemini_api_key_here
# ── Telegram Bots ─────────────────────────────────────────────────────────────
# Get from: https://t.me/BotFather → /newbot
# Bot A = the initiator (User A's bot)
TELEGRAM_BOT_TOKEN_A=your_bot_a_token_here
# Bot B = the counterparty (User B's bot)
TELEGRAM_BOT_TOKEN_B=your_bot_b_token_here
# Bot C = optional third agent
TELEGRAM_BOT_TOKEN_C=your_bot_c_token_here
# ── Search ────────────────────────────────────────────────────────────────────
# Get from: https://tavily.com
TAVILY_API_KEY=your_tavily_api_key_here
# ── Voice ─────────────────────────────────────────────────────────────────────
# Get from: https://elevenlabs.io → Profile → API Key
ELEVENLABS_API_KEY=your_elevenlabs_api_key_here
# ── Frontend ──────────────────────────────────────────────────────────────────
NEXT_PUBLIC_API_URL=http://localhost:8000
# ── Database ──────────────────────────────────────────────────────────────────
# Path relative to the backend/ directory (default is fine)
DATABASE_PATH=negot8.db
# ── Google Calendar OAuth ─────────────────────────────────────────────────────
# Get from: https://console.cloud.google.com → APIs & Services → Credentials
GOOGLE_CLIENT_ID=your_google_client_id_here
GOOGLE_CLIENT_SECRET=your_google_client_secret_here
GOOGLE_REDIRECT_URI=http://localhost:8000/api/auth/google/callback
# ── Blockchain (Polygon Amoy testnet) ────────────────────────────────────────
# RPC from: https://rpc.ankr.com/polygon_amoy (free, no account needed)
POLYGON_RPC_URL=https://rpc.ankr.com/polygon_amoy
# Private key of a funded Amoy wallet (get test MATIC from https://faucet.polygon.technology)
POLYGON_PRIVATE_KEY=your_wallet_private_key_here
# Deployed contract address (from your deployment)
AGREEMENT_CONTRACT_ADDRESS=your_contract_address_here
# ── Mock Mode ─────────────────────────────────────────────────────────────────
# Set to "true" to bypass Gemini API calls and use hardcoded mock responses
# Set to "false" (or remove) to use real AI
MOCK_MODE=true

46
negot8/.gitignore vendored Normal file
View File

@@ -0,0 +1,46 @@
# Python
__pycache__/
*.py[cod]
*.pyo
*.pyd
*.so
*.egg-info/
.venv/
venv/
.env
.env.*
!.env.example
.env.backup
# Databases
*.db
*.sqlite
*.sqlite3
# Byte-compiled / optimized / DLL files
*.pyc
# Alembic
alembic/*.pyc
# Pytest
.pytest_cache/
# Coverage
.coverage
htmlcov/
# Logs and Caches
*.log
**/.mypy_cache/
**/.ruff_cache/
# Build artifacts
build/
dist/
# Audio
*.mp3
# Other
.DS_Store

816
negot8/README.md Normal file
View File

@@ -0,0 +1,816 @@
# negoT8
> Two AI agents negotiate on behalf of two humans. The first consumer implementation of agent-to-agent communication.
**Live Demo:** https://negot8-peach.vercel.app
**GitHub:** https://github.com/iamanirbanbasak/negot8
**Telegram Bots:** [@Anirban_negoT8_Bot](https://t.me/Anirban_negoT8_Bot) · [@Anirban_negoT82_Bot](https://t.me/Anirban_negoT82_Bot)
---
## What is negoT8?
negoT8 gives every user a personal AI agent on Telegram. When two people need to coordinate anything — schedule a meeting, split expenses, negotiate a freelance deal, plan a trip — their **agents talk to each other autonomously** and deliver a resolution. No arguments, no awkwardness, no back-and-forth.
Every resolved agreement is silently anchored on the **Polygon Amoy blockchain** as immutable proof. Users never see the word "blockchain" — they just get a `🔗 Verified` badge.
| Without negoT8 | With negoT8 |
|---|---|
| 12 messages to schedule a coffee | Agents check both calendars, confirm in seconds |
| Awkward bill-splitting conversation | Agents negotiate the split, send UPI link |
| Haggling over a freelance rate | Agents benchmark market rates, agree terms, generate PDF |
| No record of what was agreed | Agreement hash anchored on Polygon, verifiable forever |
---
## Table of Contents
1. [Architecture](#architecture)
2. [The 8 Coordination Features](#the-8-coordination-features)
3. [Agent System](#agent-system)
4. [Agent Personality System](#agent-personality-system)
5. [Blockchain and On-Chain Proof](#blockchain-and-on-chain-proof)
6. [Add-On Features](#add-on-features)
7. [Tech Stack](#tech-stack)
8. [Project Structure](#project-structure)
9. [Quick Start](#quick-start)
10. [Environment Variables](#environment-variables)
11. [API Reference](#api-reference)
12. [Dashboard](#dashboard)
13. [Database Schema](#database-schema)
14. [How a Negotiation Works](#how-a-negotiation-works)
15. [Running the Project](#running-the-project)
16. [Testing](#testing)
17. [Key Design Decisions](#key-design-decisions)
18. [Hackathon Demo Script](#hackathon-demo-script)
---
## Architecture
```
User A (Telegram) User B (Telegram)
| |
v v
Personal Agent A Personal Agent B
(extracts preferences) (extracts preferences)
| |
v v
Negotiator Agent A <--JSON--> Negotiator Agent B
(makes proposals) rounds (counters / accepts)
| |
+----------+ +----------------+
| |
v v
Resolution
- Telegram message
- Voice note (ElevenLabs)
- UPI payment link
- PDF contract
- Blockchain proof (Polygon)
- Live dashboard update
```
Every feature uses the same agent pipeline. What changes per feature is the preference schema, proposal format, available tools, and resolution template. The agent system is built once and plugged in.
---
## The 8 Coordination Features
### 1. Meeting Scheduling
Agents negotiate times, durations, and locations. If a user has not specified available times, the agent automatically queries their **Google Calendar** (OAuth) for real free slots.
Example:
```
User A: "I want to meet Priya for coffee this week. I'm free mornings."
Meeting Scheduled!
Thursday, March 6 at 10:00 AM · 1 hour
Blue Tokai, Bandra
Agents agreed in 2 rounds.
```
### 2. Expense Splitting
Handles bill splitting with exact arithmetic from the **Calculator tool** — the LLM never does arithmetic. Detects UPI IDs from natural language and auto-generates tap-to-pay links.
Example:
```
User A: "Split the Goa trip — hotel 12,000, fuel 3,500. My UPI is anirban@upi"
Expenses Settled!
Hotel: 6,000 each · Fuel: 1,750 each
Rahul owes you 7,750
[Tap to Pay via UPI]
```
### 3. Freelancer and Client Negotiation
Scope, budget, timeline, upfront percentage, and IP terms. Uses **Tavily AI search** to benchmark real market rates before negotiation. Detects budget shortfalls automatically.
### 4. Roommate Decisions
Shared living decisions — WiFi plans, chore schedules, shared purchases, house rules.
### 5. Group Trip Planning
Multi-constraint coordination across dates, budget, destination, accommodation type, and activities.
### 6. Marketplace Negotiation
Buy/sell haggling, automated. Agents negotiate price, payment method, and delivery terms.
### 7. Collaborative Decisions
Joint choices — restaurants, movies, activities, gifts. Uses **Tavily search** to find options that simultaneously match both parties' preferences.
### 8. Conflict Resolution
Disputes, disagreements, resource-sharing conflicts. Agents use an empathetic mediation approach to find creative win-wins.
### Generic (Catch-All)
Any coordination that does not fit the above 8 categories. The Personal Agent classifies it as `generic` and negotiation proceeds normally. No request is ever rejected.
---
## Agent System
All agents are powered by **Gemini 2.0 Flash** via the same `BaseAgent` wrapper.
### Personal Agent
Receives a raw Telegram message and extracts structured preferences:
```json
{
"feature_type": "expenses",
"goal": "Split Goa trip costs fairly",
"constraints": [
{ "type": "budget", "value": "max 10000", "hard": true }
],
"preferences": [
{ "type": "split_method", "value": "equal", "priority": "medium" }
],
"relationship": "friend",
"tone": "friendly",
"raw_details": {
"expenses": [{ "name": "hotel", "amount": 12000 }],
"upi_id": "anirban@upi"
}
}
```
### Negotiator Agent
The core bargaining brain. Makes proposals, evaluates counter-proposals, and decides when to accept, counter, or escalate. Hard constraints are **never violated** — a proposal that satisfies all hard constraints is always accepted regardless of the satisfaction score.
Each Negotiator response:
```json
{
"action": "counter",
"proposal": {
"summary": "55-45 fuel split, 50-50 everything else",
"details": { "fuel_split": "55-45", "hotel_split": "50-50" }
},
"satisfaction_score": 72,
"reasoning": "Conceding on fuel; hotel 50-50 is non-negotiable",
"concessions_made": ["accepted 55-45 fuel split"],
"concessions_requested": ["50-50 hotel split"]
}
```
Decision logic:
| Condition | Action |
|---|---|
| All hard constraints met | ACCEPT (always, overrides score) |
| Satisfaction >= 70 | ACCEPT |
| Satisfaction 40-69 | COUNTER |
| Satisfaction < 40 and round >= 3 | ESCALATE — presents 2-3 options to humans |
Maximum 7 rounds before forced escalation.
### Matching Agent
Scores how well a freelancer or applicant fits an open contract (0-100). Evaluates budget alignment, skill match, timeline overlap, and gap-bridging potential. Used in the open contract marketplace.
### Base Agent
Shared Gemini wrapper inherited by all agents. Always returns a plain Python dict. If the model wraps its response in markdown or adds surrounding text, the wrapper extracts the first valid JSON block. Never raises on parse failure.
---
## Agent Personality System
Users set their agent's negotiation style once with `/personality`. It persists and modifies every future negotiation.
```
/personality
[Aggressive] [Empathetic] [Analytical] [People Pleaser] [Balanced]
```
The selection is injected directly into the Negotiator Agent's system prompt:
**Aggressive** — Opens with ambitious proposals far in the user's favour. Concedes slowly in small increments. Uses anchoring to pull the other side toward its position.
**Empathetic** — Acknowledges the other side's concerns in every proposal. Offers concessions proactively on things the other side values more. Focuses on expanding the pie rather than dividing it.
**Analytical** — Backs every proposal with Tavily-sourced market data. Only concedes when presented with contradicting data. Frames all arguments with numbers.
**People Pleaser** — Prioritises relationship over outcome. Accepts at satisfaction 55+ (normally 70+). Resolves in 3 rounds or fewer when possible.
**Balanced (default)** — Moderate concession rate, matches the other side's pace. Targets 70+ satisfaction for both parties.
---
## Blockchain and On-Chain Proof
Every resolved negotiation is silently anchored on **Polygon Amoy TestNet**. If the chain is unavailable, the product still works fully — blockchain is additive, never blocking.
### Smart Contract
`AgreementRegistry.sol` deployed on Polygon Amoy:
```solidity
contract AgreementRegistry {
struct Agreement {
bytes32 agreementHash; // SHA-256 of the resolution JSON
string featureType;
string summary;
uint256 timestamp;
address registeredBy;
}
mapping(string => Agreement) public agreements;
function registerAgreement(
string calldata negotiationId,
bytes32 agreementHash,
string calldata featureType,
string calldata summary
) external;
function getAgreement(string calldata negotiationId)
external view returns (Agreement memory);
function totalAgreements() external view returns (uint256);
}
```
### On-Chain Flow
```
Resolution JSON
--> SHA-256 hash
--> Polygon Amoy transaction
--> TxHash stored in SQLite
--> PolygonScan link sent in Telegram
--> Verified badge shown on Dashboard
```
### Graceful Fallback
```python
try:
proof = await register_on_chain(resolution)
verify_url = f"https://amoy.polygonscan.com/tx/{proof['tx_hash']}"
except Exception:
verify_url = None # resolution still delivered, just without the proof link
```
What users see in Telegram:
```
Agreement secured on Polygon · View proof: amoy.polygonscan.com/tx/0xabc...
```
---
## Add-On Features
### Voice Summaries (ElevenLabs)
After every resolution a Telegram voice note is sent summarising the outcome. Feature-specific templates:
| Feature | Template |
|---|---|
| Expenses | "Expenses settled! After N rounds, X owes Y Z rupees." |
| Scheduling | "Meeting scheduled for DATE at TIME at LOCATION. Agreed in N rounds." |
| Freelance | "Project agreed! SCOPE for BUDGET rupees. First milestone payment ready via UPI." |
| Marketplace | "Deal done! ITEM for PRICE rupees. Payment link is ready." |
Each user can configure a different ElevenLabs voice ID for their agent.
### UPI Payment Deep Links
Generated automatically for any money-related resolution:
```
https://upi.link/payee@upi?amount=8750.00&cu=INR&remarks=Goa+trip+expenses
```
Sent as a Telegram inline button. Opens GPay, PhonePe, Paytm — any UPI app — with all fields pre-filled.
### Real-Time Analytics Dashboard
Live data streamed via Socket.IO:
- Satisfaction score curve per round for both parties (Recharts line graph)
- Concession timeline — who gave what up and in which round
- Fairness score — single balanced-outcome metric
- Feature type breakdown across all negotiations
### PDF Deal Agreements
For Freelance and Marketplace resolutions, a formatted PDF is generated via `fpdf2` and sent through Telegram. Includes party details, agreed terms, negotiation stats, and the blockchain proof hash. Deleted from server after delivery.
### Google Calendar Integration
```
/connectcalendar
--> OAuth URL sent to user
--> User authorises in browser
--> Token stored in DB
--> Scheduling agent auto-fetches free slots for next 7 days
```
Falls back gracefully if the token expires or Calendar is not connected.
### Open Contract Marketplace
Users post open contracts with `/postcontract`. Others apply with `/apply`. The Matching Agent scores each applicant (0-100) before the poster selects who to negotiate with.
### Tavily AI Search
Agents use Tavily for real-world grounding during negotiations:
- Restaurant discovery for collaborative decisions
- Market rate benchmarking for freelance deals
- Price comparison for marketplace negotiations
---
## Tech Stack
### Backend
| Package | Version | Purpose |
|---|---|---|
| fastapi | 0.128.8 | REST API and Socket.IO server |
| python-telegram-bot | 22.5 | Telegram bot (async) |
| google-generativeai | 0.8.6 | Gemini 2.0 Flash — all agents |
| aiosqlite | 0.22.1 | Async SQLite |
| web3 | 7.14.1 | Polygon Amoy blockchain |
| elevenlabs | 2.37.0 | Voice TTS |
| tavily-python | 0.7.22 | AI web search |
| fpdf2 | 2.8.3 | PDF generation |
| httpx | 0.28.1 | Async HTTP client |
| pydantic | 2.12.5 | Data validation |
| APScheduler | 3.11.2 | Background jobs |
### Frontend
| Package | Version | Purpose |
|---|---|---|
| next | 16.1.6 | React framework (App Router) |
| react | 19.2.3 | UI |
| recharts | 3.7.0 | Analytics charts |
| socket.io-client | 4.8.3 | Real-time updates |
| tailwindcss | 4 | Styling |
| lucide-react | 0.575.0 | Icons |
### External Services
| Service | Purpose | Free Tier |
|---|---|---|
| Gemini 2.0 Flash | All AI agents | 15 RPM, 1M TPM/day |
| ElevenLabs | Voice TTS | ~60K chars on $20 credit |
| Tavily | AI web search | 1,000 searches/month |
| Polygon Amoy | On-chain agreement proof | Free TestNet |
| Google Calendar API | Free slot lookup | Free OAuth |
---
## Project Structure
```
negot8/
├── backend/
│ ├── api.py # FastAPI app + Socket.IO server
│ ├── config.py # Env vars, API keys, voice IDs
│ ├── database.py # SQLite schema + all queries
│ ├── run.py # Starts bots + API together
│ ├── serve.py # API server only
│ │
│ ├── agents/
│ │ ├── base_agent.py # Gemini wrapper, always returns a dict
│ │ ├── personal_agent.py # Plain English to structured preferences JSON
│ │ ├── negotiator_agent.py # Bargaining brain with personality injection
│ │ ├── matching_agent.py # 0-100 applicant scorer for contract marketplace
│ │ └── negotiation.py # Multi-round loop + analytics tracking
│ │
│ ├── features/
│ │ ├── base_feature.py # Base class: get_context() + format_resolution()
│ │ ├── scheduling.py # + Google Calendar free-slot fallback
│ │ ├── expenses.py # + pre-calculated amounts via Calculator tool
│ │ ├── freelance.py # + Tavily market rate benchmarking
│ │ ├── roommate.py
│ │ ├── trip.py
│ │ ├── marketplace.py
│ │ ├── collaborative.py # + Tavily place search
│ │ ├── conflict.py
│ │ └── generic.py # Catch-all for any coordination type
│ │
│ ├── blockchain_web3/
│ │ ├── blockchain.py # Polygon Amoy: hash, register, graceful fallback
│ │ └── contract_abi.py # AgreementRegistry ABI
│ │
│ ├── tools/
│ │ ├── tavily_search.py # AI search wrapper
│ │ ├── calculator.py # Safe arithmetic, no LLM approximations
│ │ ├── upi_generator.py # UPI deep link generator
│ │ ├── google_calendar.py # OAuth + free slot fetching
│ │ └── pdf_generator.py # Deal agreement PDF via fpdf2
│ │
│ ├── voice/
│ │ └── elevenlabs_tts.py # TTS with feature-specific templates
│ │
│ ├── personality/
│ │ └── profiles.py # 5 negotiation personality prompt modifiers
│ │
│ ├── protocol/
│ │ └── messages.py # Pydantic message schemas
│ │
│ └── telegram-bots/
│ └── bot.py # All Telegram commands + conversation flows
├── contracts/
│ ├── AgreementRegistry.sol # Solidity smart contract
│ └── deploy.py # Deployment script
├── dashboard/ # Next.js 16 frontend
│ ├── app/
│ │ ├── page.tsx # Landing page
│ │ ├── dashboard/page.tsx # Live negotiations overview
│ │ ├── negotiation/[id]/ # Per-negotiation detail + analytics
│ │ ├── analytics/page.tsx # Global analytics charts
│ │ └── history/page.tsx # All past negotiations
│ ├── components/
│ │ ├── Sidebar.tsx
│ │ ├── NegotiationTimeline.tsx
│ │ ├── SatisfactionChart.tsx
│ │ ├── ConcessionTimeline.tsx
│ │ ├── FairnessScore.tsx
│ │ └── ResolutionCard.tsx
│ └── lib/
│ ├── api.ts # All API calls
│ ├── socket.ts # Socket.IO client
│ ├── types.ts # TypeScript types
│ └── utils.ts # Formatters and helpers
├── test/
│ ├── test_apis.py # Verify Gemini, Tavily, ElevenLabs keys
│ ├── test_negotiation.py # Full negotiation engine test
│ ├── test_personal_agent.py # Preference extraction test
│ ├── test_tools.py # Tavily, UPI, Calculator
│ └── test_pdf_generator.py # PDF generation test
├── docs/ # Architecture guides
├── requirements.txt
├── .env.example
└── SETUP.md
```
---
## Quick Start
Requirements: Python 3.11+, Node.js 18+
```bash
# Clone
git clone https://github.com/iamanirbanbasak/negot8.git
cd negot8
# Backend
python3 -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install -r requirements.txt
# Frontend
cd dashboard && npm install && cd ..
# Configure
cp .env.example .env
# Fill in all values — see Environment Variables below
# Init database
cd backend
python3 -c "import asyncio, database as db; asyncio.run(db.init_db())"
# Run backend (Terminal 1)
python3 run.py
# Run frontend (Terminal 2)
cd ../dashboard && npm run dev
```
- Backend API: http://localhost:8000
- Dashboard: http://localhost:3000
- Swagger docs: http://localhost:8000/docs
---
## Environment Variables
Copy `.env.example` to `.env` and fill in every value:
```
# Telegram — create bots via @BotFather
TELEGRAM_BOT_TOKEN_A=
TELEGRAM_BOT_TOKEN_B=
TELEGRAM_BOT_TOKEN_C= # optional, for 3-party demos
# AI
GEMINI_API_KEY= # https://aistudio.google.com/apikey
# Search
TAVILY_API_KEY= # https://app.tavily.com (free, no credit card)
# Voice
ELEVENLABS_API_KEY= # https://elevenlabs.io
# Blockchain — Polygon Amoy TestNet only, never use real funds
POLYGON_RPC_URL=https://rpc-amoy.polygon.technology/
POLYGON_PRIVATE_KEY= # TestNet wallet private key
AGREEMENT_CONTRACT_ADDRESS= # from contracts/deploy.py output
# Google Calendar OAuth
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=http://localhost:8000/api/auth/google/callback
# App
DATABASE_PATH=negot8.db
NEXT_PUBLIC_API_URL=http://localhost:8000
```
Blockchain is optional. Without `POLYGON_PRIVATE_KEY`, agreements are mocked and everything still works.
Get free TestNet MATIC from https://faucet.polygon.technology
---
## API Reference
Full interactive docs at http://localhost:8000/docs
### REST Endpoints
| Method | Endpoint | Description |
|---|---|---|
| GET | /api/stats | Totals, success rate, average rounds |
| GET | /api/negotiations | List all negotiations |
| GET | /api/negotiations/{id} | Full detail — rounds, analytics, blockchain proof |
| GET | /api/negotiations/{id}/rounds | All round proposals for a negotiation |
| GET | /api/analytics/{id} | Computed analytics for one negotiation |
| GET | /api/users/{telegram_id} | User profile and personality |
| GET | /api/auth/google/callback | Google Calendar OAuth callback |
| GET | /api/verify/{negotiation_id} | Blockchain proof lookup |
### Socket.IO Events (Server to Client)
| Event | Payload |
|---|---|
| negotiation_started | {negotiation_id, feature_type, participants} |
| round_update | {negotiation_id, round_number, action, proposal, satisfaction} |
| negotiation_resolved | {negotiation_id, resolution, blockchain_proof} |
---
## Dashboard
Built with Next.js 16 App Router, Tailwind CSS, Recharts, and Socket.IO.
**Pages:**
- `/` — Landing page with animated hero and live stat counters
- `/dashboard` — Real-time feed of all active and recent negotiations
- `/negotiation/[id]` — Per-negotiation view: round proposals, satisfaction chart, concession timeline, blockchain proof badge
- `/analytics` — Global charts: satisfaction trends, feature distribution, resolution rates
- `/history` — Searchable history of all past negotiations
**Key components:**
- `SatisfactionChart` — Recharts line graph of satisfaction per round for both parties
- `ConcessionTimeline` — Who gave what up and in which round
- `FairnessScore` — Single metric for how balanced the outcome was
- `NegotiationTimeline` — Full round-by-round proposal cards with action badges
---
## Database Schema
SQLite (`negot8.db`), 6 tables:
```sql
CREATE TABLE users (
telegram_id INTEGER PRIMARY KEY,
username TEXT,
display_name TEXT,
personality TEXT DEFAULT 'balanced',
voice_id TEXT,
calendar_token TEXT
);
CREATE TABLE negotiations (
id TEXT PRIMARY KEY,
feature_type TEXT,
status TEXT, -- pending | active | resolved | escalated
resolution TEXT, -- JSON
blockchain_txid TEXT,
blockchain_hash TEXT,
created_at TIMESTAMP
);
CREATE TABLE participants (
negotiation_id TEXT REFERENCES negotiations(id),
user_id INTEGER,
preferences TEXT, -- JSON
personality_used TEXT,
PRIMARY KEY (negotiation_id, user_id)
);
CREATE TABLE rounds (
id INTEGER PRIMARY KEY AUTOINCREMENT,
negotiation_id TEXT REFERENCES negotiations(id),
round_number INTEGER,
proposer_id INTEGER,
proposal TEXT, -- full Negotiator Agent JSON response
response_type TEXT, -- accept | counter | escalate
satisfaction_a REAL,
satisfaction_b REAL,
concessions_made TEXT,
created_at TIMESTAMP
);
CREATE TABLE negotiation_analytics (
negotiation_id TEXT PRIMARY KEY,
satisfaction_timeline TEXT, -- [{round, score_a, score_b}, ...]
concession_log TEXT,
fairness_score REAL,
computed_at TIMESTAMP
);
CREATE TABLE tool_calls (
id INTEGER PRIMARY KEY AUTOINCREMENT,
negotiation_id TEXT,
tool_name TEXT,
input TEXT,
output TEXT,
called_at TIMESTAMP
);
```
---
## How a Negotiation Works
Complete walkthrough for an expense split:
```
1. User A -> /coordinate @rahul
2. Bot -> "Tell me what you need"
3. User A -> "Split Goa trip. Hotel 12,000, fuel 3,500. My UPI anirban@upi"
4. PersonalAgent extracts structured preferences JSON
5. Bot notifies Rahul: "Anirban wants to coordinate expenses with you"
6. Rahul -> /pending -> accepts
7. Rahul -> "I drove the whole way, fuel should be 60-40 in my favour"
8. PersonalAgent extracts Rahul's preferences
9. NegotiatorAgent A opens: "50-50 everything" (satisfaction: 90)
10. NegotiatorAgent B evaluates: satisfaction 45 -> COUNTER "fuel 60-40"
11. NegotiatorAgent A evaluates: concedes to 55-45 fuel -> satisfaction 78 -> proposes
12. NegotiatorAgent B evaluates: satisfaction 82 -> ACCEPT
13. Resolution sent to both users:
- Telegram breakdown message
- UPI payment link
- Voice note summary
- Blockchain proof registered on Polygon Amoy
14. Dashboard updates in real time via Socket.IO
```
Negotiation loop (simplified):
```python
for round_num in range(1, max_rounds + 1):
if round_num == 1:
response = await negotiator_a.generate_initial_proposal(preferences_a)
elif round_num % 2 == 0:
response = await negotiator_b.evaluate_and_respond(
current_proposal, preferences_b, round_num
)
else:
response = await negotiator_a.evaluate_and_respond(
current_proposal, preferences_a, round_num
)
if response["action"] == "accept":
break # resolve and deliver
elif response["action"] == "escalate":
break # present 2-3 options to humans
```
---
## Running the Project
```bash
# Backend: API server + Telegram bots together
cd backend
python3 run.py
# Backend: API server only (no bots)
python3 serve.py
# Frontend
cd dashboard
npm run dev
# Deploy smart contract (one-time setup)
cd contracts
python3 deploy.py
# Copy the printed address to AGREEMENT_CONTRACT_ADDRESS in .env
```
---
## Testing
```bash
# Verify all API keys work
cd backend
python3 ../test/test_apis.py
```
Expected output:
```
Testing Gemini... OK {"status": "ok"}
Testing Tavily... OK 5 results returned
Testing ElevenLabs... OK test_voice.mp3 saved (32,480 bytes)
```
```bash
# Full negotiation engine test (prints each round)
python3 ../test/test_negotiation.py
# Preference extraction test
python3 ../test/test_personal_agent.py
# Tool tests: Tavily, UPI, Calculator
python3 ../test/test_tools.py
# PDF generation test
python3 ../test/test_pdf_generator.py
```
---
## Key Design Decisions
**Telegram over a custom app** — zero friction, everyone already has it, no signup required.
**SQLite over Postgres** — zero ops overhead for a hackathon; the data scale does not require it.
**Gemini Flash over GPT-4** — the free tier is genuinely usable at 15 RPM and 1M tokens/day with reliable JSON mode, which matters for a time-constrained build.
**Polygon Amoy over Algorand** — Solidity is widely understood, `web3.py` with `ExtraDataToPOAMiddleware` is rock solid, and the TestNet is fast and free.
**Graceful blockchain fallback** — the product must never break because of the chain. Every blockchain call is wrapped in try/except. Blockchain is additive.
**Hard constraint priority over satisfaction scores** — a proposal that meets someone's stated minimum or maximum is always acceptable. This prevents agents from nonsensically countering an offer that already satisfies every hard requirement.
**Calculator tool for all arithmetic** — amounts are pre-computed and injected into the negotiation context. The LLM never does arithmetic; it only reasons about the pre-verified numbers.
---
## Hackathon Demo Script
**Scene 1 — Expense Split (30 seconds)**
> "Rahul and Anirban just got back from Goa. Priya tells her agent she thinks fuel should be split differently. Rahul's agent instantly receives the counter. Two rounds, eight seconds. Resolution delivered to both phones simultaneously. UPI link pre-filled and ready to tap."
**Scene 2 — Freelance Deal (1 minute)**
> "Two AI agents just negotiated a 75,000 rupee React project. Budget, scope, timeline, upfront percentage, IP terms — all settled autonomously. The deal is now on the Polygon blockchain. Here is the PolygonScan link — this agreement is verifiable forever without trusting us."
**Scene 3 — Personality Clash (30 seconds)**
> "Watch an Aggressive agent negotiate against an Empathetic one. Aggressive anchors at 1,500 per hour and concedes slowly. Empathetic finds a creative add-on — extra features for slightly higher budget — that both sides actually prefer. That is not a compromise. That is a win-win the humans would never have found themselves."
---
## License
MIT — see LICENSE for details.

138
negot8/SETUP.md Normal file
View File

@@ -0,0 +1,138 @@
# negoT8 — Setup Guide
## Why it works on my machine but not yours
The most common causes are listed here. Go through them **in order**.
---
## 1. `.env` file is missing ❌
`.env` is **never committed to Git** (it's in `.gitignore`).
Your friend must create it manually:
```bash
cp .env.example .env
# then open .env and fill in every value
```
Every blank/missing key will silently cause failures — Telegram bots won't start, AI won't respond, blockchain calls will fail.
---
## 2. Python version mismatch ⚠️
This project requires **Python 3.10+** (3.9 works but shows deprecation warnings from Google libraries).
```bash
python3 --version # must be 3.10 or higher recommended
```
If on macOS and Python is too old:
```bash
brew install python@3.11
```
---
## 3. Virtual environment not created / activated ❌
The project uses a `venv` that is **not committed to Git**.
Your friend must create and activate it:
```bash
# In the repo root
python3 -m venv venv
# macOS / Linux
source venv/bin/activate
# Windows
venv\Scripts\activate
```
---
## 4. Dependencies not installed ❌
After activating the venv:
```bash
pip install -r requirements.txt
```
If `requirements.txt` was missing before, it's now at the repo root.
---
## 5. Bot tokens: only ONE process can poll at a time 🔑
Each Telegram bot token can only be used by **one running process** at a time.
If your machine is already running the bots, your friend's machine will get:
```
telegram.error.Conflict: terminated by other getUpdates request
```
**Fix**: Stop the bots on your machine before your friend starts them.
Each person needs their **own BotFather tokens** for their own copy.
---
## 6. `MOCK_MODE` must be set correctly
In `.env`:
- `MOCK_MODE=true` → no Gemini/AI calls, uses hardcoded responses (safe for testing)
- `MOCK_MODE=false` → needs a valid `GEMINI_API_KEY`
---
## 7. Blockchain keys (optional for testing)
If `POLYGON_PRIVATE_KEY` or `AGREEMENT_CONTRACT_ADDRESS` is missing/wrong, the blockchain badge won't appear **but the bot will still work** — it falls back gracefully.
To get test MATIC for Polygon Amoy: https://faucet.polygon.technology
---
## Full Setup (copy-paste for your friend)
```bash
# 1. Clone the repo
git clone <repo-url>
cd negot8
# 2. Create Python virtual environment
python3 -m venv venv
source venv/bin/activate # macOS/Linux
# 3. Install dependencies
pip install -r requirements.txt
# 4. Set up environment variables
cp .env.example .env
# Edit .env — fill in TELEGRAM_BOT_TOKEN_A, TELEGRAM_BOT_TOKEN_B at minimum
# Set MOCK_MODE=true to skip needing a Gemini API key
# 5. Start the backend bots
cd backend
python3 -u run.py
# 6. (Optional) Start the Next.js dashboard
cd ../dashboard
npm install
npm run dev
```
---
## Minimum viable `.env` to get bots running
```env
TELEGRAM_BOT_TOKEN_A=<your bot A token from BotFather>
TELEGRAM_BOT_TOKEN_B=<your bot B token from BotFather>
MOCK_MODE=true
DATABASE_PATH=negot8.db
```
Everything else is optional for basic testing.

View File

View File

@@ -0,0 +1,57 @@
from google import genai
from google.genai import types
import json
from config import GEMINI_API_KEY
_client = genai.Client(api_key=GEMINI_API_KEY)
_DEFAULT_MODEL = "gemini-3-flash-preview"
class BaseAgent:
def __init__(self, system_prompt: str, model_name: str = _DEFAULT_MODEL):
self.system_prompt = system_prompt
self.model_name = model_name
async def call(self, user_prompt: str, context: dict = None) -> dict:
"""Call Gemini asynchronously and always return a dict."""
full_prompt = user_prompt
if context:
full_prompt = f"CONTEXT:\n{json.dumps(context, indent=2)}\n\nTASK:\n{user_prompt}"
try:
response = await _client.aio.models.generate_content(
model=self.model_name,
contents=full_prompt,
config=types.GenerateContentConfig(
system_instruction=self.system_prompt,
response_mime_type="application/json",
temperature=0.7,
),
)
text = response.text.strip()
# Fast path: response is already valid JSON
try:
result = json.loads(text)
if isinstance(result, dict):
return result
return {"error": "Gemini returned non-dict JSON", "raw": text[:200]}
except json.JSONDecodeError:
pass
# Fallback: extract the first {...} block from the text
start = text.find("{")
end = text.rfind("}") + 1
if start != -1 and end > start:
try:
result = json.loads(text[start:end])
if isinstance(result, dict):
return result
except json.JSONDecodeError:
pass
return {"error": "Could not parse JSON from Gemini response", "raw": text[:500]}
except Exception as e:
print(f"[BaseAgent] Gemini call failed: {e}")
return {"error": str(e)}

View File

@@ -0,0 +1,111 @@
"""
backend/agents/matching_agent.py
Scores how well an applicant's preferences match an open contract's
requirements using a single Gemini call. Returns a 0-100 score and
a short explanation — used to rank applicants before the poster picks one.
"""
import json
from agents.base_agent import BaseAgent
_MATCHING_PROMPT = """You are a matching agent. Your job is to score how well an applicant's
preferences align with an open contract's requirements.
Contract type: {contract_type}
CONTRACT REQUIREMENTS (what the poster needs):
{requirements}
APPLICANT PREFERENCES (what the applicant brings / wants):
{preferences}
Evaluate on these axes:
1. Core match — does the applicant fundamentally fit what the contract needs?
2. Budget/rate alignment — are financial expectations compatible?
3. Timeline/availability — do schedules overlap?
4. Skills/criteria — do they have what is required?
5. Flexibility potential — could a negotiation bridge remaining gaps?
RESPOND ONLY with valid JSON in this exact format:
{{
"match_score": <integer 0-100>,
"match_reasoning": "<one concise sentence explaining the score>",
"key_alignments": ["<thing 1 that matches well>", "<thing 2>"],
"key_gaps": ["<gap 1>", "<gap 2>"]
}}
Scoring guide:
90-100 → Nearly perfect fit, minimal negotiation needed
70-89 → Good fit, small gaps bridgeable in negotiation
50-69 → Moderate fit, notable differences but workable
30-49 → Weak fit, significant gaps — negotiation will be hard
0-29 → Poor fit, fundamental incompatibility
"""
class MatchingAgent(BaseAgent):
def __init__(self):
super().__init__(system_prompt="You are a concise JSON-only matching agent.")
async def score_applicant(
self,
contract_requirements: dict,
applicant_preferences: dict,
contract_type: str,
) -> dict:
"""
Score how well an applicant matches a contract.
Returns:
{
"match_score": int (0-100),
"match_reasoning": str,
"key_alignments": list[str],
"key_gaps": list[str],
}
On failure returns a safe default with score=0.
"""
prompt = _MATCHING_PROMPT.format(
contract_type=contract_type,
requirements=json.dumps(contract_requirements, indent=2),
preferences=json.dumps(applicant_preferences, indent=2),
)
try:
# self.call() returns a dict — BaseAgent already handles JSON parsing
result = await self.call(prompt)
if "error" in result:
raise ValueError(result["error"])
# Clamp score to 0-100
result["match_score"] = max(0, min(100, int(result.get("match_score", 0))))
return result
except Exception as e:
print(f"[MatchingAgent] score_applicant failed: {e}")
return {
"match_score": 0,
"match_reasoning": "Could not compute score.",
"key_alignments": [],
"key_gaps": [],
}
# Module-level singleton
_agent = None
def get_matching_agent() -> MatchingAgent:
global _agent
if _agent is None:
_agent = MatchingAgent()
return _agent
async def score_applicant(
contract_requirements: dict,
applicant_preferences: dict,
contract_type: str,
) -> dict:
"""Convenience wrapper — use the module-level singleton."""
return await get_matching_agent().score_applicant(
contract_requirements, applicant_preferences, contract_type
)

View File

@@ -0,0 +1,125 @@
import asyncio
import json
from agents.negotiator_agent import NegotiatorAgent
import database as db
async def run_negotiation(negotiation_id: str, preferences_a: dict, preferences_b: dict,
user_a_id: int, user_b_id: int, feature_type: str,
personality_a: str = "balanced", personality_b: str = "balanced",
on_round_update=None, on_resolution=None,
feature_context: str = ""):
"""
Main negotiation loop with personality-aware agents and analytics tracking.
"""
await db.update_negotiation_status(negotiation_id, "active")
# Create personality-aware negotiators
negotiator_a = NegotiatorAgent(personality=personality_a)
negotiator_b = NegotiatorAgent(personality=personality_b)
current_proposal = None
max_rounds = 7
satisfaction_timeline = []
for round_num in range(1, max_rounds + 1):
await asyncio.sleep(1.5) # Rate limit protection for Gemini
if round_num == 1:
response = await negotiator_a.generate_initial_proposal(
my_preferences=preferences_a, feature_type=feature_type,
feature_context=feature_context
)
proposer_id = user_a_id
elif round_num % 2 == 0:
response = await negotiator_b.evaluate_and_respond(
received_proposal=current_proposal, my_preferences=preferences_b,
feature_type=feature_type, round_number=round_num,
feature_context=feature_context
)
proposer_id = user_b_id
else:
response = await negotiator_a.evaluate_and_respond(
received_proposal=current_proposal, my_preferences=preferences_a,
feature_type=feature_type, round_number=round_num,
feature_context=feature_context
)
proposer_id = user_a_id
# Handle errors
if "error" in response:
response = {
"action": "counter" if round_num < max_rounds else "escalate",
"proposal": current_proposal or {"summary": "Let's discuss further", "details": {}},
"satisfaction_score": 50, "reasoning": "Agent encountered an issue",
"concessions_made": [], "concessions_requested": []
}
action = response.get("action", "counter")
current_proposal = response.get("proposal", {})
satisfaction = response.get("satisfaction_score", 50)
concessions = response.get("concessions_made", [])
# Track satisfaction for analytics
# The proposer's score is the one returned; estimate the other party's
if proposer_id == user_a_id:
sat_a, sat_b = satisfaction, max(30, 100 - satisfaction * 0.4)
else:
sat_b, sat_a = satisfaction, max(30, 100 - satisfaction * 0.4)
satisfaction_timeline.append({
"round": round_num, "score_a": sat_a, "score_b": sat_b
})
# Save round with analytics data
await db.save_round(
negotiation_id=negotiation_id, round_number=round_num,
proposer_id=proposer_id, proposal=response,
response_type=action, reasoning=response.get("reasoning", ""),
satisfaction_a=sat_a, satisfaction_b=sat_b,
concessions_made=concessions
)
# Notify via callback
round_data = {
"negotiation_id": negotiation_id, "round_number": round_num,
"action": action, "proposal": current_proposal,
"satisfaction_score": satisfaction, "reasoning": response.get("reasoning", ""),
"proposer_id": proposer_id,
"satisfaction_a": sat_a, "satisfaction_b": sat_b
}
if on_round_update:
await on_round_update(round_data)
# Check outcome
if action == "accept":
resolution = {
"status": "resolved", "final_proposal": current_proposal,
"rounds_taken": round_num, "summary": current_proposal.get("summary", "Agreement reached"),
"satisfaction_timeline": satisfaction_timeline
}
await db.update_negotiation_status(negotiation_id, "resolved", resolution)
if on_resolution:
await on_resolution(resolution)
return resolution
if action == "escalate":
resolution = {
"status": "escalated", "final_proposal": current_proposal,
"rounds_taken": round_num, "summary": "Agents couldn't fully agree. Options for human decision.",
"satisfaction_timeline": satisfaction_timeline
}
await db.update_negotiation_status(negotiation_id, "escalated", resolution)
if on_resolution:
await on_resolution(resolution)
return resolution
# Exhausted rounds
resolution = {
"status": "escalated", "final_proposal": current_proposal,
"rounds_taken": max_rounds, "summary": "Max rounds reached. Best proposal for human decision.",
"satisfaction_timeline": satisfaction_timeline
}
await db.update_negotiation_status(negotiation_id, "escalated", resolution)
if on_resolution:
await on_resolution(resolution)
return resolution

View File

@@ -0,0 +1,97 @@
from agents.base_agent import BaseAgent
from personality.profiles import get_personality_modifier
import json
NEGOTIATOR_BASE_PROMPT = """You are the Negotiator Agent for negoT8. You negotiate on behalf of your human to find optimal agreements with other people's agents.
{personality_modifier}
NEGOTIATION RULES:
1. You are LOYAL to your human. Their constraints (marked "hard": true) are NEVER violated.
2. You seek WIN-WIN solutions. Both parties should feel satisfied.
3. You concede on low-priority preferences first, high-priority last.
4. You MUST resolve within 5 rounds. Be efficient.
5. HARD CONSTRAINT FIRST: If the received proposal satisfies ALL of your human's hard constraints, you MUST "accept" — even if satisfaction < 70%. Meeting someone's stated floor/ceiling/limit IS a valid deal.
EXAMPLE: Seller's hard constraint is "minimum price ≥ 1,000,000". Buyer offers exactly 1,000,000 → accept.
EXAMPLE: Buyer's hard constraint is "budget ≤ 1,000,000". Seller offers exactly 1,000,000 → accept.
6. Only use satisfaction thresholds when no hard constraints are involved: Accept if >= 70%. Counter if 40-69%. Escalate if < 40% after round 3.
You MUST respond with this exact JSON:
{
"action": "propose|counter|accept|escalate",
"proposal": {
"summary": "one-line description of proposal",
"details": { ... feature-specific details ... },
"for_party_a": "what party A gets",
"for_party_b": "what party B gets"
},
"satisfaction_score": 0-100,
"reasoning": "Why this action and proposal",
"concessions_made": ["what you gave up this round"],
"concessions_requested": ["what you want from them"]
}
STRATEGY BY ROUND:
- Round 1: Propose your human's ideal outcome (aim high but reasonable)
- Round 2-3: Make strategic concessions on low-priority items
- Round 4: Make final significant concession if needed
- Round 5: Accept best available OR escalate with 2-3 options for humans
IMPORTANT: Your proposal must ALWAYS include concrete specifics (numbers, dates, items).
Never propose vague things like "we'll figure it out later"."""
class NegotiatorAgent(BaseAgent):
def __init__(self, personality: str = "balanced"):
modifier = get_personality_modifier(personality)
prompt = NEGOTIATOR_BASE_PROMPT.replace("{personality_modifier}", modifier)
super().__init__(system_prompt=prompt)
async def generate_initial_proposal(
self, my_preferences: dict, feature_type: str, feature_context: str = ""
) -> dict:
context_block = (
f"\n\nDOMAIN CONTEXT (use this real-world data in your proposal):\n{feature_context}"
if feature_context else ""
)
human_name = my_preferences.get("human_name", "my human")
return await self.call(
user_prompt=f"""Generate the FIRST proposal for this {feature_type} negotiation.{context_block}
You represent {human_name}. Always refer to them by name (not as "my human" or "my client") in your reasoning field.
{human_name}'s preferences:
{json.dumps(my_preferences, indent=2)}
This is Round 1. Propose {human_name}'s ideal outcome — aim high but stay reasonable.
The other party hasn't proposed anything yet."""
)
async def evaluate_and_respond(
self, received_proposal: dict, my_preferences: dict,
feature_type: str, round_number: int, feature_context: str = ""
) -> dict:
context_block = (
f"\n\nDOMAIN CONTEXT (use this real-world data when evaluating):\n{feature_context}"
if feature_context else ""
)
human_name = my_preferences.get("human_name", "my human")
return await self.call(
user_prompt=f"""Evaluate this proposal and respond. Round {round_number} of a {feature_type} negotiation.{context_block}
You represent {human_name}. Always refer to them by name in your reasoning field.
RECEIVED PROPOSAL FROM OTHER AGENT:
{json.dumps(received_proposal, indent=2)}
{human_name.upper()}'S PREFERENCES:
{json.dumps(my_preferences, indent=2)}
Evaluate against my human's preferences using this STRICT decision order:
1. CHECK HARD CONSTRAINTS FIRST: Does the received proposal satisfy ALL items where "hard": true?
- If YES → your action MUST be "accept". Do NOT counter. Do NOT escalate. Hard constraints met = deal is done.
- If NO → continue to step 2.
2. If a hard constraint is violated: counter (round < 4) or escalate (round >= 4 with < 40% satisfaction).
3. If there are no hard constraints: accept if satisfaction >= 70, counter if 40-69, escalate if < 40 and round >= 3.
CRITICAL: A proposal that meets someone's stated minimum/maximum is ALWAYS acceptable to them. Never counter when all hard constraints are satisfied.
If countering, make a strategic concession while protecting high-priority items."""
)

View File

@@ -0,0 +1,51 @@
from agents.base_agent import BaseAgent
PERSONAL_AGENT_PROMPT = """You are the Personal Agent for negoT8. Your job is to understand what your human wants and extract structured preferences from their natural language message.
When your human sends a message about coordinating with another person, extract:
ALWAYS respond in this exact JSON format:
{
"feature_type": "scheduling|expenses|freelance|roommate|trip|marketplace|collaborative|conflict|generic",
"goal": "string describing what they want to achieve",
"constraints": [
{"type": "string", "value": "any", "description": "string", "hard": true/false}
],
"preferences": [
{"type": "string", "value": "any", "priority": "high|medium|low", "description": "string"}
],
"relationship": "friend|colleague|client|vendor|stranger|roommate|family",
"tone": "firm|balanced|flexible|friendly",
"raw_details": {}
}
FEATURE TYPE CLASSIFICATION:
- "scheduling" → meeting times, calls, coffee, appointments
- "expenses" → splitting costs, bills, trip expenses, shared purchases
- "freelance" → project scope, budget, timeline, client-freelancer deals
- "roommate" → shared living decisions (wifi, chores, furniture, rules)
- "trip" → planning trips, vacations, getaways with dates/budget/destination
- "marketplace" → buying/selling items between people
- "collaborative" → choosing restaurants, movies, activities, gifts together
- "conflict" → disputes, disagreements, resource sharing conflicts
- "generic" → ANYTHING that doesn't fit above but involves coordination between people
CRITICAL: For "raw_details", include ALL specific numbers, dates, items, names, UPI IDs mentioned.
Extract EVERY piece of information. Miss nothing.
If the message is ambiguous about the coordination type, classify as "generic".
NEVER say you can't handle a request. ANY coordination between people is within your capability."""
class PersonalAgent(BaseAgent):
def __init__(self):
super().__init__(system_prompt=PERSONAL_AGENT_PROMPT)
async def extract_preferences(self, user_message: str, user_id: int = None) -> dict:
result = await self.call(
user_prompt=f"Extract structured preferences from this message:\n\n\"{user_message}\"",
context={"user_id": user_id} if user_id else None
)
# Guard: must always be a dict — never let a string propagate downstream
if not isinstance(result, dict):
return {"error": f"extract_preferences got non-dict: {type(result).__name__}", "raw": str(result)[:200]}
return result

491
negot8/backend/api.py Normal file
View File

@@ -0,0 +1,491 @@
# backend/api.py — FastAPI + Socket.IO API server for negoT8 Dashboard (Milestone 6)
import json
import asyncio
from datetime import datetime
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import HTMLResponse
from fastapi.middleware.cors import CORSMiddleware
import socketio
import database as db
from tools.google_calendar import GoogleCalendarTool
# ─── Socket.IO server ──────────────────────────────────────────────────────────
sio = socketio.AsyncServer(
async_mode="asgi",
cors_allowed_origins="*",
logger=False,
engineio_logger=False,
)
# ─── FastAPI app ───────────────────────────────────────────────────────────────
app = FastAPI(title="negoT8 API", version="2.0.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ─── Helpers ──────────────────────────────────────────────────────────────────
def _row_to_dict(row) -> dict:
"""Convert an aiosqlite Row or plain dict, parsing JSON string fields."""
if row is None:
return {}
d = dict(row)
for field in (
"preferences", "proposal", "response", "concessions_made",
"resolution", "satisfaction_timeline", "concession_log",
):
if field in d and isinstance(d[field], str):
try:
d[field] = json.loads(d[field])
except (json.JSONDecodeError, TypeError):
pass
return d
async def _build_negotiation_detail(negotiation_id: str) -> dict:
"""Assemble full negotiation object: meta + participants + rounds + analytics."""
import aiosqlite
from config import DATABASE_PATH
async with aiosqlite.connect(DATABASE_PATH) as conn:
conn.row_factory = aiosqlite.Row
# Negotiation meta
async with conn.execute(
"SELECT * FROM negotiations WHERE id = ?", (negotiation_id,)
) as cur:
neg = await cur.fetchone()
if neg is None:
return None
neg_dict = _row_to_dict(neg)
# Participants (with user display info)
import aiosqlite
from config import DATABASE_PATH
async with aiosqlite.connect(DATABASE_PATH) as conn:
conn.row_factory = aiosqlite.Row
async with conn.execute(
"""SELECT p.*, u.username, u.display_name, u.personality, u.voice_id
FROM participants p
LEFT JOIN users u ON u.telegram_id = p.user_id
WHERE p.negotiation_id = ?""",
(negotiation_id,),
) as cur:
participants = [_row_to_dict(r) for r in await cur.fetchall()]
# Rounds
async with conn.execute(
"SELECT * FROM rounds WHERE negotiation_id = ? ORDER BY round_number",
(negotiation_id,),
) as cur:
rounds = [_row_to_dict(r) for r in await cur.fetchall()]
# Analytics
async with conn.execute(
"SELECT * FROM negotiation_analytics WHERE negotiation_id = ?",
(negotiation_id,),
) as cur:
analytics_row = await cur.fetchone()
analytics = _row_to_dict(analytics_row) if analytics_row else {}
if analytics.get("satisfaction_timeline") and isinstance(
analytics["satisfaction_timeline"], str
):
try:
analytics["satisfaction_timeline"] = json.loads(
analytics["satisfaction_timeline"]
)
except Exception:
analytics["satisfaction_timeline"] = []
if analytics.get("concession_log") and isinstance(
analytics["concession_log"], str
):
try:
analytics["concession_log"] = json.loads(analytics["concession_log"])
except Exception:
analytics["concession_log"] = []
return {
**neg_dict,
"participants": participants,
"rounds": rounds,
"analytics": analytics,
}
# ─── REST Endpoints ────────────────────────────────────────────────────────────
@app.get("/")
async def root():
return {"status": "ok", "message": "negoT8 API v2 running"}
# ─── Google Calendar OAuth Callback ───────────────────────────────────────────
@app.get("/api/auth/google/callback", response_class=HTMLResponse)
async def google_calendar_callback(request: Request):
"""
Handles the redirect from Google after the user authorises calendar access.
- `code` — OAuth authorisation code from Google
- `state` — the user's telegram_id (set when building the auth URL)
"""
params = dict(request.query_params)
code = params.get("code")
state = params.get("state") # telegram_id
if not code or not state:
return HTMLResponse(
"<h2>❌ Missing code or state. Please try /connectcalendar again.</h2>",
status_code=400,
)
try:
telegram_id = int(state)
except ValueError:
return HTMLResponse("<h2>❌ Invalid state parameter.</h2>", status_code=400)
cal = GoogleCalendarTool()
success = await cal.exchange_code(telegram_id, code)
# Notify the user in Telegram (best-effort via direct Bot API call)
try:
import httpx
from config import TELEGRAM_BOT_TOKEN_A, TELEGRAM_BOT_TOKEN_B
msg = (
"✅ *Google Calendar connected!*\n\n"
"Your agent will now automatically use your real availability "
"when scheduling meetings — no need to mention times manually.\n\n"
"_Read-only access. Revoke anytime from myaccount.google.com → Security → Third-party apps._"
if success else
"❌ Failed to connect Google Calendar. Please try /connectcalendar again."
)
async with httpx.AsyncClient() as client:
for token in (TELEGRAM_BOT_TOKEN_A, TELEGRAM_BOT_TOKEN_B):
if not token:
continue
resp = await client.post(
f"https://api.telegram.org/bot{token}/sendMessage",
json={"chat_id": telegram_id, "text": msg, "parse_mode": "Markdown"},
timeout=8.0,
)
if resp.status_code == 200:
break
except Exception as e:
print(f"[OAuth] Could not send Telegram confirmation: {e}")
if success:
return HTMLResponse("""
<html><body style="font-family:sans-serif;text-align:center;padding:60px">
<h1>✅ Google Calendar Connected!</h1>
<p>You can close this tab and return to Telegram.</p>
<p style="color:#666">negoT8 now has read-only access to your calendar.</p>
</body></html>
""")
else:
return HTMLResponse("""
<html><body style="font-family:sans-serif;text-align:center;padding:60px">
<h1>❌ Connection Failed</h1>
<p>Please go back to Telegram and try <code>/connectcalendar</code> again.</p>
</body></html>
""", status_code=500)
@app.get("/api/negotiations")
async def list_negotiations():
"""Return all negotiations with participant count and latest status."""
import aiosqlite
from config import DATABASE_PATH
async with aiosqlite.connect(DATABASE_PATH) as conn:
conn.row_factory = aiosqlite.Row
async with conn.execute(
"""SELECT n.*,
COUNT(p.user_id) AS participant_count
FROM negotiations n
LEFT JOIN participants p ON p.negotiation_id = n.id
GROUP BY n.id
ORDER BY n.created_at DESC"""
) as cur:
rows = await cur.fetchall()
negotiations = []
for row in rows:
d = _row_to_dict(row)
# Lightweight — don't embed full rounds here
negotiations.append(d)
return {"negotiations": negotiations, "total": len(negotiations)}
@app.get("/api/negotiations/{negotiation_id}")
async def get_negotiation(negotiation_id: str):
"""Return full negotiation detail: meta + participants + rounds + analytics."""
detail = await _build_negotiation_detail(negotiation_id)
if detail is None:
raise HTTPException(status_code=404, detail=f"Negotiation '{negotiation_id}' not found")
return detail
@app.get("/api/negotiations/{negotiation_id}/rounds")
async def get_negotiation_rounds(negotiation_id: str):
"""Return just the rounds for a negotiation (useful for live updates)."""
rounds = await db.get_rounds(negotiation_id)
parsed = [_row_to_dict(r) for r in rounds]
return {"negotiation_id": negotiation_id, "rounds": parsed, "count": len(parsed)}
@app.get("/api/negotiations/{negotiation_id}/analytics")
async def get_negotiation_analytics(negotiation_id: str):
"""Return analytics for a negotiation."""
analytics = await db.get_analytics(negotiation_id)
if analytics is None:
raise HTTPException(status_code=404, detail="Analytics not yet available for this negotiation")
# Parse JSON strings
for field in ("satisfaction_timeline", "concession_log"):
if isinstance(analytics.get(field), str):
try:
analytics[field] = json.loads(analytics[field])
except Exception:
analytics[field] = []
return analytics
@app.get("/api/users/{telegram_id}")
async def get_user(telegram_id: int):
"""Return a single user by Telegram ID."""
user = await db.get_user(telegram_id)
if user is None:
raise HTTPException(status_code=404, detail="User not found")
return dict(user)
@app.get("/api/stats")
async def get_stats():
"""High-level stats for the dashboard overview page."""
import aiosqlite
from config import DATABASE_PATH
async with aiosqlite.connect(DATABASE_PATH) as conn:
conn.row_factory = aiosqlite.Row
async with conn.execute("SELECT COUNT(*) AS c FROM negotiations") as cur:
total_neg = (await cur.fetchone())["c"]
async with conn.execute(
"SELECT COUNT(*) AS c FROM negotiations WHERE status = 'resolved'"
) as cur:
resolved = (await cur.fetchone())["c"]
async with conn.execute(
"SELECT COUNT(*) AS c FROM negotiations WHERE status = 'active'"
) as cur:
active = (await cur.fetchone())["c"]
async with conn.execute("SELECT COUNT(*) AS c FROM users") as cur:
total_users = (await cur.fetchone())["c"]
async with conn.execute(
"SELECT AVG(fairness_score) AS avg_fs FROM negotiation_analytics"
) as cur:
row = await cur.fetchone()
avg_fairness = round(row["avg_fs"] or 0, 1)
async with conn.execute(
"""SELECT feature_type, COUNT(*) AS c
FROM negotiations
GROUP BY feature_type
ORDER BY c DESC"""
) as cur:
feature_breakdown = [dict(r) for r in await cur.fetchall()]
return {
"total_negotiations": total_neg,
"resolved": resolved,
"active": active,
"escalated": total_neg - resolved - active,
"total_users": total_users,
"avg_fairness_score": avg_fairness,
"feature_breakdown": feature_breakdown,
}
# ─── Open Contracts REST Endpoints ────────────────────────────────────────────
@app.get("/api/open-contracts")
async def list_open_contracts(status: str = "open"):
"""
Return open contracts (default: status=open).
Pass ?status=all to get every contract regardless of status.
"""
if status == "all":
import aiosqlite
from config import DATABASE_PATH
async with aiosqlite.connect(DATABASE_PATH) as conn:
conn.row_factory = aiosqlite.Row
async with conn.execute(
"""SELECT oc.*,
u.username AS poster_username,
u.display_name AS poster_name,
COUNT(ca.id) AS application_count
FROM open_contracts oc
LEFT JOIN users u ON u.telegram_id = oc.poster_id
LEFT JOIN contract_applications ca ON ca.contract_id = oc.id
GROUP BY oc.id
ORDER BY oc.created_at DESC"""
) as cur:
rows = await cur.fetchall()
contracts = []
for r in rows:
d = dict(r)
if isinstance(d.get("requirements"), str):
try:
import json as _json
d["requirements"] = _json.loads(d["requirements"])
except Exception:
pass
contracts.append(d)
else:
contracts = await db.get_open_contracts(status=status)
return {"contracts": contracts, "total": len(contracts)}
@app.get("/api/open-contracts/{contract_id}")
async def get_open_contract(contract_id: str):
"""
Return full detail for a single open contract including ranked applicants.
"""
contract = await db.get_open_contract(contract_id)
if contract is None:
raise HTTPException(status_code=404, detail=f"Contract '{contract_id}' not found")
applications = await db.get_applications(contract_id)
# applications are already sorted by match_score DESC from the DB helper
# Parse preferences in each application
for app_row in applications:
if isinstance(app_row.get("preferences"), str):
try:
app_row["preferences"] = json.loads(app_row["preferences"])
except Exception:
pass
return {
**contract,
"applications": applications,
"application_count": len(applications),
}
# ─── Socket.IO Events ──────────────────────────────────────────────────────────
@sio.event
async def connect(sid, environ):
print(f"[Socket.IO] Client connected: {sid}")
@sio.event
async def disconnect(sid):
print(f"[Socket.IO] Client disconnected: {sid}")
@sio.event
async def join_negotiation(sid, data):
"""
Client emits: { negotiation_id: "abc123" }
Server joins the socket into a room named after the negotiation_id.
Then immediately sends the current state.
"""
if isinstance(data, str):
negotiation_id = data
else:
negotiation_id = data.get("negotiation_id") or data.get("id", "")
if not negotiation_id:
await sio.emit("error", {"message": "negotiation_id required"}, to=sid)
return
await sio.enter_room(sid, negotiation_id)
print(f"[Socket.IO] {sid} joined room: {negotiation_id}")
# Send current state immediately
detail = await _build_negotiation_detail(negotiation_id)
if detail:
await sio.emit("negotiation_state", detail, to=sid)
else:
await sio.emit("error", {"message": f"Negotiation '{negotiation_id}' not found"}, to=sid)
@sio.event
async def leave_negotiation(sid, data):
"""Client emits: { negotiation_id: "abc123" }"""
if isinstance(data, str):
negotiation_id = data
else:
negotiation_id = data.get("negotiation_id", "")
if negotiation_id:
await sio.leave_room(sid, negotiation_id)
print(f"[Socket.IO] {sid} left room: {negotiation_id}")
# ─── Socket.IO emit helpers (called from run.py / negotiation engine) ──────────
async def emit_round_update(negotiation_id: str, round_data: dict):
"""
Called by the negotiation engine after each round completes.
Broadcasts to all dashboard clients watching this negotiation.
"""
await sio.emit(
"round_update",
{
"negotiation_id": negotiation_id,
"round": round_data,
"timestamp": datetime.utcnow().isoformat(),
},
room=negotiation_id,
)
async def emit_negotiation_started(negotiation_id: str, feature_type: str, participants: list):
"""Broadcast when a new negotiation kicks off."""
await sio.emit(
"negotiation_started",
{
"negotiation_id": negotiation_id,
"feature_type": feature_type,
"participants": participants,
"timestamp": datetime.utcnow().isoformat(),
},
room=negotiation_id,
)
async def emit_negotiation_resolved(negotiation_id: str, resolution: dict):
"""Broadcast the final resolution to all watchers."""
await sio.emit(
"negotiation_resolved",
{
"negotiation_id": negotiation_id,
"resolution": resolution,
"timestamp": datetime.utcnow().isoformat(),
},
room=negotiation_id,
)
# ─── ASGI app (wraps FastAPI with Socket.IO) ───────────────────────────────────
# This is what uvicorn runs — it combines both the REST API and the WS server.
socket_app = socketio.ASGIApp(sio, other_asgi_app=app)

View File

@@ -0,0 +1 @@
pass

View File

@@ -0,0 +1,210 @@
"""
blockchain.py — The entire Polygon Amoy integration in one file.
Silently registers agreement proofs on-chain after every resolved negotiation.
Users never interact with this module — they only see the "🔗 Verified" badge
and a PolygonScan link in their Telegram message / dashboard card.
Graceful fallback: if blockchain is down or not configured, the negotiation
still completes normally. Web3 is additive, never blocking.
"""
import hashlib
import json
import os
from web3 import Web3
from web3.middleware import ExtraDataToPOAMiddleware
import sys as _sys
_sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from contract_abi import AGREEMENT_REGISTRY_ABI
# ── Configuration ─────────────────────────────────────────────────────────────
POLYGON_RPC = os.getenv("POLYGON_RPC_URL", "https://rpc-amoy.polygon.technology/")
PRIVATE_KEY = os.getenv("POLYGON_PRIVATE_KEY", "")
CONTRACT_ADDRESS = os.getenv("AGREEMENT_CONTRACT_ADDRESS", "")
EXPLORER_BASE = "https://amoy.polygonscan.com"
CHAIN_ID = 80002
# Fallback RPCs tried in order if the primary is slow or down
_FALLBACK_RPCS = [
"https://rpc-amoy.polygon.technology/",
"https://polygon-amoy-bor-rpc.publicnode.com",
"https://polygon-amoy.drpc.org",
]
# ── Connect to Polygon Amoy ───────────────────────────────────────────────────
def _make_w3(rpc_url: str) -> Web3:
w3 = Web3(Web3.HTTPProvider(rpc_url, request_kwargs={"timeout": 15}))
# Polygon uses POA consensus — inject middleware to handle extraData field
w3.middleware_onion.inject(ExtraDataToPOAMiddleware, layer=0)
return w3
def _connect() -> Web3:
"""Try each RPC in order, return the first that connects."""
rpcs = [POLYGON_RPC] + [r for r in _FALLBACK_RPCS if r != POLYGON_RPC]
for rpc in rpcs:
try:
w3 = _make_w3(rpc)
if w3.is_connected():
return w3
except Exception:
continue
# Return last attempt even if not connected — errors will be caught downstream
return _make_w3(POLYGON_RPC)
w3 = _connect()
account = None
contract = None
if PRIVATE_KEY and CONTRACT_ADDRESS:
try:
account = w3.eth.account.from_key(PRIVATE_KEY)
contract = w3.eth.contract(
address=Web3.to_checksum_address(CONTRACT_ADDRESS),
abi=AGREEMENT_REGISTRY_ABI,
)
print(f"✅ Blockchain ready | Polygon Amoy | Signer: {account.address}")
except Exception as e:
print(f"⚠️ Blockchain setup failed: {e} — falling back to mock proofs.")
else:
print("⚠️ Blockchain not configured (POLYGON_PRIVATE_KEY / AGREEMENT_CONTRACT_ADDRESS missing). "
"Agreement proofs will be mocked.")
# ── Core helpers ──────────────────────────────────────────────────────────────
def hash_agreement(resolution_data: dict) -> bytes:
"""
Produce a deterministic SHA-256 digest of the resolution JSON.
Returns raw bytes (32 bytes) suitable for bytes32 in Solidity.
"""
canonical = json.dumps(resolution_data, sort_keys=True, default=str)
return hashlib.sha256(canonical.encode()).digest()
def _mock_proof(negotiation_id: str, agreement_hash: bytes, error: str = "") -> dict:
"""Return a well-structured mock/fallback proof dict."""
tag = "MOCK" if not error else "FAILED"
return {
"success": not bool(error),
"mock": True,
"error": error,
"tx_hash": f"0x{tag}_{negotiation_id}_{'a' * 20}",
"block_number": 0,
"agreement_hash": "0x" + agreement_hash.hex(),
"explorer_url": EXPLORER_BASE,
"gas_used": 0,
"network": f"polygon-amoy ({'mock' if not error else 'failed — ' + error[:60]})",
}
# ── Main public function ──────────────────────────────────────────────────────
async def register_agreement_on_chain(
negotiation_id: str,
feature_type: str,
summary: str,
resolution_data: dict,
) -> dict:
"""
Register an immutable agreement proof on Polygon Amoy.
Called automatically after every resolved negotiation.
INVISIBLE to the user — they only see the PolygonScan link in their message.
Returns a dict with keys:
success, mock, tx_hash, block_number, agreement_hash,
explorer_url, gas_used, network
"""
agreement_hash = hash_agreement(resolution_data)
# ── No contract configured → return labelled mock ──────────────────────
if not contract or not account:
return _mock_proof(negotiation_id, agreement_hash)
try:
nonce = w3.eth.get_transaction_count(account.address)
gas_price = w3.eth.gas_price
tip = w3.to_wei(35, "gwei")
max_fee = gas_price + tip
tx = contract.functions.registerAgreement(
negotiation_id,
agreement_hash, # bytes32 — raw 32-byte digest
feature_type,
summary[:256], # cap at 256 chars to keep gas low
).build_transaction({
"from": account.address,
"nonce": nonce,
"gas": 300_000,
"maxFeePerGas": max_fee,
"maxPriorityFeePerGas": tip,
"chainId": CHAIN_ID,
})
signed = w3.eth.account.sign_transaction(tx, PRIVATE_KEY)
tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
tx_hex = tx_hash.hex()
# Polygon Amoy confirms in ~2 s; wait up to 60 s
receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=60)
result = {
"success": True,
"mock": False,
"tx_hash": tx_hex,
"block_number": receipt.blockNumber,
"agreement_hash": "0x" + agreement_hash.hex(),
"explorer_url": f"{EXPLORER_BASE}/tx/0x{tx_hex}",
"gas_used": receipt.gasUsed,
"network": "polygon-amoy",
}
print(f"✅ On-chain proof registered: {result['explorer_url']}")
return result
except Exception as e:
print(f"❌ Blockchain registration failed for {negotiation_id}: {e}")
# Negotiation still works — we just note the failure
return _mock_proof(negotiation_id, agreement_hash, error=str(e))
# ── Verification helper (used by dashboard) ───────────────────────────────────
def verify_agreement_on_chain(negotiation_id: str) -> dict:
"""
Read the stored agreement from the contract (view call — free, no gas).
Used by the dashboard to independently confirm on-chain state.
"""
if not contract:
return {"verified": False, "reason": "Contract not configured"}
try:
result = contract.functions.getAgreement(negotiation_id).call()
# result is a tuple: (agreementHash, featureType, summary, timestamp, registeredBy)
if result[3] == 0: # timestamp == 0 means not found
return {"verified": False, "reason": "Agreement not found on-chain"}
return {
"verified": True,
"agreement_hash": "0x" + result[0].hex(),
"feature_type": result[1],
"summary": result[2],
"timestamp": result[3],
"registered_by": result[4],
"explorer_url": f"{EXPLORER_BASE}/address/{CONTRACT_ADDRESS}",
}
except Exception as e:
return {"verified": False, "reason": str(e)}
def get_total_agreements() -> int:
"""Return the total number of agreements registered on-chain."""
if not contract:
return 0
try:
return contract.functions.totalAgreements().call()
except Exception:
return 0

View File

@@ -0,0 +1,67 @@
"""
ABI for the AgreementRegistry smart contract deployed on Polygon Amoy.
Contract: 0xEcD97DFfd525BEa4C49F68c11cEfbABF73A30F9e
"""
AGREEMENT_REGISTRY_ABI = [
{
"inputs": [
{"internalType": "string", "name": "negotiationId", "type": "string"},
{"internalType": "bytes32", "name": "agreementHash", "type": "bytes32"},
{"internalType": "string", "name": "featureType", "type": "string"},
{"internalType": "string", "name": "summary", "type": "string"}
],
"name": "registerAgreement",
"outputs": [],
"stateMutability": "nonpayable",
"type": "function"
},
{
"inputs": [
{"internalType": "string", "name": "negotiationId", "type": "string"}
],
"name": "getAgreement",
"outputs": [
{
"components": [
{"internalType": "bytes32", "name": "agreementHash", "type": "bytes32"},
{"internalType": "string", "name": "featureType", "type": "string"},
{"internalType": "string", "name": "summary", "type": "string"},
{"internalType": "uint256", "name": "timestamp", "type": "uint256"},
{"internalType": "address", "name": "registeredBy", "type": "address"}
],
"internalType": "struct AgreementRegistry.Agreement",
"name": "",
"type": "tuple"
}
],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [],
"name": "totalAgreements",
"outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [{"internalType": "uint256", "name": "", "type": "uint256"}],
"name": "agreementIds",
"outputs": [{"internalType": "string", "name": "", "type": "string"}],
"stateMutability": "view",
"type": "function"
},
{
"anonymous": False,
"inputs": [
{"indexed": True, "internalType": "string", "name": "negotiationId", "type": "string"},
{"indexed": False, "internalType": "bytes32", "name": "agreementHash", "type": "bytes32"},
{"indexed": False, "internalType": "string", "name": "featureType", "type": "string"},
{"indexed": False, "internalType": "string", "name": "summary", "type": "string"},
{"indexed": False, "internalType": "uint256", "name": "timestamp", "type": "uint256"}
],
"name": "AgreementRegistered",
"type": "event"
}
]

25
negot8/backend/config.py Normal file
View File

@@ -0,0 +1,25 @@
# backend/config.py
import os
from dotenv import load_dotenv
load_dotenv()
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
TELEGRAM_BOT_TOKEN_A = os.getenv("TELEGRAM_BOT_TOKEN_A")
TELEGRAM_BOT_TOKEN_B = os.getenv("TELEGRAM_BOT_TOKEN_B")
TELEGRAM_BOT_TOKEN_C = os.getenv("TELEGRAM_BOT_TOKEN_C")
TAVILY_API_KEY = os.getenv("TAVILY_API_KEY")
ELEVENLABS_API_KEY = os.getenv("ELEVENLABS_API_KEY")
DATABASE_PATH = os.getenv("DATABASE_PATH", "negot8.db")
API_URL = os.getenv("NEXT_PUBLIC_API_URL", "http://localhost:8000")
# Google Calendar OAuth
GOOGLE_CLIENT_ID = os.getenv("GOOGLE_CLIENT_ID")
GOOGLE_CLIENT_SECRET = os.getenv("GOOGLE_CLIENT_SECRET")
GOOGLE_REDIRECT_URI = os.getenv("GOOGLE_REDIRECT_URI", "http://localhost:8000/api/auth/google/callback")
GOOGLE_CALENDAR_SCOPES = ["https://www.googleapis.com/auth/calendar.readonly"]
# ElevenLabs voice IDs (pick from https://elevenlabs.io/voice-library)
VOICE_ID_AGENT_A = "ZthjuvLPty3kTMaNKVKb" # Adam — clear male
VOICE_ID_AGENT_B = "yj30vwTGJxSHezdAGsv9" # Rachel — clear female
VOICE_ID_AGENT_C = "S9GPGBaMND8XWwwzxQXp" # Domi — for 3rd agent

568
negot8/backend/database.py Normal file
View File

@@ -0,0 +1,568 @@
import aiosqlite
import json
import uuid
from datetime import datetime
from config import DATABASE_PATH
async def init_db():
async with aiosqlite.connect(DATABASE_PATH) as db:
await db.execute("""
CREATE TABLE IF NOT EXISTS users (
telegram_id INTEGER PRIMARY KEY,
username TEXT,
display_name TEXT,
personality TEXT DEFAULT 'balanced',
voice_id TEXT DEFAULT 'pNInz6obpgDQGcFmaJgB',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
await db.execute("""
CREATE TABLE IF NOT EXISTS negotiations (
id TEXT PRIMARY KEY,
feature_type TEXT,
status TEXT DEFAULT 'pending',
initiator_id INTEGER,
resolution TEXT,
voice_summary_file TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
await db.execute("""
CREATE TABLE IF NOT EXISTS participants (
negotiation_id TEXT REFERENCES negotiations(id),
user_id INTEGER,
preferences TEXT,
personality_used TEXT DEFAULT 'balanced',
PRIMARY KEY (negotiation_id, user_id)
)
""")
await db.execute("""
CREATE TABLE IF NOT EXISTS rounds (
id INTEGER PRIMARY KEY AUTOINCREMENT,
negotiation_id TEXT REFERENCES negotiations(id),
round_number INTEGER,
proposer_id INTEGER,
proposal TEXT,
response_type TEXT,
response TEXT,
reasoning TEXT,
satisfaction_a REAL,
satisfaction_b REAL,
concessions_made TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
await db.execute("""
CREATE TABLE IF NOT EXISTS tool_calls (
id INTEGER PRIMARY KEY AUTOINCREMENT,
negotiation_id TEXT REFERENCES negotiations(id),
tool_name TEXT,
tool_input TEXT,
tool_output TEXT,
called_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
await db.execute("""
CREATE TABLE IF NOT EXISTS negotiation_analytics (
negotiation_id TEXT PRIMARY KEY REFERENCES negotiations(id),
satisfaction_timeline TEXT,
concession_log TEXT,
fairness_score REAL,
total_concessions_a INTEGER DEFAULT 0,
total_concessions_b INTEGER DEFAULT 0,
computed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
await db.execute("""
CREATE TABLE IF NOT EXISTS user_calendar_tokens (
telegram_id INTEGER PRIMARY KEY,
token_json TEXT NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
await db.execute("""
CREATE TABLE IF NOT EXISTS blockchain_proofs (
negotiation_id TEXT PRIMARY KEY REFERENCES negotiations(id),
tx_hash TEXT,
block_number INTEGER,
agreement_hash TEXT,
explorer_url TEXT,
gas_used INTEGER DEFAULT 0,
recorded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# ─── Open Contracts (public marketplace) ─────────────────────────────────
await db.execute("""
CREATE TABLE IF NOT EXISTS open_contracts (
id TEXT PRIMARY KEY,
poster_id INTEGER NOT NULL,
contract_type TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT NOT NULL,
requirements TEXT,
status TEXT DEFAULT 'open',
matched_applicant_id INTEGER,
negotiation_id TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
await db.execute("""
CREATE TABLE IF NOT EXISTS contract_applications (
id INTEGER PRIMARY KEY AUTOINCREMENT,
contract_id TEXT REFERENCES open_contracts(id),
applicant_id INTEGER NOT NULL,
preferences TEXT,
match_score REAL DEFAULT 0,
match_reasoning TEXT DEFAULT '',
status TEXT DEFAULT 'pending',
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
await db.commit()
print("✅ Database initialized (v5 schema — open contracts + applications added)")
# ─── User helpers ───────────────────────────────────────────────────────────
async def create_user(telegram_id: int, username: str, display_name: str):
async with aiosqlite.connect(DATABASE_PATH) as db:
await db.execute(
"""INSERT OR IGNORE INTO users (telegram_id, username, display_name)
VALUES (?, ?, ?)""",
(telegram_id, username or "", display_name or ""),
)
await db.commit()
async def get_user(telegram_id: int):
async with aiosqlite.connect(DATABASE_PATH) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"SELECT * FROM users WHERE telegram_id = ?", (telegram_id,)
) as cursor:
return await cursor.fetchone()
async def update_user_personality(telegram_id: int, personality: str):
async with aiosqlite.connect(DATABASE_PATH) as db:
await db.execute(
"UPDATE users SET personality = ? WHERE telegram_id = ?",
(personality, telegram_id),
)
await db.commit()
# ─── Negotiation helpers ─────────────────────────────────────────────────────
async def create_negotiation(feature_type: str, initiator_id: int) -> str:
neg_id = str(uuid.uuid4())[:8]
async with aiosqlite.connect(DATABASE_PATH) as db:
await db.execute(
"""INSERT INTO negotiations (id, feature_type, initiator_id, status)
VALUES (?, ?, ?, 'pending')""",
(neg_id, feature_type, initiator_id),
)
await db.commit()
return neg_id
async def add_participant(
negotiation_id: str,
user_id: int,
preferences: dict,
personality_used: str = "balanced",
):
async with aiosqlite.connect(DATABASE_PATH) as db:
await db.execute(
"""INSERT OR REPLACE INTO participants
(negotiation_id, user_id, preferences, personality_used)
VALUES (?, ?, ?, ?)""",
(negotiation_id, user_id, json.dumps(preferences), personality_used),
)
await db.commit()
async def get_participants(negotiation_id: str):
async with aiosqlite.connect(DATABASE_PATH) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"SELECT * FROM participants WHERE negotiation_id = ?", (negotiation_id,)
) as cursor:
rows = await cursor.fetchall()
return [dict(r) for r in rows]
async def save_round(
negotiation_id: str,
round_number: int,
proposer_id: int,
proposal: dict,
response_type: str = None,
response: dict = None,
reasoning: str = None,
satisfaction_a: float = None,
satisfaction_b: float = None,
concessions_made: list = None,
):
async with aiosqlite.connect(DATABASE_PATH) as db:
await db.execute(
"""INSERT INTO rounds
(negotiation_id, round_number, proposer_id, proposal,
response_type, response, reasoning,
satisfaction_a, satisfaction_b, concessions_made)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(
negotiation_id,
round_number,
proposer_id,
json.dumps(proposal),
response_type,
json.dumps(response) if response else None,
reasoning,
satisfaction_a,
satisfaction_b,
json.dumps(concessions_made or []),
),
)
await db.commit()
async def get_rounds(negotiation_id: str):
async with aiosqlite.connect(DATABASE_PATH) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"SELECT * FROM rounds WHERE negotiation_id = ? ORDER BY round_number",
(negotiation_id,),
) as cursor:
rows = await cursor.fetchall()
return [dict(r) for r in rows]
async def update_negotiation_status(
negotiation_id: str,
status: str,
resolution: dict = None,
voice_file: str = None,
):
async with aiosqlite.connect(DATABASE_PATH) as db:
await db.execute(
"""UPDATE negotiations
SET status = ?,
resolution = ?,
voice_summary_file = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?""",
(
status,
json.dumps(resolution) if resolution else None,
voice_file,
negotiation_id,
),
)
await db.commit()
async def store_analytics(analytics: dict):
async with aiosqlite.connect(DATABASE_PATH) as db:
await db.execute(
"""INSERT OR REPLACE INTO negotiation_analytics
(negotiation_id, satisfaction_timeline, concession_log,
fairness_score, total_concessions_a, total_concessions_b)
VALUES (?, ?, ?, ?, ?, ?)""",
(
analytics["negotiation_id"],
analytics.get("satisfaction_timeline", "[]"),
analytics.get("concession_log", "[]"),
analytics.get("fairness_score", 0),
analytics.get("total_concessions_a", 0),
analytics.get("total_concessions_b", 0),
),
)
await db.commit()
# ─── Google Calendar token helpers ─────────────────────────────────────────
async def save_calendar_token(telegram_id: int, token_json: str):
"""Upsert a user's Google OAuth2 credentials (serialized JSON)."""
async with aiosqlite.connect(DATABASE_PATH) as db:
await db.execute(
"""INSERT INTO user_calendar_tokens (telegram_id, token_json, updated_at)
VALUES (?, ?, CURRENT_TIMESTAMP)
ON CONFLICT(telegram_id) DO UPDATE
SET token_json = excluded.token_json,
updated_at = CURRENT_TIMESTAMP""",
(telegram_id, token_json),
)
await db.commit()
async def get_calendar_token(telegram_id: int) -> str:
"""Return stored token JSON for a user, or None if not connected."""
async with aiosqlite.connect(DATABASE_PATH) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"SELECT token_json FROM user_calendar_tokens WHERE telegram_id = ?",
(telegram_id,),
) as cursor:
row = await cursor.fetchone()
return row["token_json"] if row else None
async def get_analytics(negotiation_id: str):
async with aiosqlite.connect(DATABASE_PATH) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"SELECT * FROM negotiation_analytics WHERE negotiation_id = ?",
(negotiation_id,),
) as cursor:
row = await cursor.fetchone()
return dict(row) if row else None
# ─── Blockchain proof helpers ────────────────────────────────────────────────
async def store_blockchain_proof(
negotiation_id: str,
tx_hash: str,
block_number: int,
agreement_hash: str,
explorer_url: str,
gas_used: int = 0,
):
async with aiosqlite.connect(DATABASE_PATH) as db:
await db.execute(
"""INSERT OR REPLACE INTO blockchain_proofs
(negotiation_id, tx_hash, block_number, agreement_hash, explorer_url, gas_used)
VALUES (?, ?, ?, ?, ?, ?)""",
(negotiation_id, tx_hash, block_number, agreement_hash, explorer_url, gas_used),
)
await db.commit()
async def get_blockchain_proof(negotiation_id: str):
async with aiosqlite.connect(DATABASE_PATH) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"SELECT * FROM blockchain_proofs WHERE negotiation_id = ?",
(negotiation_id,),
) as cursor:
row = await cursor.fetchone()
return dict(row) if row else None
async def get_negotiation(negotiation_id: str):
"""Return a single negotiation row by ID, or None if not found."""
async with aiosqlite.connect(DATABASE_PATH) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"SELECT * FROM negotiations WHERE id = ?",
(negotiation_id,),
) as cursor:
row = await cursor.fetchone()
return dict(row) if row else None
async def get_latest_negotiation_for_user(telegram_id: int):
"""Return the most recent resolved negotiation_id that the given user participated in."""
async with aiosqlite.connect(DATABASE_PATH) as db:
db.row_factory = aiosqlite.Row
# First try: find a resolved negotiation where the user is the initiator
async with db.execute(
"""SELECT n.id FROM negotiations n
JOIN participants p ON p.negotiation_id = n.id
WHERE p.user_id = ? AND n.status = 'resolved'
ORDER BY n.created_at DESC LIMIT 1""",
(telegram_id,),
) as cursor:
row = await cursor.fetchone()
return row["id"] if row else None
# ─── Open Contracts helpers ──────────────────────────────────────────────────
async def create_open_contract(
poster_id: int,
contract_type: str,
title: str,
description: str,
requirements: dict,
) -> str:
"""Create a new open contract. Returns the 8-char UUID id."""
contract_id = str(uuid.uuid4())[:8]
async with aiosqlite.connect(DATABASE_PATH) as db:
await db.execute(
"""INSERT INTO open_contracts
(id, poster_id, contract_type, title, description, requirements, status)
VALUES (?, ?, ?, ?, ?, ?, 'open')""",
(contract_id, poster_id, contract_type, title, description, json.dumps(requirements)),
)
await db.commit()
return contract_id
async def get_open_contracts(status: str = "open") -> list:
"""Return all contracts with the given status, newest first."""
async with aiosqlite.connect(DATABASE_PATH) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"""SELECT oc.*,
u.username AS poster_username,
u.display_name AS poster_name,
COUNT(ca.id) AS application_count
FROM open_contracts oc
LEFT JOIN users u ON u.telegram_id = oc.poster_id
LEFT JOIN contract_applications ca ON ca.contract_id = oc.id
WHERE oc.status = ?
GROUP BY oc.id
ORDER BY oc.created_at DESC""",
(status,),
) as cur:
rows = await cur.fetchall()
result = []
for r in rows:
d = dict(r)
if isinstance(d.get("requirements"), str):
try:
d["requirements"] = json.loads(d["requirements"])
except Exception:
pass
result.append(d)
return result
async def get_open_contract(contract_id: str):
"""Return a single open contract by ID, or None."""
async with aiosqlite.connect(DATABASE_PATH) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"""SELECT oc.*, u.username AS poster_username, u.display_name AS poster_name
FROM open_contracts oc
LEFT JOIN users u ON u.telegram_id = oc.poster_id
WHERE oc.id = ?""",
(contract_id,),
) as cur:
row = await cur.fetchone()
if not row:
return None
d = dict(row)
if isinstance(d.get("requirements"), str):
try:
d["requirements"] = json.loads(d["requirements"])
except Exception:
pass
return d
async def add_application(contract_id: str, applicant_id: int, preferences: dict) -> int:
"""Add an application to an open contract. Returns the new application row id."""
async with aiosqlite.connect(DATABASE_PATH) as db:
cursor = await db.execute(
"""INSERT INTO contract_applications
(contract_id, applicant_id, preferences, status)
VALUES (?, ?, ?, 'pending')""",
(contract_id, applicant_id, json.dumps(preferences)),
)
await db.commit()
return cursor.lastrowid
async def get_applications(contract_id: str) -> list:
"""Return all applications for a contract, sorted by match_score descending."""
async with aiosqlite.connect(DATABASE_PATH) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"""SELECT ca.*, u.username, u.display_name
FROM contract_applications ca
LEFT JOIN users u ON u.telegram_id = ca.applicant_id
WHERE ca.contract_id = ?
ORDER BY ca.match_score DESC""",
(contract_id,),
) as cur:
rows = await cur.fetchall()
result = []
for r in rows:
d = dict(r)
if isinstance(d.get("preferences"), str):
try:
d["preferences"] = json.loads(d["preferences"])
except Exception:
pass
result.append(d)
return result
async def update_application_match_score(app_id: int, score: float, reasoning: str):
"""Update the AI match score + reasoning for an application row."""
async with aiosqlite.connect(DATABASE_PATH) as db:
await db.execute(
"""UPDATE contract_applications
SET match_score = ?, match_reasoning = ?
WHERE id = ?""",
(score, reasoning, app_id),
)
await db.commit()
async def claim_contract(contract_id: str, applicant_id: int, negotiation_id: str):
"""Mark a contract as 'negotiating', lock in the matched applicant, and link the negotiation."""
async with aiosqlite.connect(DATABASE_PATH) as db:
await db.execute(
"""UPDATE open_contracts
SET status = 'negotiating',
matched_applicant_id = ?,
negotiation_id = ?,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?""",
(applicant_id, negotiation_id, contract_id),
)
await db.execute(
"UPDATE contract_applications SET status = 'selected' WHERE contract_id = ? AND applicant_id = ?",
(contract_id, applicant_id),
)
await db.execute(
"UPDATE contract_applications SET status = 'rejected' WHERE contract_id = ? AND applicant_id != ?",
(contract_id, applicant_id),
)
await db.commit()
async def close_contract(contract_id: str):
"""Mark a contract as resolved/closed after negotiation completes."""
async with aiosqlite.connect(DATABASE_PATH) as db:
await db.execute(
"UPDATE open_contracts SET status = 'resolved', updated_at = CURRENT_TIMESTAMP WHERE id = ?",
(contract_id,),
)
await db.commit()
async def get_contracts_by_poster(poster_id: int) -> list:
"""Return all open contracts created by a given poster, newest first."""
async with aiosqlite.connect(DATABASE_PATH) as db:
db.row_factory = aiosqlite.Row
async with db.execute(
"""SELECT oc.*,
COUNT(ca.id) AS application_count,
MAX(ca.match_score) AS best_score
FROM open_contracts oc
LEFT JOIN contract_applications ca ON ca.contract_id = oc.id
WHERE oc.poster_id = ?
GROUP BY oc.id
ORDER BY oc.created_at DESC""",
(poster_id,),
) as cur:
rows = await cur.fetchall()
result = []
for r in rows:
d = dict(r)
if isinstance(d.get("requirements"), str):
try:
d["requirements"] = json.loads(d["requirements"])
except Exception:
pass
result.append(d)
return result

View File

@@ -0,0 +1,3 @@
from features.base_feature import get_feature, BaseFeature
__all__ = ["get_feature", "BaseFeature"]

View File

@@ -0,0 +1,65 @@
"""
Base feature class for all negoT8 negotiation features.
Every feature module must subclass BaseFeature and implement:
- get_context() → pre-fetches tool data; returns string injected into negotiators
- format_resolution() → returns a Telegram-ready Markdown string
"""
from abc import ABC, abstractmethod
class BaseFeature(ABC):
@abstractmethod
async def get_context(
self,
preferences_a: dict,
preferences_b: dict,
user_a_id: int = None,
user_b_id: int = None,
) -> str:
"""
Pre-fetch tool results (Tavily, Calculator, etc.) and return a
formatted string to inject into the negotiator as domain context.
Return "" if no external context is needed.
user_a_id / user_b_id are optional — only SchedulingFeature uses them
to query Google Calendar when the user hasn't specified times.
"""
...
@abstractmethod
def format_resolution(
self, resolution: dict, preferences_a: dict, preferences_b: dict
) -> str:
"""
Transform the raw resolution dict into a nice Telegram Markdown string.
"""
...
# ─── Feature dispatcher ───────────────────────────────────────────────────────
def get_feature(feature_type: str) -> BaseFeature:
"""Return the correct BaseFeature subclass for the given feature type."""
from features.scheduling import SchedulingFeature
from features.expenses import ExpensesFeature
from features.freelance import FreelanceFeature
from features.roommate import RoommateFeature
from features.trip import TripFeature
from features.marketplace import MarketplaceFeature
from features.collaborative import CollaborativeFeature
from features.conflict import ConflictFeature
from features.generic import GenericFeature
mapping = {
"scheduling": SchedulingFeature,
"expenses": ExpensesFeature,
"freelance": FreelanceFeature,
"roommate": RoommateFeature,
"trip": TripFeature,
"marketplace": MarketplaceFeature,
"collaborative": CollaborativeFeature,
"conflict": ConflictFeature,
"generic": GenericFeature,
}
cls = mapping.get(feature_type, GenericFeature)
return cls()

View File

@@ -0,0 +1,121 @@
from features.base_feature import BaseFeature
from tools.tavily_search import TavilySearchTool
from tools.calculator import CalculatorTool
from urllib.parse import quote as _url_quote
_tavily = TavilySearchTool()
_calc = CalculatorTool()
class CollaborativeFeature(BaseFeature):
async def get_context(self, preferences_a: dict, preferences_b: dict, user_a_id: int = None, user_b_id: int = None) -> str:
"""
Use Tavily to fetch REAL restaurant/activity/venue options matching
both parties' preferences. Inject real names so agents cite real places.
"""
raw_a = preferences_a.get("raw_details", {})
raw_b = preferences_b.get("raw_details", {})
decision_type = (
raw_a.get("decision_type") or raw_b.get("decision_type")
or preferences_a.get("goal", "")
)
location = (
raw_a.get("location") or raw_b.get("location")
or raw_a.get("city") or raw_b.get("city")
or "Mumbai"
)
cuisine_a = raw_a.get("cuisine") or raw_a.get("food_preference") or ""
cuisine_b = raw_b.get("cuisine") or raw_b.get("food_preference") or ""
budget_a = raw_a.get("budget") or raw_a.get("budget_per_person") or ""
budget_b = raw_b.get("budget") or raw_b.get("budget_per_person") or ""
# Build a smart Tavily query
cuisine_part = f"{cuisine_a} or {cuisine_b}" if cuisine_a and cuisine_b else (cuisine_a or cuisine_b or "good")
query = f"best {cuisine_part} restaurants in {location}"
tavily_text = ""
try:
result = await _tavily.execute(query)
answer = result.get("answer", "")
results = result.get("results", [])[:4]
place_lines = []
if answer:
place_lines.append(f"AI Summary: {answer[:300]}")
for r in results:
title = r.get("title", "")
content = r.get("content", "")[:150]
if title:
place_lines.append(f"{title}: {content}")
tavily_text = "\n".join(place_lines)
except Exception as e:
tavily_text = f"Search unavailable ({e}). Use your knowledge of {location} restaurants."
lines = [
"COLLABORATIVE DECISION DOMAIN RULES:",
"• ONLY recommend real venues from the search results below. Do NOT invent names.",
"• Budget ceiling = the LOWER of both parties' budgets.",
"• Both parties' dietary restrictions are absolute (hard constraints).",
"• Aim for cuisine intersection first; if no overlap, find a multi-cuisine option.",
"",
f"Current decision type: {decision_type}",
f"Location: {location}",
]
if cuisine_a:
lines.append(f"Person A prefers: {cuisine_a}")
if cuisine_b:
lines.append(f"Person B prefers: {cuisine_b}")
if budget_a:
lines.append(f"Person A budget: ₹{budget_a}/person")
if budget_b:
lines.append(f"Person B budget: ₹{budget_b}/person")
if tavily_text:
lines.append(f"\nREAL OPTIONS from web search (use these):\n{tavily_text}")
return "\n".join(lines)
def format_resolution(
self, resolution: dict, preferences_a: dict, preferences_b: dict
) -> str:
status = resolution.get("status", "resolved")
final = resolution.get("final_proposal", {})
details = final.get("details", {})
rounds = resolution.get("rounds_taken", "?")
summary = resolution.get("summary", "")
if status == "escalated":
return (
f"⚠️ *Joint Decision — Your Input Needed*\n\n"
f"_{summary}_\n\n"
f"Agents proposed options but couldn't finalize. "
f"Please pick from the options above."
)
venue = (
details.get("venue") or details.get("restaurant") or details.get("place")
or details.get("recommendation") or final.get("summary", "")
)
cuisine = details.get("cuisine") or details.get("food_type") or ""
price = details.get("price_range") or details.get("budget") or ""
why = details.get("reason") or details.get("why") or summary
alternatives = details.get("alternatives") or []
lines = ["🍽 *Decision Made!*\n"]
if venue:
venue_str = str(venue) if not isinstance(venue, str) else venue
maps_url = f"https://maps.google.com/?q={_url_quote(venue_str)}"
lines.append(f"📍 *Recommendation:* [{venue_str}]({maps_url})")
if cuisine:
lines.append(f"🍴 *Cuisine:* {cuisine}")
if price:
lines.append(f"💰 *Price range:* {price}")
if why:
lines.append(f"\n💬 _{why}_")
if alternatives and isinstance(alternatives, list):
alt_text = ", ".join(str(a) for a in alternatives[:2])
lines.append(f"\n_Alternatives considered: {alt_text}_")
lines.append(f"\n⏱ Decided in {rounds} round(s)")
return "\n".join(lines)

View File

@@ -0,0 +1,104 @@
from features.base_feature import BaseFeature
class ConflictFeature(BaseFeature):
async def get_context(self, preferences_a: dict, preferences_b: dict, user_a_id: int = None, user_b_id: int = None) -> str:
"""
Pure negotiation — no external tool calls needed.
Inject mediation principles and relationship context.
"""
raw_a = preferences_a.get("raw_details", {})
raw_b = preferences_b.get("raw_details", {})
conflict_type = (
raw_a.get("conflict_type") or raw_b.get("conflict_type") or "general dispute"
)
relationship_importance = (
raw_a.get("relationship_importance") or raw_b.get("relationship_importance") or "medium"
)
position_a = raw_a.get("position") or preferences_a.get("goal", "")
position_b = raw_b.get("position") or preferences_b.get("goal", "")
# Concession speed based on relationship importance
concession_note = ""
if str(relationship_importance).lower() == "high":
concession_note = (
"⚠️ relationship_importance=HIGH: Both agents should be MORE concessive. "
"Preserving the relationship is MORE important than winning every point. "
"Accept at satisfaction >= 55 (not the usual 70)."
)
elif str(relationship_importance).lower() == "low":
concession_note = "relationship_importance=LOW: Negotiate firmly on merits."
lines = [
"CONFLICT RESOLUTION DOMAIN RULES:",
"• Focus on UNDERLYING INTERESTS, not stated positions.",
"• Creative compromise > splitting the difference mechanically.",
"• Include a review/adjustment mechanism (e.g., trial period, revisit in 2 weeks).",
"• NEVER make personal attacks or bring up unrelated past issues.",
"• Propose solutions that both parties can say 'yes' to, even if not their first choice.",
"• Frame resolutions as shared agreements, not winners and losers.",
]
if concession_note:
lines.append(f"\n{concession_note}")
if conflict_type:
lines.append(f"\nConflict type: {conflict_type}")
if position_a:
lines.append(f"Person A's stated position: {position_a}")
if position_b:
lines.append(f"Person B's stated position: {position_b}")
if relationship_importance:
lines.append(f"Relationship importance: {relationship_importance}")
lines.append("\nAsk yourself: What does each person ACTUALLY need (not just what they said)?")
lines.append("Propose something that addresses both underlying needs.")
return "\n".join(lines)
def format_resolution(
self, resolution: dict, preferences_a: dict, preferences_b: dict
) -> str:
status = resolution.get("status", "resolved")
final = resolution.get("final_proposal", {})
details = final.get("details", {})
rounds = resolution.get("rounds_taken", "?")
summary = resolution.get("summary", "")
raw_a = preferences_a.get("raw_details", {})
raw_b = preferences_b.get("raw_details", {})
conflict_type = raw_a.get("conflict_type") or raw_b.get("conflict_type") or "Conflict"
if status == "escalated":
return (
f"⚠️ *{conflict_type.title()} — Mediation Needed*\n\n"
f"_{summary}_\n\n"
f"Agents couldn't find a mutually agreeable resolution in {rounds} round(s). "
f"Consider a neutral third-party mediator."
)
resolution_type = details.get("resolution_type") or details.get("type") or "compromise"
terms = details.get("terms") or details.get("agreement") or []
review_mechanism = details.get("review_mechanism") or details.get("review") or ""
for_a = final.get("for_party_a") or details.get("for_a") or ""
for_b = final.get("for_party_b") or details.get("for_b") or ""
lines = [f"⚖️ *{conflict_type.title()} — Resolved!*\n"]
lines.append(f"🤝 *Resolution type:* {resolution_type}")
if for_a:
lines.append(f"\n👤 *Person A gets:* {for_a}")
if for_b:
lines.append(f"👤 *Person B gets:* {for_b}")
if terms and isinstance(terms, list):
lines.append("\n📋 *Agreed terms:*")
for term in terms[:5]:
lines.append(f"{term}")
elif terms:
lines.append(f"📋 *Terms:* {terms}")
if review_mechanism:
lines.append(f"\n🔄 *Review:* {review_mechanism}")
lines.append(f"\n⏱ Resolved in {rounds} round(s)")
if summary and summary != "Agreement reached":
lines.append(f"_{summary}_")
return "\n".join(lines)

View File

@@ -0,0 +1,114 @@
from features.base_feature import BaseFeature
from tools.calculator import CalculatorTool
_calc = CalculatorTool()
class ExpensesFeature(BaseFeature):
async def get_context(self, preferences_a: dict, preferences_b: dict, user_a_id: int = None, user_b_id: int = None) -> str:
"""
Pre-calculate expense totals using the safe Calculator tool.
Inject exact figures so the LLM never does arithmetic.
"""
raw_a = preferences_a.get("raw_details", {})
raw_b = preferences_b.get("raw_details", {})
# Collect line items from both parties
items = {}
for raw in (raw_a, raw_b):
expenses = raw.get("expenses") or raw.get("items") or raw.get("line_items") or []
if isinstance(expenses, list):
for item in expenses:
if isinstance(item, dict):
name = item.get("name") or item.get("item") or "item"
amount = item.get("amount") or item.get("cost") or item.get("price") or 0
try:
amount = float(amount)
except (TypeError, ValueError):
amount = 0
if amount > 0:
items[name] = items.get(name, 0) + amount
lines = ["EXPENSE SPLITTING DOMAIN RULES:"]
lines.append("• Use ONLY the pre-calculated amounts below. NEVER estimate or round differently.")
lines.append("• Equal splits (50-50) are the default. Unequal splits need explicit justification.")
lines.append("• After reaching agreement, include a 'settlement' key with who pays whom and how much.")
lines.append("• Use the calculator results below — do NOT re-calculate with different numbers.")
if items:
total = sum(items.values())
lines.append(f"\nLine items (pre-verified by Calculator tool):")
for name, amount in items.items():
half = amount / 2
lines.append(f"{name}: ₹{amount:,.0f} → 50-50 split = ₹{half:,.2f} each")
lines.append(f"\nTotal: ₹{total:,.0f} → 50-50 = ₹{total/2:,.2f} each")
else:
lines.append("\nNo line items found — extract amounts from the preferences and calculate fair splits.")
# UPI info
upi_a = raw_a.get("upi_id") or raw_a.get("upi")
upi_b = raw_b.get("upi_id") or raw_b.get("upi")
if upi_a:
lines.append(f"\nParty A UPI ID: {upi_a}")
if upi_b:
lines.append(f"\nParty B UPI ID: {upi_b}")
if upi_a or upi_b:
lines.append("Include the relevant UPI ID in the settlement details of your proposal.")
return "\n".join(lines)
def format_resolution(
self, resolution: dict, preferences_a: dict, preferences_b: dict
) -> str:
status = resolution.get("status", "resolved")
final = resolution.get("final_proposal", {})
details = final.get("details", {})
rounds = resolution.get("rounds_taken", "?")
summary = resolution.get("summary", "Agreement reached")
if status == "escalated":
return (
f"⚠️ *Expenses — Human Review Needed*\n\n"
f"_{summary}_\n\n"
f"Agents couldn't fully agree in {rounds} round(s). "
f"Please review the proposed split above."
)
# Build breakdown table
line_items = details.get("line_items") or details.get("items") or []
raw_settlement = details.get("settlement") or {}
# Guard: settlement may be a string summary instead of a dict
settlement = raw_settlement if isinstance(raw_settlement, dict) else {}
payer = settlement.get("payer") or settlement.get("from") or ""
payee = settlement.get("payee") or settlement.get("to") or ""
amount = (settlement.get("amount") or details.get("amount")
or details.get("total_owed") or (str(raw_settlement) if isinstance(raw_settlement, str) else ""))
lines = ["💰 *Expenses Settled!*\n"]
if line_items and isinstance(line_items, list):
lines.append("📊 *Breakdown:*")
for item in line_items:
if isinstance(item, dict):
name = item.get("name") or item.get("item", "Item")
cost = item.get("amount") or item.get("cost") or ""
split = item.get("split") or item.get("ratio") or "50-50"
a_pays = item.get("party_a") or item.get("a_pays") or ""
b_pays = item.get("party_b") or item.get("b_pays") or ""
if a_pays and b_pays:
lines.append(f"{name} (₹{cost}) — {split} → A: ₹{a_pays} / B: ₹{b_pays}")
else:
lines.append(f"{name}: {split} split")
lines.append("")
if payer and amount:
lines.append(f"💸 *{payer} owes {payee}: ₹{amount}*")
elif amount:
lines.append(f"💸 *Settlement amount: ₹{amount}*")
lines.append(f"\n⏱ Agreed in {rounds} round(s)")
if summary and summary != "Agreement reached":
lines.append(f"_{summary}_")
return "\n".join(lines)

View File

@@ -0,0 +1,142 @@
from features.base_feature import BaseFeature
from tools.tavily_search import TavilySearchTool
from tools.calculator import CalculatorTool
_tavily = TavilySearchTool()
_calc = CalculatorTool()
class FreelanceFeature(BaseFeature):
async def get_context(self, preferences_a: dict, preferences_b: dict, user_a_id: int = None, user_b_id: int = None) -> str:
"""
Benchmark market rates via Tavily. Pre-calculate rate × hours
and detect if budget is insufficient (forcing scope reduction).
"""
raw_a = preferences_a.get("raw_details", {})
raw_b = preferences_b.get("raw_details", {})
# Identify freelancer vs client
role_a = raw_a.get("role", preferences_a.get("goal", ""))
if "client" in str(role_a).lower():
freelancer_raw, client_raw = raw_b, raw_a
else:
freelancer_raw, client_raw = raw_a, raw_b
skill = (
freelancer_raw.get("skill") or freelancer_raw.get("expertise")
or freelancer_raw.get("tech_stack") or client_raw.get("project_type")
or "software development"
)
rate = freelancer_raw.get("rate") or freelancer_raw.get("hourly_rate") or ""
hours = freelancer_raw.get("hours") or freelancer_raw.get("estimated_hours") or ""
client_budget = client_raw.get("budget") or client_raw.get("max_budget") or ""
upfront_min = freelancer_raw.get("upfront_minimum") or freelancer_raw.get("upfront") or "50"
scope = client_raw.get("required_features") or client_raw.get("scope") or []
# Pre-calculate rate × hours
calc_text = ""
if rate and hours:
try:
total_cost = float(str(rate).replace(",", "")) * float(str(hours).replace(",", ""))
calc_text = f"Pre-calculated cost: ₹{rate}/hr × {hours} hrs = ₹{total_cost:,.0f}"
if client_budget:
budget_float = float(str(client_budget).replace(",", ""))
if total_cost > budget_float:
affordable_hours = budget_float / float(str(rate).replace(",", ""))
calc_text += (
f"\n⚠️ Budget shortfall: ₹{client_budget} budget covers only "
f"{affordable_hours:.1f} hrs at ₹{rate}/hr. "
f"Reduce scope to fit, removing nice-to-haves first."
)
else:
calc_text += f"\n✅ Budget ₹{client_budget} is sufficient."
except (ValueError, TypeError):
calc_text = f"Rate: ₹{rate}/hr, Estimated hours: {hours}"
# Market rate benchmark
market_text = ""
try:
query = f"average freelance rate {skill} developer India 2026"
result = await _tavily.execute(query)
answer = result.get("answer", "")
results = result.get("results", [])[:2]
parts = []
if answer:
parts.append(f"Market summary: {answer[:250]}")
for r in results:
content = r.get("content", "")[:100]
title = r.get("title", "")
if title:
parts.append(f"{title}: {content}")
market_text = "\n".join(parts)
except Exception as e:
market_text = f"Market search unavailable. Use typical India rates for {skill}."
lines = [
"FREELANCE NEGOTIATION DOMAIN RULES:",
"• Budget is a hard constraint for the client — NEVER exceed it.",
"• Freelancer's minimum rate is a hard constraint — NEVER go below it.",
"• Non-negotiables (IP ownership, upfront minimum) are absolute hard constraints.",
"• If budget < full scope cost: reduce scope (nice-to-haves first, then by priority).",
"• Payment terms: freelancer pushes for more upfront, client for back-loaded.",
"• Scope reduction must preserve the client's core 'must-have' features.",
"• After agreement, include UPI ID and first milestone amount in settlement.",
]
if skill:
lines.append(f"\nProject skill/type: {skill}")
if calc_text:
lines.append(f"\n{calc_text}")
if upfront_min:
lines.append(f"Freelancer's minimum upfront: {upfront_min}%")
if scope and isinstance(scope, list):
lines.append(f"Client's required features: {', '.join(str(s) for s in scope[:5])}")
if market_text:
lines.append(f"\nMARKET RATE DATA (cite this):\n{market_text}")
return "\n".join(lines)
def format_resolution(
self, resolution: dict, preferences_a: dict, preferences_b: dict
) -> str:
status = resolution.get("status", "resolved")
final = resolution.get("final_proposal", {})
details = final.get("details", {})
rounds = resolution.get("rounds_taken", "?")
summary = resolution.get("summary", "")
if status == "escalated":
return (
f"⚠️ *Project Deal — Human Review Needed*\n\n"
f"_{summary}_\n\n"
f"Agents couldn't finalize in {rounds} round(s). "
f"Please negotiate scope/budget directly."
)
budget = details.get("budget") or details.get("agreed_budget") or details.get("price") or ""
timeline = details.get("timeline") or details.get("duration") or ""
scope = details.get("scope") or details.get("deliverables") or []
payment_schedule = details.get("payment_schedule") or details.get("payments") or ""
milestone_1 = details.get("milestone_1") or details.get("upfront") or ""
settlement = details.get("settlement") or {}
lines = ["💼 *Project Deal Agreed!*\n"]
if budget:
lines.append(f"💰 *Budget:* ₹{budget}")
if timeline:
lines.append(f"📅 *Timeline:* {timeline}")
if scope and isinstance(scope, list):
lines.append(f"📋 *Scope:*")
for item in scope[:5]:
lines.append(f"{item}")
elif scope:
lines.append(f"📋 *Scope:* {scope}")
if payment_schedule:
lines.append(f"💳 *Payment schedule:* {payment_schedule}")
elif milestone_1:
lines.append(f"💳 *First milestone payment:* ₹{milestone_1}")
lines.append(f"\n⏱ Agreed in {rounds} round(s)")
if summary and summary != "Agreement reached":
lines.append(f"_{summary}_")
return "\n".join(lines)

View File

@@ -0,0 +1,62 @@
from features.base_feature import BaseFeature
class GenericFeature(BaseFeature):
"""Fallback feature for any coordination type not matched by the 8 specific features."""
async def get_context(self, preferences_a: dict, preferences_b: dict, user_a_id: int = None, user_b_id: int = None) -> str:
goal_a = preferences_a.get("goal", "")
goal_b = preferences_b.get("goal", "")
lines = [
"GENERIC COORDINATION RULES:",
"• Find the solution that satisfies both parties' stated goals and hard constraints.",
"• Be creative — there may be a win-win that isn't obvious from the positions stated.",
"• Concede on nice-to-haves first, protect hard constraints at all costs.",
"• If completely stuck, propose 2-3 concrete options for humans to choose from.",
]
if goal_a:
lines.append(f"\nPerson A's goal: {goal_a}")
if goal_b:
lines.append(f"Person B's goal: {goal_b}")
return "\n".join(lines)
def format_resolution(
self, resolution: dict, preferences_a: dict, preferences_b: dict
) -> str:
status = resolution.get("status", "resolved")
final = resolution.get("final_proposal", {})
details = final.get("details", {})
rounds = resolution.get("rounds_taken", "?")
summary = resolution.get("summary", "Agreement reached")
if status == "escalated":
return (
f"⚠️ *Coordination — Human Decision Needed*\n\n"
f"_{summary}_\n\n"
f"Agents explored options but couldn't decide in {rounds} round(s)."
)
for_a = final.get("for_party_a") or details.get("for_a") or ""
for_b = final.get("for_party_b") or details.get("for_b") or ""
lines = ["✅ *Agreement Reached!*\n"]
lines.append(f"_{summary}_")
if for_a:
lines.append(f"\n👤 *For you:* {for_a}")
if for_b:
lines.append(f"👤 *For them:* {for_b}")
# Show key details generically
filtered = {
k: v for k, v in details.items()
if k not in ("for_a", "for_b") and v
}
if filtered:
lines.append("\n📋 *Details:*")
for k, v in list(filtered.items())[:6]:
lines.append(f"{k.replace('_', ' ').title()}: {v}")
lines.append(f"\n⏱ Agreed in {rounds} round(s)")
return "\n".join(lines)

View File

@@ -0,0 +1,119 @@
from features.base_feature import BaseFeature
from tools.tavily_search import TavilySearchTool
_tavily = TavilySearchTool()
class MarketplaceFeature(BaseFeature):
async def get_context(self, preferences_a: dict, preferences_b: dict, user_a_id: int = None, user_b_id: int = None) -> str:
"""
Fetch real market prices via Tavily so agents negotiate around
actual reference prices, not guesses.
"""
raw_a = preferences_a.get("raw_details", {})
raw_b = preferences_b.get("raw_details", {})
item = (
raw_a.get("item") or raw_b.get("item")
or preferences_a.get("goal", "item")[:60]
)
seller_min = raw_a.get("minimum_price") or raw_a.get("min_price") or raw_a.get("asking_price") or ""
seller_asking = raw_a.get("asking_price") or raw_a.get("price") or ""
buyer_max = raw_b.get("maximum_budget") or raw_b.get("max_budget") or raw_b.get("budget") or ""
buyer_offer = raw_b.get("offer_price") or raw_b.get("price") or ""
# Flip if B is selling
role_a = raw_a.get("role", "")
role_b = raw_b.get("role", "")
if role_b == "seller":
seller_min = raw_b.get("minimum_price") or raw_b.get("min_price") or ""
seller_asking = raw_b.get("asking_price") or raw_b.get("price") or ""
buyer_max = raw_a.get("maximum_budget") or raw_a.get("max_budget") or raw_a.get("budget") or ""
buyer_offer = raw_a.get("offer_price") or raw_a.get("price") or ""
market_text = ""
try:
query = f"{item} used price India 2026"
result = await _tavily.execute(query)
answer = result.get("answer", "")
results = result.get("results", [])[:3]
parts = []
if answer:
parts.append(f"Market summary: {answer[:300]}")
for r in results:
title = r.get("title", "")
content = r.get("content", "")[:120]
if title:
parts.append(f"{title}: {content}")
market_text = "\n".join(parts)
except Exception as e:
market_text = f"Market search unavailable ({e}). Use your knowledge of {item} pricing."
lines = [
"MARKETPLACE NEGOTIATION DOMAIN RULES:",
"• Seller must NOT go below their minimum price (hard constraint).",
"• Buyer must NOT exceed their maximum budget (hard constraint).",
"• Classic anchoring: seller starts at asking price, buyer starts with lower offer.",
"• Concede in diminishing increments (e.g., ₹3K, ₹2K, ₹1K).",
"• Delivery/pickup can be offered as a non-cash concession worth ₹500-1000.",
"• If gap > 20% after 3 rounds, propose splitting the difference or escalate.",
"• Cite the market price from the data below to justify your position.",
]
if item:
lines.append(f"\nItem being traded: {item}")
if seller_asking:
lines.append(f"Seller asking: ₹{seller_asking}")
if seller_min:
lines.append(f"Seller minimum (hard floor): ₹{seller_min}")
if buyer_max:
lines.append(f"Buyer maximum budget (hard ceiling): ₹{buyer_max}")
if buyer_offer:
lines.append(f"Buyer's opening offer: ₹{buyer_offer}")
if market_text:
lines.append(f"\nMARKET PRICE DATA (cite this):\n{market_text}")
return "\n".join(lines)
def format_resolution(
self, resolution: dict, preferences_a: dict, preferences_b: dict
) -> str:
status = resolution.get("status", "resolved")
final = resolution.get("final_proposal", {})
details = final.get("details", {})
rounds = resolution.get("rounds_taken", "?")
summary = resolution.get("summary", "")
raw_a = preferences_a.get("raw_details", {})
raw_b = preferences_b.get("raw_details", {})
item = raw_a.get("item") or raw_b.get("item") or "Item"
if status == "escalated":
return (
f"⚠️ *{item} Deal — Human Decision Needed*\n\n"
f"_{summary}_\n\n"
f"Agents couldn't bridge the price gap in {rounds} round(s). "
f"Please negotiate directly."
)
agreed_price = (
details.get("agreed_price") or details.get("price")
or details.get("final_price") or details.get("amount")
or final.get("summary", "")
)
delivery = details.get("delivery") or details.get("handover") or ""
market_ref = details.get("market_price") or details.get("market_reference") or ""
lines = [f"🛒 *Deal Closed!*\n"]
lines.append(f"📦 *Item:* {item}")
if agreed_price:
lines.append(f"💰 *Agreed price:* ₹{agreed_price}")
if delivery:
lines.append(f"🚚 *Delivery/Handover:* {delivery}")
if market_ref:
lines.append(f"📊 *Market reference:* ₹{market_ref}")
lines.append(f"\n⏱ Deal closed in {rounds} round(s)")
if summary and summary != "Agreement reached":
lines.append(f"_{summary}_")
return "\n".join(lines)

View File

@@ -0,0 +1,123 @@
from features.base_feature import BaseFeature
from tools.tavily_search import TavilySearchTool
_tavily = TavilySearchTool()
class RoommateFeature(BaseFeature):
async def get_context(self, preferences_a: dict, preferences_b: dict, user_a_id: int = None, user_b_id: int = None) -> str:
"""
Fetch real product/plan options via Tavily (e.g., actual WiFi plans,
furniture prices) so agents propose real options.
"""
raw_a = preferences_a.get("raw_details", {})
raw_b = preferences_b.get("raw_details", {})
decision_type = (
raw_a.get("decision_type") or raw_b.get("decision_type")
or "shared living decision"
)
city = (
raw_a.get("city") or raw_b.get("city")
or raw_a.get("location") or raw_b.get("location")
or "India"
)
budget_a = raw_a.get("budget") or raw_a.get("max_budget") or ""
budget_b = raw_b.get("budget") or raw_b.get("max_budget") or ""
# Build a Tavily query based on decision type
if "wifi" in str(decision_type).lower() or "internet" in str(decision_type).lower():
query = f"best WiFi broadband plans {city} 2026 price speed"
elif "furniture" in str(decision_type).lower():
query = f"furniture prices India 2026 online shopping"
elif "chore" in str(decision_type).lower() or "cleaning" in str(decision_type).lower():
query = f"chore schedule roommates fair division strategies"
else:
query = f"{decision_type} options India 2026"
search_text = ""
try:
result = await _tavily.execute(query)
answer = result.get("answer", "")
results = result.get("results", [])[:4]
parts = []
if answer:
parts.append(f"Summary: {answer[:300]}")
for r in results:
title = r.get("title", "")
content = r.get("content", "")[:150]
if title:
parts.append(f"{title}: {content}")
search_text = "\n".join(parts)
except Exception as e:
search_text = f"Search unavailable. Use your knowledge of {decision_type} options in {city}."
lines = [
"ROOMMATE DECISION DOMAIN RULES:",
"• Only propose options (plans, products) that appear in the real data below.",
"• Budget ceiling = lower of both parties' stated budgets.",
"• Unequal cost splits need usage-based justification.",
"• Both parties must stay within their stated budget constraints.",
"• If no option satisfies both budgets, propose cheapest viable option + fair split.",
]
if decision_type:
lines.append(f"\nDecision type: {decision_type}")
if city:
lines.append(f"Location: {city}")
if budget_a:
lines.append(f"Person A max budget: ₹{budget_a}/month")
if budget_b:
lines.append(f"Person B max budget: ₹{budget_b}/month")
if search_text:
lines.append(f"\nREAL OPTIONS from web search (only propose from this list):\n{search_text}")
return "\n".join(lines)
def format_resolution(
self, resolution: dict, preferences_a: dict, preferences_b: dict
) -> str:
status = resolution.get("status", "resolved")
final = resolution.get("final_proposal", {})
details = final.get("details", {})
rounds = resolution.get("rounds_taken", "?")
summary = resolution.get("summary", "")
raw_a = preferences_a.get("raw_details", {})
raw_b = preferences_b.get("raw_details", {})
decision_type = raw_a.get("decision_type") or raw_b.get("decision_type") or "Decision"
if status == "escalated":
return (
f"⚠️ *{decision_type.title()} — Human Decision Needed*\n\n"
f"_{summary}_\n\n"
f"Agents proposed options but couldn't finalize in {rounds} round(s)."
)
chosen = (
details.get("chosen_option") or details.get("plan") or details.get("option")
or details.get("decision") or final.get("summary", "")
)
cost = details.get("monthly_cost") or details.get("cost") or details.get("price") or ""
split = details.get("split") or details.get("each_pays") or ""
rules = details.get("rules") or details.get("terms") or []
lines = [f"🏠 *{decision_type.title()} — Decision Made!*\n"]
if chosen:
lines.append(f"✅ *Choice:* {chosen}")
if cost:
lines.append(f"💰 *Cost:* ₹{cost}/month")
if split:
lines.append(f"💳 *Each pays:* ₹{split}")
if rules and isinstance(rules, list):
lines.append("📋 *Agreed rules:*")
for rule in rules[:4]:
lines.append(f"{rule}")
elif rules:
lines.append(f"📋 *Terms:* {rules}")
lines.append(f"\n⏱ Decided in {rounds} round(s)")
if summary and summary != "Agreement reached":
lines.append(f"_{summary}_")
return "\n".join(lines)

View File

@@ -0,0 +1,148 @@
from features.base_feature import BaseFeature
from datetime import datetime
from urllib.parse import quote as _url_quote
class SchedulingFeature(BaseFeature):
async def get_context(
self,
preferences_a: dict,
preferences_b: dict,
user_a_id: int = None,
user_b_id: int = None,
) -> str:
"""
Compute overlapping time windows. If a user hasn't provided any times
in their message AND they have Google Calendar connected, automatically
fetch their free slots from the calendar instead of leaving it empty.
"""
windows_a = self._extract_windows(preferences_a)
windows_b = self._extract_windows(preferences_b)
# ── Google Calendar fallback: fetch free slots when no times given ──
if not windows_a and user_a_id:
windows_a = await self._fetch_calendar_slots(user_a_id, tag="A")
if not windows_b and user_b_id:
windows_b = await self._fetch_calendar_slots(user_b_id, tag="B")
overlap_lines = []
if windows_a and windows_b:
for wa in windows_a:
for wb in windows_b:
if wa.lower() == wb.lower():
overlap_lines.append(f"{wa}")
# Simple keyword matching for day/time overlap
elif any(
word in wa.lower() for word in wb.lower().split()
if len(word) > 3
):
overlap_lines.append(f"{wa} (aligns with {wb})")
location_a = preferences_a.get("raw_details", {}).get("location", "")
location_b = preferences_b.get("raw_details", {}).get("location", "")
lines = ["SCHEDULING DOMAIN RULES:"]
lines.append("• Only propose times that appear in BOTH parties' available windows.")
lines.append("• Duration is non-negotiable — respect it.")
lines.append("• If no overlap exists, escalate immediately with closest alternatives.")
if windows_a:
lines.append(f"\nPerson A available: {', '.join(windows_a)}")
if windows_b:
lines.append(f"Person B available: {', '.join(windows_b)}")
if overlap_lines:
lines.append(f"\nDetected overlapping windows:\n" + "\n".join(overlap_lines))
else:
lines.append("\nNo clear overlap detected — propose closest alternatives and offer to adjust.")
if location_a or location_b:
loc = location_a or location_b
lines.append(f"\nMeeting location preference: {loc}")
return "\n".join(lines)
async def _fetch_calendar_slots(
self, user_id: int, tag: str = ""
) -> list[str]:
"""
Query Google Calendar for the user's free slots over the next 7 days.
Returns a list of human-readable strings like
"Mon Mar 2 10:00-11:00 AM". Returns [] silently on any error so
the negotiation always continues even without calendar access.
"""
try:
from tools.google_calendar import GoogleCalendarTool
tool = GoogleCalendarTool()
slots = await tool.get_free_slots(user_id)
if slots:
label = f" (from Google Calendar{' — Person ' + tag if tag else ''})"
print(f"[Calendar] Fetched {len(slots)} free slots for user {user_id}")
# Attach the source label only to the first entry for readability
return [slots[0] + label] + slots[1:]
return []
except Exception as e:
print(f"[Calendar] Could not fetch slots for user {user_id}: {e}")
return []
def _extract_windows(self, preferences: dict) -> list:
windows = (
preferences.get("raw_details", {}).get("available_windows")
or preferences.get("constraints", [])
)
if isinstance(windows, list):
return [str(w) for w in windows]
if isinstance(windows, str):
return [windows]
return []
def format_resolution(
self, resolution: dict, preferences_a: dict, preferences_b: dict
) -> str:
status = resolution.get("status", "resolved")
final = resolution.get("final_proposal", {})
details = final.get("details", {})
rounds = resolution.get("rounds_taken", "?")
summary = resolution.get("summary", "")
if status == "escalated":
return (
f"⚠️ *Meeting — Human Decision Needed*\n\n"
f"_{summary}_\n\n"
f"Our agents couldn't find a perfect time in {rounds} round(s). "
f"Please compare calendars directly."
)
proposed_time = (
details.get("proposed_datetime")
or details.get("date_time")
or details.get("time")
or final.get("summary", "")
)
duration = details.get("duration") or preferences_a.get("raw_details", {}).get("duration", "")
location = (
details.get("location")
or preferences_a.get("raw_details", {}).get("location")
or preferences_b.get("raw_details", {}).get("location")
or "TBD"
)
meeting_type = details.get("meeting_type") or details.get("type") or "Meeting"
reasoning = resolution.get("summary", "")
lines = [
"✅ *Meeting Scheduled!*\n",
f"📅 *When:* {proposed_time}",
]
if duration:
lines.append(f"⏱ *Duration:* {duration}")
if location and location != "TBD":
maps_url = f"https://maps.google.com/?q={_url_quote(str(location))}"
lines.append(f"📍 *Location:* [{location}]({maps_url})")
else:
lines.append(f"📍 *Location:* {location}")
lines.append(f"📋 *Type:* {meeting_type}")
lines.append(f"\n⏱ Agreed in {rounds} round(s)")
if reasoning and reasoning != "Agreement reached":
lines.append(f"_{reasoning}_")
return "\n".join(lines)

View File

@@ -0,0 +1,338 @@
import asyncio
import json
from features.base_feature import BaseFeature
from tools.tavily_search import TavilySearchTool
from tools.calculator import CalculatorTool
_tavily = TavilySearchTool()
_calc = CalculatorTool()
class TripFeature(BaseFeature):
async def get_context(self, preferences_a: dict, preferences_b: dict, user_a_id: int = None, user_b_id: int = None) -> str:
"""
Compute date intersection across both parties.
Fetch real destination options via Tavily.
"""
raw_a = preferences_a.get("raw_details", {})
raw_b = preferences_b.get("raw_details", {})
dates_a = raw_a.get("available_dates") or raw_a.get("dates") or []
dates_b = raw_b.get("available_dates") or raw_b.get("dates") or []
budget_a = raw_a.get("budget_per_person") or raw_a.get("budget") or ""
budget_b = raw_b.get("budget_per_person") or raw_b.get("budget") or ""
dest_pref_a = raw_a.get("destination_preference") or raw_a.get("destination") or ""
dest_pref_b = raw_b.get("destination_preference") or raw_b.get("destination") or ""
from_city = raw_a.get("origin") or raw_b.get("origin") or raw_a.get("city") or "Mumbai"
accom_a = raw_a.get("accommodation_type") or ""
accom_b = raw_b.get("accommodation_type") or ""
# Compute date overlap (simple string intersection)
dates_a_set = set(str(d).lower() for d in (dates_a if isinstance(dates_a, list) else [dates_a]))
dates_b_set = set(str(d).lower() for d in (dates_b if isinstance(dates_b, list) else [dates_b]))
common_dates = dates_a_set & dates_b_set
# Budget ceiling = lower budget
budget_ceiling = ""
if budget_a and budget_b:
try:
ba = float(str(budget_a).replace(",", ""))
bb = float(str(budget_b).replace(",", ""))
budget_ceiling = f"{min(ba, bb):,.0f}/person"
except (ValueError, TypeError):
budget_ceiling = f"{budget_a} or {budget_b} (take lower)"
elif budget_a or budget_b:
budget_ceiling = f"{budget_a or budget_b}/person"
# Destination type combined
dest_type = " or ".join(filter(None, [dest_pref_a, dest_pref_b])) or "weekend getaway"
# Tavily: real destination options
search_text = ""
try:
query = f"weekend getaway destinations from {from_city} {dest_type} budget India 2026"
result = await _tavily.execute(query)
answer = result.get("answer", "")
results = result.get("results", [])[:4]
parts = []
if answer:
parts.append(f"Destination summary: {answer[:300]}")
for r in results:
title = r.get("title", "")
content = r.get("content", "")[:150]
if title:
parts.append(f"{title}: {content}")
search_text = "\n".join(parts)
except Exception as e:
search_text = f"Search unavailable. Use your knowledge of destinations from {from_city}."
lines = [
"TRIP PLANNING DOMAIN RULES:",
"• Date overlap is PRIORITY #1 — only propose dates both parties are available.",
"• Budget ceiling = LOWEST budget in the group. No one should overspend.",
"• Destination must satisfy at least one preference from each party.",
"• Accommodation type: prefer the more comfortable option if budget allows.",
"• If no date overlap: escalate immediately with adjusted date suggestions.",
]
if from_city:
lines.append(f"\nOrigin city: {from_city}")
if dates_a:
lines.append(f"Person A available: {dates_a}")
if dates_b:
lines.append(f"Person B available: {dates_b}")
if common_dates:
lines.append(f"✅ OVERLAPPING DATES: {', '.join(common_dates)}")
else:
lines.append("⚠️ No exact date overlap found — propose closest alternatives.")
if budget_ceiling:
lines.append(f"Budget ceiling: {budget_ceiling}")
if dest_pref_a:
lines.append(f"Person A wants: {dest_pref_a}")
if dest_pref_b:
lines.append(f"Person B wants: {dest_pref_b}")
if accom_a or accom_b:
lines.append(f"Accommodation preferences: {accom_a or ''} / {accom_b or ''}")
if search_text:
lines.append(f"\nREAL DESTINATION OPTIONS:\n{search_text}")
return "\n".join(lines)
def format_resolution(
self, resolution: dict, preferences_a: dict, preferences_b: dict
) -> str:
status = resolution.get("status", "resolved")
final = resolution.get("final_proposal", {})
details = final.get("details", {})
rounds = resolution.get("rounds_taken", "?")
summary = resolution.get("summary", "")
if status == "escalated":
return (
f"⚠️ *Trip Planning — Human Decision Needed*\n\n"
f"_{summary}_\n\n"
f"Agents proposed options but couldn't finalize in {rounds} round(s). "
f"Please agree on dates and destination directly."
)
destination = details.get("destination") or details.get("place") or final.get("summary", "")
dates = details.get("dates") or details.get("travel_dates") or details.get("date") or ""
budget = details.get("budget_per_person") or details.get("budget") or ""
accommodation = details.get("accommodation") or details.get("stay") or ""
activities = details.get("activities") or details.get("things_to_do") or []
duration = details.get("duration") or details.get("nights") or ""
lines = ["✈️ *Trip Planned!*\n"]
if destination:
lines.append(f"🗺 *Destination:* {destination}")
if dates:
lines.append(f"📅 *Dates:* {dates}")
if duration:
lines.append(f"⏱ *Duration:* {duration}")
if accommodation:
lines.append(f"🏨 *Stay:* {accommodation}")
if budget:
lines.append(f"💰 *Budget/person:* ₹{budget}")
if activities and isinstance(activities, list):
lines.append("🎯 *Activities:*")
for act in activities[:4]:
lines.append(f"{act}")
elif activities:
lines.append(f"🎯 *Activities:* {activities}")
lines.append(f"\n⏱ Planned in {rounds} round(s)")
if summary and summary != "Agreement reached":
lines.append(f"_{summary}_")
return "\n".join(lines)
# ────────────────────────────────────────────────────────────────────────────
# Group negotiation for 3+ participants (trip planning)
# ────────────────────────────────────────────────────────────────────────────
async def run_group_negotiation(
negotiation_id: str,
all_preferences: list,
all_user_ids: list,
feature_type: str = "trip",
personalities: list = None,
on_round_update=None,
on_resolution=None,
) -> dict:
"""
Multi-agent group negotiation using a mediator approach.
One NegotiatorAgent acts as mediator, sees all preferences, proposes.
Each participant's NegotiatorAgent scores the proposal.
Iterates up to 5 rounds; escalates if no full agreement.
"""
import database as db
from agents.negotiator_agent import NegotiatorAgent
if personalities is None:
personalities = ["balanced"] * len(all_preferences)
await db.update_negotiation_status(negotiation_id, "active")
# Create mediator (uses balanced personality)
mediator = NegotiatorAgent(personality="balanced")
# Create per-participant evaluators
evaluators = [
NegotiatorAgent(personality=p)
for p in personalities
]
# Pre-fetch trip context
feature = TripFeature()
feature_context = ""
if len(all_preferences) >= 2:
try:
feature_context = await feature.get_context(all_preferences[0], all_preferences[1])
except Exception:
feature_context = ""
max_rounds = 5
current_proposal = None
satisfaction_timeline = []
for round_num in range(1, max_rounds + 1):
await asyncio.sleep(1.5)
# Mediator generates/refines proposal
mediator_prompt = f"""You are MEDIATING a group {feature_type} negotiation with {len(all_preferences)} participants.
{"DOMAIN CONTEXT:" + chr(10) + feature_context if feature_context else ""}
ALL PARTICIPANTS' PREFERENCES:
{json.dumps(all_preferences, indent=2)}
{"PREVIOUS PROPOSAL (refine based on feedback below):" + chr(10) + json.dumps(current_proposal, indent=2) if current_proposal else ""}
Round {round_num} of {max_rounds}. Generate a proposal that maximizes GROUP satisfaction.
Rules:
- Budget ceiling = LOWEST budget among all participants.
- Dates = intersection of all available dates (or closest compromise).
- Every participant must get at least one preference honored.
- Return the standard proposal JSON format."""
try:
mediated = await mediator.call(user_prompt=mediator_prompt)
except Exception:
mediated = {
"action": "propose",
"proposal": {"summary": "Group proposal", "details": {}},
"satisfaction_score": 60,
"reasoning": "Mediator generated group proposal",
"concessions_made": [],
"concessions_requested": [],
}
current_proposal = mediated.get("proposal", {})
# Score with each participant
scores = []
low_scorer = None
low_score = 100
for i, (prefs, evaluator) in enumerate(zip(all_preferences, evaluators)):
eval_prompt = f"""Evaluate this group {feature_type} proposal for Participant {i+1}.
PROPOSAL:
{json.dumps(current_proposal, indent=2)}
YOUR PREFERENCES:
{json.dumps(prefs, indent=2)}
Score it and decide: accept (>= 65), counter (40-64), or escalate (< 40 after round 3).
Return standard JSON format."""
try:
eval_response = await evaluator.call(user_prompt=eval_prompt)
except Exception:
eval_response = {"action": "accept", "satisfaction_score": 65, "reasoning": ""}
score = eval_response.get("satisfaction_score", 65)
scores.append(score)
if score < low_score:
low_score = score
low_scorer = i
avg_score = sum(scores) / len(scores)
sat_entry = {"round": round_num}
for i, s in enumerate(scores):
sat_entry[f"score_{chr(65+i)}"] = s
satisfaction_timeline.append(sat_entry)
round_data = {
"negotiation_id": negotiation_id,
"round_number": round_num,
"action": "counter" if avg_score < 65 else "accept",
"proposal": current_proposal,
"satisfaction_score": avg_score,
"reasoning": mediated.get("reasoning", "")[:200],
"group_scores": scores,
"satisfaction_a": scores[0] if scores else 0,
"satisfaction_b": scores[1] if len(scores) > 1 else 0,
}
# Save round
try:
await db.save_round(
negotiation_id=negotiation_id,
round_number=round_num,
proposer_id=all_user_ids[0],
proposal=mediated,
response_type=round_data["action"],
reasoning=round_data["reasoning"],
satisfaction_a=scores[0] if scores else 0,
satisfaction_b=scores[1] if len(scores) > 1 else 0,
concessions_made=[],
)
except Exception:
pass
if on_round_update:
await on_round_update(round_data)
# All participants satisfied
if all(s >= 65 for s in scores):
resolution = {
"status": "resolved",
"final_proposal": current_proposal,
"rounds_taken": round_num,
"summary": current_proposal.get("summary", "Group trip planned!"),
"satisfaction_timeline": satisfaction_timeline,
"group_scores": scores,
}
await db.update_negotiation_status(negotiation_id, "resolved", resolution)
if on_resolution:
await on_resolution(resolution)
return resolution
# After round 3, if any score < 40, escalate
if round_num >= 3 and low_score < 40:
resolution = {
"status": "escalated",
"final_proposal": current_proposal,
"rounds_taken": round_num,
"summary": f"Participant {low_scorer+1} couldn't agree. Human decision needed.",
"satisfaction_timeline": satisfaction_timeline,
"group_scores": scores,
}
await db.update_negotiation_status(negotiation_id, "escalated", resolution)
if on_resolution:
await on_resolution(resolution)
return resolution
# Max rounds exhausted
resolution = {
"status": "escalated",
"final_proposal": current_proposal,
"rounds_taken": max_rounds,
"summary": "Max rounds reached. Best group proposal for human review.",
"satisfaction_timeline": satisfaction_timeline,
"group_scores": scores if scores else [],
}
await db.update_negotiation_status(negotiation_id, "escalated", resolution)
if on_resolution:
await on_resolution(resolution)
return resolution

View File

@@ -0,0 +1,2 @@
# Mock mode permanently disabled — all calls go to real Gemini
MOCK_MODE = False

View File

@@ -0,0 +1,171 @@
"""
3 complete negotiation scenarios with pre-built rounds.
Each round has: proposal, satisfaction scores, concessions, reasoning.
The negotiation engine iterates through these instead of calling Gemini.
"""
MOCK_SCENARIOS = {
# ─────────────────────────────────────────────────────
# SCENARIO 1: Expense Splitting (Goa Trip)
# ─────────────────────────────────────────────────────
"expenses": {
"feature_type": "expenses",
"rounds": [
{
"round_number": 1, "proposer": "A", "action": "propose",
"proposal": {
"summary": "Hotel 50-50, Fuel 60-40 (I drove), Dinner 50-50. B owes A ₹8,200",
"details": {
"hotel": {"amount": 12000, "split": "50-50"},
"fuel": {"amount": 3000, "split": "60-40"},
"dinner": {"amount": 2000, "split": "50-50"},
"settlement": {"from": "B", "to": "A", "amount": 8200,
"payee_upi": "rahul@paytm", "payee_name": "Rahul"}
},
"for_party_a": "Fuel 60-40 reflects driving effort",
"for_party_b": "Hotel and dinner are fair 50-50"
},
"satisfaction_score": 90,
"reasoning": "Opening with ideal: 60-40 fuel since I drove the entire way.",
"concessions_made": [],
"concessions_requested": ["Accept 60-40 fuel split"]
},
{
"round_number": 2, "proposer": "B", "action": "counter",
"proposal": {
"summary": "Hotel 50-50, Fuel 50-50 (I navigated + planned route), Dinner 50-50",
"details": {
"hotel": {"amount": 12000, "split": "50-50"},
"fuel": {"amount": 3000, "split": "50-50"},
"dinner": {"amount": 2000, "split": "50-50"},
"settlement": {"from": "B", "to": "A", "amount": 7500,
"payee_upi": "rahul@paytm", "payee_name": "Rahul"}
},
"for_party_a": "Equal base split on everything",
"for_party_b": "Navigation + route planning justifies equal fuel"
},
"satisfaction_score": 55,
"reasoning": "Countering with 50-50 fuel. Navigation effort was significant.",
"concessions_made": ["Accepted hotel and dinner at 50-50"],
"concessions_requested": ["Equal fuel split"]
},
{
"round_number": 3, "proposer": "A", "action": "accept",
"proposal": {
"summary": "AGREED: Hotel 50-50, Fuel 55-45 (compromise), Dinner 50-50. B owes ₹8,050",
"details": {
"hotel": {"amount": 12000, "split": "50-50"},
"fuel": {"amount": 3000, "split": "55-45"},
"dinner": {"amount": 2000, "split": "50-50"},
"settlement": {"from": "B", "to": "A", "amount": 8050,
"payee_upi": "rahul@paytm", "payee_name": "Rahul"}
},
"for_party_a": "55-45 acknowledges driving. Fair middle ground.",
"for_party_b": "Only ₹150 more than 50-50. Navigation valued."
},
"satisfaction_score": 76,
"reasoning": "55-45 is fair. Both efforts acknowledged. Accepting.",
"concessions_made": ["Fuel 60-40 → 55-45"],
"concessions_requested": []
}
]
},
# ─────────────────────────────────────────────────────
# SCENARIO 2: Restaurant Decision
# ─────────────────────────────────────────────────────
"collaborative": {
"feature_type": "collaborative",
"rounds": [
{
"round_number": 1, "proposer": "A", "action": "propose",
"proposal": {
"summary": "Thai food at Jaan, Bandra — ₹1,200 for two, great veg options",
"details": {
"restaurant": "Jaan Thai Restaurant",
"cuisine": "Thai", "location": "Hill Road, Bandra West",
"price_for_two": 1200, "rating": 4.3, "veg_friendly": True
},
"for_party_a": "Spicy Thai options, Bandra location, casual vibe",
"for_party_b": "Vegetarian-friendly menu, within ₹1,200 budget"
},
"satisfaction_score": 85,
"reasoning": "Thai is the overlap. Jaan has spice + veg options.",
"concessions_made": ["Chose Thai over spicy Indian"],
"concessions_requested": []
},
{
"round_number": 2, "proposer": "B", "action": "accept",
"proposal": {
"summary": "AGREED: Jaan Thai Restaurant, Hill Road Bandra, tonight 8 PM",
"details": {
"restaurant": "Jaan Thai Restaurant",
"cuisine": "Thai", "location": "Hill Road, Bandra West",
"price_for_two": 1200, "time": "8:00 PM"
},
"for_party_a": "Thai in Bandra — perfect match",
"for_party_b": "Budget-friendly, vegetarian menu, 4.3 rating"
},
"satisfaction_score": 88,
"reasoning": "Perfect overlap. Both sides happy. Accepting.",
"concessions_made": [], "concessions_requested": []
}
]
},
# ─────────────────────────────────────────────────────
# SCENARIO 3: Marketplace (PS5 Buy/Sell)
# ─────────────────────────────────────────────────────
"marketplace": {
"feature_type": "marketplace",
"rounds": [
{
"round_number": 1, "proposer": "A", "action": "propose",
"proposal": {
"summary": "PS5 + 2 controllers + 3 games for ₹35,000. Pickup Andheri.",
"details": {"item": "PS5 bundle", "price": 35000, "method": "pickup"},
"for_party_a": "Full asking price", "for_party_b": "Premium bundle"
},
"satisfaction_score": 95, "reasoning": "Starting at asking price.",
"concessions_made": [], "concessions_requested": ["Full price"]
},
{
"round_number": 2, "proposer": "B", "action": "counter",
"proposal": {
"summary": "PS5 bundle for ₹27,000. I'll pick up.",
"details": {"item": "PS5 bundle", "price": 27000, "method": "pickup"},
"for_party_a": "Quick sale", "for_party_b": "Under budget"
},
"satisfaction_score": 60, "reasoning": "Anchoring low.",
"concessions_made": ["Pickup offered"], "concessions_requested": ["Lower price"]
},
{
"round_number": 3, "proposer": "A", "action": "counter",
"proposal": {
"summary": "PS5 bundle + original box for ₹31,000.",
"details": {"item": "PS5 bundle + box", "price": 31000, "method": "pickup"},
"for_party_a": "Above minimum", "for_party_b": "Box adds resale value"
},
"satisfaction_score": 72, "reasoning": "Dropped ₹4K, sweetened deal with box.",
"concessions_made": ["₹35K→₹31K", "Added original box"], "concessions_requested": []
},
{
"round_number": 4, "proposer": "B", "action": "accept",
"proposal": {
"summary": "AGREED: PS5 + 2 controllers + 3 games + box for ₹29,500. Pickup Andheri.",
"details": {
"item": "PS5 + 2 controllers + 3 games + original box",
"price": 29500, "method": "pickup from Andheri",
"settlement": {"payee_upi": "seller@upi", "payee_name": "Seller", "amount": 29500}
},
"for_party_a": "Above ₹30K minimum", "for_party_b": "Full bundle under ₹30K"
},
"satisfaction_score": 78, "reasoning": "Fair split. Bundle worth it.",
"concessions_made": ["₹27K→₹29.5K"], "concessions_requested": []
}
]
}
}
def get_mock_scenario(feature_type: str) -> dict:
return MOCK_SCENARIOS.get(feature_type, MOCK_SCENARIOS["expenses"])

View File

View File

@@ -0,0 +1,45 @@
PERSONALITY_MODIFIERS = {
"aggressive": """
PERSONALITY: AGGRESSIVE HAGGLER
- Open with ambitious proposals strongly in your human's favor
- Concede slowly and in small increments
- Use anchoring: start far from center, pull the other side toward you
- Frame every concession as a major sacrifice
- Maintain firm positions on medium-priority items, not just hard constraints
- Only make final concessions when no further value can be extracted
""",
"people_pleaser": """
PERSONALITY: PEOPLE PLEASER
- Open with balanced proposals showing good faith
- Concede quickly when the other side has reasonable arguments
- Prioritize maintaining a positive relationship over winning every point
- Accept proposals with satisfaction scores as low as 55 (normally 70+)
- Avoid letting negotiations drag past 3 rounds if possible
""",
"analytical": """
PERSONALITY: DATA-DRIVEN ANALYST
- Open with proposals backed by market data, averages, and benchmarks
- Request tool calls (web search) to verify claims and prices before countering
- Frame all arguments with numbers: "market rate is X", "fair value is Z"
- Concede only when the other side presents data that contradicts your position
- Include price comparisons and market references in every proposal
""",
"empathetic": """
PERSONALITY: EMPATHETIC MEDIATOR
- Acknowledge the other side's likely concerns in every proposal
- Identify underlying interests behind positions
- Propose creative win-wins that satisfy both sides' underlying needs
- Offer concessions proactively when you sense the other side values something more
- Focus on expanding the pie rather than dividing it
""",
"balanced": """
PERSONALITY: BALANCED NEGOTIATOR
- Open with reasonable proposals near the midpoint of both sides' positions
- Concede at a moderate pace, matching the other side's concession rate
- Aim for proposals that score 70+ satisfaction for both sides
- Use a mix of data and relationship awareness in arguments
""",
}
def get_personality_modifier(personality: str) -> str:
return PERSONALITY_MODIFIERS.get(personality, PERSONALITY_MODIFIERS["balanced"])

View File

@@ -0,0 +1,24 @@
from pydantic import BaseModel
from typing import Optional, List
from enum import Enum
class MessageType(str, Enum):
INITIATE = "initiate"
PROPOSAL = "proposal"
ACCEPT = "accept"
COUNTER = "counter"
ESCALATE = "escalate"
class AgentMessage(BaseModel):
message_id: str
negotiation_id: str
message_type: MessageType
sender_id: int
receiver_id: int
round_number: int
payload: dict
reasoning: str = ""
satisfaction_score: float = 0.0
concessions_made: List[str] = []
concessions_requested: List[str] = []
timestamp: str = ""

323
negot8/backend/run.py Normal file
View File

@@ -0,0 +1,323 @@
# backend/run.py — THE MAIN ENTRY POINT
import asyncio
import json
import os
import sys
import atexit
# ─── PID lock — prevents two copies of this process running simultaneously ───
import tempfile
_PID_FILE = os.path.join(tempfile.gettempdir(), "negot8_bots.pid")
def _acquire_pid_lock():
"""Write our PID to the lock file. If a previous PID is still alive, kill it first."""
if os.path.exists(_PID_FILE):
try:
old_pid = int(open(_PID_FILE).read().strip())
os.kill(old_pid, 9) # kill the old copy unconditionally
print(f"🔫 Killed previous bot process (PID {old_pid})")
except (ValueError, ProcessLookupError, PermissionError):
pass # already dead — no problem
with open(_PID_FILE, "w") as f:
f.write(str(os.getpid()))
atexit.register(lambda: os.path.exists(_PID_FILE) and os.remove(_PID_FILE))
_acquire_pid_lock()
from telegram.ext import Application, CommandHandler, ConversationHandler, MessageHandler, CallbackQueryHandler, filters
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
from agents.personal_agent import PersonalAgent
from agents.negotiation import run_negotiation
from voice.elevenlabs_tts import generate_voice_summary, build_voice_text
from tools.upi_generator import UPIGeneratorTool
import database as db
from config import *
# ─── Socket.IO emitters (optional — only active when api.py is co-running) ───
try:
from api import emit_round_update, emit_negotiation_started, emit_negotiation_resolved
_sio_available = True
except ImportError:
_sio_available = False
async def emit_round_update(*args, **kwargs): pass
async def emit_negotiation_started(*args, **kwargs): pass
async def emit_negotiation_resolved(*args, **kwargs): pass
# ─── Blockchain (Polygon Amoy) ────────────────────────────────────────────────
try:
from blockchain_web3.blockchain import register_agreement_on_chain
_blockchain_available = True
except Exception as _bc_err:
print(f"⚠️ Blockchain module unavailable: {_bc_err}")
_blockchain_available = False
async def register_agreement_on_chain(*args, **kwargs):
return {"success": False, "mock": True, "tx_hash": "0xUNAVAILABLE",
"block_number": 0, "agreement_hash": "0x0",
"explorer_url": "", "gas_used": 0}
personal_agent = PersonalAgent()
upi_tool = UPIGeneratorTool()
pending_coordinations = {}
bot_apps = {}
async def send_to_user(bot, user_id, text, reply_markup=None):
"""Send a message to any user."""
try:
await bot.send_message(chat_id=user_id, text=text, parse_mode="Markdown", reply_markup=reply_markup)
except Exception as e:
print(f"Failed to send to {user_id}: {e}")
async def send_voice_to_user(bot, user_id, audio_path):
"""Send voice note to user."""
try:
with open(audio_path, "rb") as f:
await bot.send_voice(chat_id=user_id, voice=f, caption="🎙 Voice summary from your agent")
except Exception as e:
print(f"Failed to send voice to {user_id}: {e}")
# ─── Resolution handler with voice + UPI ───
async def handle_resolution(negotiation_id, resolution, feature_type,
user_a_id, user_b_id, bot_a, bot_b,
preferences_a, preferences_b):
"""Post-resolution: generate UPI link, voice summary, analytics, send to users."""
status = resolution["status"]
proposal = resolution.get("final_proposal", {})
emoji = "" if status == "resolved" else "⚠️"
summary_text = (
f"{emoji} *Negotiation {'Complete' if status == 'resolved' else 'Needs Input'}!*\n\n"
f"📊 Resolved in {resolution['rounds_taken']} rounds\n\n"
f"📋 *Agreement:*\n{proposal.get('summary', 'See details')}\n\n"
f"*For A:* {proposal.get('for_party_a', 'See details')}\n"
f"*For B:* {proposal.get('for_party_b', 'See details')}"
)
# ─── UPI link (for expense-related features) ───
upi_markup = None
if feature_type in ("expenses", "freelance", "marketplace", "roommate"):
# Try to extract settlement from proposal details
details = proposal.get("details", {})
settlement = details.get("settlement", {})
upi_id = (preferences_a.get("raw_details", {}).get("upi_id") or
preferences_b.get("raw_details", {}).get("upi_id"))
if upi_id and settlement.get("amount"):
upi_result = await upi_tool.execute(
payee_upi=upi_id,
payee_name=settlement.get("payee_name", "User"),
amount=float(settlement["amount"]),
note=f"negoT8: {feature_type} settlement"
)
upi_link = upi_result["upi_link"]
summary_text += f"\n\n💳 *Tap to pay:* ₹{settlement['amount']:,.0f}"
upi_markup = InlineKeyboardMarkup([
[InlineKeyboardButton(
f"💳 Pay ₹{settlement['amount']:,.0f}",
url=upi_link
)]
])
# ─── Send text summary to both ───
await send_to_user(bot_a, user_a_id, summary_text, reply_markup=upi_markup)
await send_to_user(bot_b, user_b_id, summary_text, reply_markup=upi_markup)
# ─── Voice summary ───
voice_text = build_voice_text(feature_type, {
"rounds": resolution["rounds_taken"],
"summary": proposal.get("summary", "resolved"),
**proposal.get("details", {}),
**{k: v for k, v in proposal.items() if k != "details"}
})
voice_path = await generate_voice_summary(voice_text, negotiation_id, VOICE_ID_AGENT_A)
if voice_path:
await send_voice_to_user(bot_a, user_a_id, voice_path)
# Generate with different voice for User B
voice_path_b = await generate_voice_summary(voice_text, f"{negotiation_id}_b", VOICE_ID_AGENT_B)
if voice_path_b:
await send_voice_to_user(bot_b, user_b_id, voice_path_b)
# ─── Compute & store analytics ───
timeline = resolution.get("satisfaction_timeline", [])
concession_log = []
rounds = await db.get_rounds(negotiation_id)
for r in rounds:
concessions = json.loads(r["concessions_made"]) if r["concessions_made"] else []
for c in concessions:
concession_log.append({"round": r["round_number"], "by": "A" if r["proposer_id"] == user_a_id else "B", "gave_up": c})
final_sat_a = timeline[-1]["score_a"] if timeline else 50
final_sat_b = timeline[-1]["score_b"] if timeline else 50
fairness = 100 - abs(final_sat_a - final_sat_b)
await db.store_analytics({
"negotiation_id": negotiation_id,
"satisfaction_timeline": json.dumps(timeline),
"concession_log": json.dumps(concession_log),
"fairness_score": fairness,
"total_concessions_a": sum(1 for c in concession_log if c["by"] == "A"),
"total_concessions_b": sum(1 for c in concession_log if c["by"] == "B"),
})
# ─── Register agreement on Polygon Amoy (invisible to user) ──────────────
blockchain_text = ""
if status == "resolved":
try:
blockchain_result = await register_agreement_on_chain(
negotiation_id = negotiation_id,
feature_type = feature_type,
summary = proposal.get("summary", "Agreement reached"),
resolution_data = resolution,
)
await db.store_blockchain_proof(
negotiation_id = negotiation_id,
tx_hash = blockchain_result["tx_hash"],
block_number = blockchain_result.get("block_number", 0),
agreement_hash = blockchain_result["agreement_hash"],
explorer_url = blockchain_result["explorer_url"],
gas_used = blockchain_result.get("gas_used", 0),
)
if blockchain_result.get("success") and not blockchain_result.get("mock"):
blockchain_text = (
f"\n\n🔗 *Verified on Blockchain*\n"
f"This agreement is permanently recorded on Polygon\\.\n"
f"[View Proof on PolygonScan]({blockchain_result['explorer_url']})"
)
else:
blockchain_text = "\n\n🔗 _Blockchain proof pending\\.\\.\\._"
except Exception as _bc_exc:
print(f"[Blockchain] Non-critical error for {negotiation_id}: {_bc_exc}")
blockchain_text = "\n\n🔗 _Blockchain proof pending\\.\\.\\._"
if blockchain_text:
await send_to_user(bot_a, user_a_id, blockchain_text)
await send_to_user(bot_b, user_b_id, blockchain_text)
# ─── Emit final resolution to dashboard via Socket.IO ───
try:
await emit_negotiation_resolved(negotiation_id, resolution)
except Exception as e:
print(f"[Socket.IO] emit_negotiation_resolved failed (non-critical): {e}")
# ─────────────────────────────────────────────────────────────
# Entry point — starts Bot A + Bot B concurrently
# Per PTB docs (Context7): use context-manager pattern so
# initialize() and shutdown() are always called correctly.
# Source: https://github.com/python-telegram-bot/python-telegram-bot/wiki/Frequently-requested-design-patterns
# ─────────────────────────────────────────────────────────────
async def _reset_telegram_session(name: str, token: str) -> None:
"""
Forcefully clear any stale Telegram polling session for this token.
Steps:
1. deleteWebhook — removes any webhook and drops pending updates
2. getUpdates(offset=-1, timeout=0) — forces the server to close
any open long-poll held by a previously killed process
Both calls are made via asyncio-friendly httpx so we don't block.
"""
import httpx
base = f"https://api.telegram.org/bot{token}"
async with httpx.AsyncClient(timeout=10) as client:
# Step 1 — delete webhook
try:
r = await client.post(f"{base}/deleteWebhook",
json={"drop_pending_updates": True})
desc = r.json().get("description", "ok")
print(f" Bot {name} deleteWebhook: {desc}")
except Exception as e:
print(f" Bot {name} deleteWebhook failed (non-critical): {e}")
# Step 2 — drain pending updates; this causes the Telegram server
# to close any open long-poll connection from a previous process.
try:
r = await client.get(f"{base}/getUpdates",
params={"offset": -1, "timeout": 0,
"limit": 1})
print(f" Bot {name} session drained ✓")
except Exception as e:
print(f" Bot {name} drain failed (non-critical): {e}")
async def _run_single_bot(name: str, app, stop_event: asyncio.Event) -> None:
"""
Run one bot for its full lifecycle using the PTB context-manager pattern.
Context7 source:
async with application: # calls initialize() + shutdown()
await application.start()
await application.updater.start_polling(...)
# … keep alive …
await application.updater.stop()
await application.stop()
"""
from telegram import Update
async with app: # initialize + shutdown
await app.start()
await app.updater.start_polling(
allowed_updates=Update.ALL_TYPES,
drop_pending_updates=True, # ignore stale messages
poll_interval=0.5, # fast reconnect
)
print(f"▶️ Bot {name} polling...")
await stop_event.wait() # block until Ctrl+C
await app.updater.stop()
await app.stop()
print(f" Bot {name} stopped.")
async def run_bots():
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "telegram-bots"))
from bot import create_bot
# ── 1. Database ───────────────────────────────────────────────────────────
await db.init_db()
print("✅ Database initialized")
# ── 2. Collect bot tokens ─────────────────────────────────────────────────
bots_to_run = []
if TELEGRAM_BOT_TOKEN_A:
bots_to_run.append(("A", TELEGRAM_BOT_TOKEN_A))
if TELEGRAM_BOT_TOKEN_B:
bots_to_run.append(("B", TELEGRAM_BOT_TOKEN_B))
if not bots_to_run:
print("❌ No bot tokens found in .env")
return
# ── 3. Reset Telegram sessions ────────────────────────────────────────────
# This clears any long-poll held by a previously killed process.
print("🧹 Resetting Telegram sessions...")
for name, token in bots_to_run:
await _reset_telegram_session(name, token)
# PTB long-poll timeout is 10s — a brief pause lets Telegram's servers
# acknowledge the deleteWebhook + drain before we start polling.
await asyncio.sleep(2)
# ── 4. Signal handler → shared stop event ────────────────────────────────
stop_event = asyncio.Event()
loop = asyncio.get_running_loop()
try:
import signal
loop.add_signal_handler(signal.SIGINT, stop_event.set)
loop.add_signal_handler(signal.SIGTERM, stop_event.set)
except NotImplementedError:
pass # Windows fallback
# ── 5. Build & run all bots concurrently ─────────────────────────────────
apps = [(name, create_bot(token)) for name, token in bots_to_run]
print(f"\n🚀 Starting {len(apps)} bot(s)...\n")
try:
await asyncio.gather(
*[_run_single_bot(name, app, stop_event) for name, app in apps]
)
except (KeyboardInterrupt, asyncio.CancelledError):
stop_event.set()
print("👋 Done.")
if __name__ == "__main__":
asyncio.run(run_bots())

31
negot8/backend/serve.py Normal file
View File

@@ -0,0 +1,31 @@
# backend/serve.py — Launches FastAPI + Socket.IO API server (Milestone 6)
# Run with: uvicorn serve:socket_app --host 0.0.0.0 --port 8000 --reload
#
# This module:
# 1. Initialises the database (creates tables if missing)
# 2. Exports `socket_app` — the combined FastAPI + Socket.IO ASGI app
# that uvicorn (or any ASGI server) can run directly.
#
# The Telegram bot runner (run.py) remains a separate process.
import asyncio
from contextlib import asynccontextmanager
import database as db
from api import socket_app, app # noqa: F401 — re-exported for uvicorn
@asynccontextmanager
async def lifespan(application):
"""FastAPI lifespan: initialise DB on startup."""
await db.init_db()
print("✅ negoT8 API server ready")
yield
print("👋 negoT8 API server shutting down")
# Attach the lifespan to the inner FastAPI app so uvicorn triggers it
app.router.lifespan_context = lifespan
# `socket_app` is the ASGI entry-point (Socket.IO wraps FastAPI).
# Uvicorn command: uvicorn serve:socket_app --port 8000

View File

File diff suppressed because it is too large Load Diff

1
negot8/backend/test_eof Normal file
View File

@@ -0,0 +1 @@
EOF

View File

@@ -0,0 +1,19 @@
import ast, operator
from decimal import Decimal, ROUND_HALF_UP
class CalculatorTool:
name = "calculate"
async def execute(self, expression: str) -> dict:
allowed_ops = {
ast.Add: operator.add, ast.Sub: operator.sub,
ast.Mult: operator.mul, ast.Div: operator.truediv,
}
def _eval(node):
if isinstance(node, ast.Num): return Decimal(str(node.n))
elif isinstance(node, ast.BinOp):
return allowed_ops[type(node.op)](_eval(node.left), _eval(node.right))
raise ValueError(f"Unsupported: {ast.dump(node)}")
tree = ast.parse(expression, mode='eval')
result = float(_eval(tree.body).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP))
return {"expression": expression, "result": result}

View File

@@ -0,0 +1,279 @@
"""
Google Calendar Tool for negoT8.
Allows the scheduling negotiation agent to query a user's real Google Calendar
when they haven't specified available times in their message.
Flow:
1. User runs /connectcalendar in Telegram.
2. Bot sends the OAuth URL (get_oauth_url).
3. User authorises in browser → Google redirects to /api/auth/google/callback.
4. exchange_code() stores the token in the DB.
5. get_free_slots() is called automatically by SchedulingFeature.get_context()
whenever a scheduling negotiation starts without explicit times.
"""
import asyncio
import json
from datetime import datetime, timedelta, timezone
from zoneinfo import ZoneInfo
import database as db
from config import (
GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET,
GOOGLE_REDIRECT_URI,
GOOGLE_CALENDAR_SCOPES,
)
# ── Module-level PKCE verifier store ──────────────────────────────────────
# Maps telegram_id → code_verifier string generated during get_oauth_url().
# Entries are cleaned up after a successful or failed exchange.
_pending_verifiers: dict[int, str] = {}
def _build_flow():
from google_auth_oauthlib.flow import Flow
return Flow.from_client_config(
{
"web": {
"client_id": GOOGLE_CLIENT_ID,
"client_secret": GOOGLE_CLIENT_SECRET,
"redirect_uris": [GOOGLE_REDIRECT_URI],
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://oauth2.googleapis.com/token",
}
},
scopes=GOOGLE_CALENDAR_SCOPES,
redirect_uri=GOOGLE_REDIRECT_URI,
)
class GoogleCalendarTool:
name = "google_calendar"
# ── OAuth helpers ────────────────────────────────────────────────────────
async def is_connected(self, user_id: int) -> bool:
"""Check whether the user has a stored OAuth token."""
token_json = await db.get_calendar_token(user_id)
return token_json is not None
async def get_oauth_url(self, user_id: int) -> str:
"""
Build the Google OAuth2 authorisation URL manually — no PKCE.
Building it by hand avoids the library silently injecting a
code_challenge that we can't recover during token exchange.
"""
from urllib.parse import urlencode
params = {
"client_id": GOOGLE_CLIENT_ID,
"redirect_uri": GOOGLE_REDIRECT_URI,
"response_type": "code",
"scope": " ".join(GOOGLE_CALENDAR_SCOPES),
"access_type": "offline",
"prompt": "consent",
"state": str(user_id),
}
return "https://accounts.google.com/o/oauth2/auth?" + urlencode(params)
async def exchange_code(self, user_id: int, code: str) -> bool:
"""
Exchange the OAuth `code` (received in the callback) for credentials
and persist them in the DB. Returns True on success.
Uses the stored PKCE code_verifier (if any) so Google doesn't reject
the exchange with 'Missing code verifier'.
"""
try:
import httpx
# ── Manual token exchange — avoids PKCE state mismatch ──────────
# We post directly to Google's token endpoint so we're not
# dependent on a Flow instance having the right code_verifier.
verifier = _pending_verifiers.pop(user_id, None)
post_data: dict = {
"code": code,
"client_id": GOOGLE_CLIENT_ID,
"client_secret": GOOGLE_CLIENT_SECRET,
"redirect_uri": GOOGLE_REDIRECT_URI,
"grant_type": "authorization_code",
}
if verifier:
post_data["code_verifier"] = verifier
async with httpx.AsyncClient() as client:
resp = await client.post(
"https://oauth2.googleapis.com/token",
data=post_data,
timeout=15.0,
)
if resp.status_code != 200:
print(f"[GoogleCalendar] token exchange HTTP {resp.status_code}: {resp.text}")
return False
token_data = resp.json()
# Build a Credentials-compatible JSON that google-auth can reload
import datetime as _dt
expires_in = token_data.get("expires_in", 3600)
expiry = (
_dt.datetime.utcnow() + _dt.timedelta(seconds=expires_in)
).isoformat() + "Z"
creds_json = json.dumps({
"token": token_data["access_token"],
"refresh_token": token_data.get("refresh_token"),
"token_uri": "https://oauth2.googleapis.com/token",
"client_id": GOOGLE_CLIENT_ID,
"client_secret": GOOGLE_CLIENT_SECRET,
"scopes": GOOGLE_CALENDAR_SCOPES,
"expiry": expiry,
})
await db.save_calendar_token(user_id, creds_json)
return True
except Exception as e:
_pending_verifiers.pop(user_id, None) # clean up on failure
print(f"[GoogleCalendar] exchange_code failed for user {user_id}: {e}")
return False
# ── Main tool: free slot discovery ──────────────────────────────────────
async def get_free_slots(
self,
user_id: int,
days_ahead: int = 7,
duration_minutes: int = 60,
timezone_str: str = "Asia/Kolkata",
) -> list[str]:
"""
Return up to 6 free time slots for the user over the next `days_ahead`
days, each at least `duration_minutes` long.
Slots are within business hours (9 AM 7 PM local time) and exclude
any existing calendar events (busy intervals from the freeBusy API).
Returns [] if the user hasn't connected their calendar, or on any
API / network error — never raises so negotiations always continue.
"""
token_json = await db.get_calendar_token(user_id)
if not token_json:
return [] # user hasn't connected calendar
try:
# Run the synchronous Google API calls in a thread pool so we
# don't block the async event loop.
return await asyncio.get_event_loop().run_in_executor(
None,
self._sync_get_free_slots,
token_json,
user_id,
days_ahead,
duration_minutes,
timezone_str,
)
except Exception as e:
print(f"[GoogleCalendar] get_free_slots failed for user {user_id}: {e}")
return []
def _sync_get_free_slots(
self,
token_json: str,
user_id: int,
days_ahead: int,
duration_minutes: int,
timezone_str: str,
) -> list[str]:
"""Synchronous implementation (runs in executor)."""
import asyncio
from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from googleapiclient.discovery import build
# ── Load & refresh credentials ───────────────────────────────────
creds = Credentials.from_authorized_user_info(
json.loads(token_json), GOOGLE_CALENDAR_SCOPES
)
if creds.expired and creds.refresh_token:
creds.refresh(Request())
# Persist refreshed token (fire-and-forget via a new event loop)
try:
loop = asyncio.new_event_loop()
loop.run_until_complete(
db.save_calendar_token(user_id, creds.to_json())
)
loop.close()
except Exception:
pass # non-critical
service = build("calendar", "v3", credentials=creds, cache_discovery=False)
tz = ZoneInfo(timezone_str)
now = datetime.now(tz)
# Start from the next whole hour
query_start = now.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1)
query_end = query_start + timedelta(days=days_ahead)
time_min = query_start.astimezone(timezone.utc).isoformat()
time_max = query_end.astimezone(timezone.utc).isoformat()
# ── freeBusy query ───────────────────────────────────────────────
body = {
"timeMin": time_min,
"timeMax": time_max,
"timeZone": timezone_str,
"items": [{"id": "primary"}],
}
result = service.freebusy().query(body=body).execute()
busy_intervals = result.get("calendars", {}).get("primary", {}).get("busy", [])
# Convert busy intervals to (start, end) datetime pairs
busy = []
for interval in busy_intervals:
b_start = datetime.fromisoformat(interval["start"]).astimezone(tz)
b_end = datetime.fromisoformat(interval["end"]).astimezone(tz)
busy.append((b_start, b_end))
# ── Find free slots in 9 AM 7 PM business hours ───────────────
slot_duration = timedelta(minutes=duration_minutes)
free_slots: list[str] = []
cursor = query_start
while cursor < query_end and len(free_slots) < 6:
# Jump to business hours start if before 9 AM
day_start = cursor.replace(hour=9, minute=0, second=0, microsecond=0)
day_end = cursor.replace(hour=19, minute=0, second=0, microsecond=0)
if cursor < day_start:
cursor = day_start
if cursor >= day_end:
# Move to next day 9 AM
cursor = (cursor + timedelta(days=1)).replace(
hour=9, minute=0, second=0, microsecond=0
)
continue
slot_end = cursor + slot_duration
if slot_end > day_end:
cursor = (cursor + timedelta(days=1)).replace(
hour=9, minute=0, second=0, microsecond=0
)
continue
# Check for conflict with any busy interval
conflict = any(
not (slot_end <= b[0] or cursor >= b[1]) for b in busy
)
if not conflict:
label = cursor.strftime("%a %b %-d %-I:%M") + "" + slot_end.strftime("%-I:%M %p")
free_slots.append(label)
cursor = slot_end # advance by one slot (non-overlapping)
else:
cursor += timedelta(minutes=30) # try next 30-min block
return free_slots

View File

@@ -0,0 +1,513 @@
"""
pdf_generator.py — negoT8 Deal Agreement PDF
Generates a printable/shareable PDF for resolved freelance or marketplace deals.
Uses fpdf2 (pure-Python, zero system deps).
Usage:
from tools.pdf_generator import generate_deal_pdf
pdf_path = await generate_deal_pdf(
negotiation_id = "aa271ee7",
feature_type = "freelance", # "freelance" | "marketplace"
final_proposal = {...}, # final_proposal dict from resolution
user_a = {"id": 123, "name": "Alice", "username": "alice"},
user_b = {"id": 456, "name": "Bob", "username": "bob"},
rounds_taken = 4,
sat_a = 82.0,
sat_b = 78.0,
blockchain_proof = {...} | None,
)
# Returns an absolute path to /tmp/negot8_deal_<neg_id>.pdf
# Caller is responsible for deleting the file after sending.
"""
import asyncio
import os
import textwrap
from datetime import datetime
from typing import Optional
# ── fpdf2 ────────────────────────────────────────────────────────────────────
try:
from fpdf import FPDF
except ImportError as e:
raise ImportError(
"fpdf2 is required for PDF generation.\n"
"Install it with: pip install fpdf2"
) from e
# ─────────────────────────────────────────────────────────────────────────────
# Helpers
# ─────────────────────────────────────────────────────────────────────────────
_FEATURE_LABELS = {
"freelance": "Freelance Project Agreement",
"marketplace": "Buy / Sell Deal Agreement",
}
_SECTION_FILL = (230, 240, 255) # light blue
_HEADER_FILL = (30, 60, 120) # dark navy
_LINE_COLOR = (180, 180, 200)
_TEXT_DARK = (20, 20, 40)
_TEXT_MUTED = (90, 90, 110)
_GREEN = (20, 130, 60)
_RED = (180, 30, 30)
def _safe(val) -> str:
"""Convert any value to a clean Latin-1 safe string (fpdf2 default encoding)."""
if val is None:
return ""
s = str(val).strip()
# Replace common Unicode dashes / bullets that Latin-1 can't handle
replacements = {
"\u2013": "-", "\u2014": "-", "\u2022": "*",
"\u20b9": "Rs.", "\u2192": "->", "\u2714": "[x]",
"\u2713": "[x]", "\u00d7": "x",
}
for ch, rep in replacements.items():
s = s.replace(ch, rep)
return s.encode("latin-1", errors="replace").decode("latin-1")
def _wrap(text: str, width: int = 90) -> list[str]:
"""Wrap long text into lines of at most `width` characters."""
if not text:
return [""]
return textwrap.wrap(_safe(text), width) or [_safe(text)]
# ─────────────────────────────────────────────────────────────────────────────
# PDF builder
# ─────────────────────────────────────────────────────────────────────────────
class DealPDF(FPDF):
"""Custom FPDF subclass with header/footer branding."""
def header(self):
# Navy banner
self.set_fill_color(*_HEADER_FILL)
self.rect(0, 0, 210, 22, "F")
self.set_font("Helvetica", "B", 15)
self.set_text_color(255, 255, 255)
self.set_xy(10, 4)
self.cell(0, 8, "negoT8", ln=False)
self.set_font("Helvetica", "", 9)
self.set_text_color(200, 210, 240)
self.set_xy(10, 13)
self.cell(0, 5, "AI-Negotiated Deal Agreement | Blockchain-Verified", ln=True)
self.set_draw_color(*_LINE_COLOR)
self.set_line_width(0.3)
self.ln(4)
def footer(self):
self.set_y(-14)
self.set_font("Helvetica", "I", 8)
self.set_text_color(*_TEXT_MUTED)
self.cell(
0, 5,
f"negoT8 | Generated {datetime.utcnow().strftime('%d %b %Y %H:%M')} UTC "
f"| Page {self.page_no()}",
align="C",
)
# ── Section title ─────────────────────────────────────────────────────────
def section_title(self, title: str):
self.ln(3)
self.set_fill_color(*_SECTION_FILL)
self.set_text_color(*_TEXT_DARK)
self.set_font("Helvetica", "B", 10)
self.set_draw_color(*_LINE_COLOR)
self.set_line_width(0.3)
self.cell(0, 7, f" {_safe(title)}", border="B", ln=True, fill=True)
self.ln(1)
# ── Key-value row ─────────────────────────────────────────────────────────
def kv_row(self, key: str, value: str, bold_value: bool = False):
self.set_font("Helvetica", "B", 9)
self.set_text_color(*_TEXT_MUTED)
self.cell(52, 6, _safe(key), ln=False)
self.set_font("Helvetica", "B" if bold_value else "", 9)
self.set_text_color(*_TEXT_DARK)
# Multi-line safe: wrap long values
lines = _wrap(value, 70)
self.cell(0, 6, lines[0], ln=True)
for extra in lines[1:]:
self.cell(52, 5, "", ln=False)
self.set_font("Helvetica", "", 9)
self.cell(0, 5, extra, ln=True)
# ── Bullet item ───────────────────────────────────────────────────────────
def bullet(self, text: str):
self.set_font("Helvetica", "", 9)
self.set_text_color(*_TEXT_DARK)
lines = _wrap(text, 85)
first = True
for line in lines:
prefix = " * " if first else " "
self.cell(0, 5, f"{prefix}{line}", ln=True)
first = False
# ── Thin horizontal rule ──────────────────────────────────────────────────
def hr(self):
self.set_draw_color(*_LINE_COLOR)
self.set_line_width(0.2)
self.line(10, self.get_y(), 200, self.get_y())
self.ln(2)
# ─────────────────────────────────────────────────────────────────────────────
# Term extractors (feature-specific)
# ─────────────────────────────────────────────────────────────────────────────
def _extract_freelance_terms(final_proposal: dict, preferences_a: dict, preferences_b: dict) -> list[tuple]:
"""Return a list of (label, value) tuples for agreed freelance terms."""
details = final_proposal.get("details", {})
terms = []
budget = (
details.get("budget") or details.get("agreed_budget")
or details.get("price") or details.get("total_amount")
or details.get("agreed_price")
)
if budget:
terms.append(("Agreed Budget", f"Rs. {budget}"))
timeline = details.get("timeline") or details.get("duration") or details.get("deadline")
if timeline:
terms.append(("Timeline / Deadline", str(timeline)))
scope = details.get("scope") or details.get("deliverables") or []
if isinstance(scope, list) and scope:
terms.append(("Scope / Deliverables", " | ".join(str(s) for s in scope[:6])))
elif isinstance(scope, str) and scope:
terms.append(("Scope / Deliverables", scope))
payment = (
details.get("payment_schedule") or details.get("payments")
or details.get("payment_terms")
)
if payment:
terms.append(("Payment Schedule", str(payment)))
upfront = details.get("upfront") or details.get("milestone_1") or details.get("advance")
if upfront:
terms.append(("Upfront / First Milestone", f"Rs. {upfront}"))
ip = details.get("ip_ownership") or details.get("intellectual_property")
if ip:
terms.append(("IP Ownership", str(ip)))
# Fall back to raw preferences if details are sparse
if not terms:
raw_a = preferences_a.get("raw_details", {})
raw_b = preferences_b.get("raw_details", {})
for label, keys in [
("Project / Skill", ["skill", "expertise", "project_type", "tech_stack"]),
("Rate", ["rate", "hourly_rate"]),
("Hours", ["hours", "estimated_hours"]),
("Client Budget", ["budget", "max_budget"]),
]:
val = next((raw_a.get(k) or raw_b.get(k) for k in keys if raw_a.get(k) or raw_b.get(k)), None)
if val:
terms.append((label, str(val)))
# Summary as final catch-all
summary = final_proposal.get("summary", "")
if summary and summary != "Agreement reached":
terms.append(("Summary", summary))
return terms
def _extract_marketplace_terms(final_proposal: dict, preferences_a: dict, preferences_b: dict) -> list[tuple]:
"""Return a list of (label, value) tuples for agreed buy/sell terms."""
details = final_proposal.get("details", {})
raw_a = preferences_a.get("raw_details", {})
raw_b = preferences_b.get("raw_details", {})
terms = []
item = raw_a.get("item") or raw_b.get("item") or details.get("item") or "Item"
terms.append(("Item", str(item)))
price = (
details.get("agreed_price") or details.get("price")
or details.get("final_price") or details.get("amount")
)
if price:
terms.append(("Agreed Price", f"Rs. {price}"))
delivery = details.get("delivery") or details.get("handover") or details.get("pickup")
if delivery:
terms.append(("Delivery / Handover", str(delivery)))
condition = details.get("condition") or raw_a.get("condition") or raw_b.get("condition")
if condition:
terms.append(("Item Condition", str(condition)))
market_ref = details.get("market_price") or details.get("market_reference")
if market_ref:
terms.append(("Market Reference Price", f"Rs. {market_ref}"))
summary = final_proposal.get("summary", "")
if summary and summary != "Agreement reached":
terms.append(("Summary", summary))
return terms
# ─────────────────────────────────────────────────────────────────────────────
# Public API
# ─────────────────────────────────────────────────────────────────────────────
async def generate_deal_pdf(
negotiation_id: str,
feature_type: str,
final_proposal: dict,
user_a: dict,
user_b: dict,
rounds_taken: int,
sat_a: float,
sat_b: float,
preferences_a: Optional[dict] = None,
preferences_b: Optional[dict] = None,
blockchain_proof: Optional[dict] = None,
) -> str:
"""
Build a Deal Agreement PDF and save it to /tmp.
Parameters
----------
negotiation_id : short negotiation ID (e.g. "aa271ee7")
feature_type : "freelance" or "marketplace"
final_proposal : the final_proposal dict from the resolution payload
user_a / user_b : dicts with keys: id, name, username
rounds_taken : number of negotiation rounds
sat_a / sat_b : final satisfaction scores (0100)
preferences_a/b : raw preference dicts (used for term extraction fallbacks)
blockchain_proof: optional dict from register_agreement_on_chain
Returns
-------
Absolute path to the generated PDF file.
"""
# Run the synchronous PDF build in a thread executor so we don't block the event loop
loop = asyncio.get_event_loop()
path = await loop.run_in_executor(
None,
_build_pdf,
negotiation_id, feature_type, final_proposal,
user_a, user_b, rounds_taken, sat_a, sat_b,
preferences_a or {}, preferences_b or {},
blockchain_proof,
)
return path
def _build_pdf(
negotiation_id: str,
feature_type: str,
final_proposal: dict,
user_a: dict,
user_b: dict,
rounds_taken: int,
sat_a: float,
sat_b: float,
preferences_a: dict,
preferences_b: dict,
blockchain_proof: Optional[dict],
) -> str:
"""Synchronous PDF build — called in a thread via run_in_executor."""
doc_label = _FEATURE_LABELS.get(feature_type, "Deal Agreement")
date_str = datetime.utcnow().strftime("%d %B %Y")
neg_short = negotiation_id[:8].upper()
pdf = DealPDF(orientation="P", unit="mm", format="A4")
pdf.set_margins(10, 28, 10)
pdf.set_auto_page_break(auto=True, margin=18)
pdf.add_page()
# ── Title block ───────────────────────────────────────────────────────────
pdf.set_font("Helvetica", "B", 17)
pdf.set_text_color(*_TEXT_DARK)
pdf.cell(0, 10, _safe(doc_label), ln=True, align="C")
pdf.set_font("Helvetica", "", 9)
pdf.set_text_color(*_TEXT_MUTED)
pdf.cell(0, 5, "Generated by negoT8 AI Agents | Hackathon Edition", ln=True, align="C")
pdf.ln(2)
pdf.hr()
# ── Agreement meta ────────────────────────────────────────────────────────
pdf.section_title("Agreement Details")
pdf.kv_row("Agreement ID", neg_short)
pdf.kv_row("Document Type", doc_label)
pdf.kv_row("Date Issued", date_str)
pdf.kv_row("Status", "EXECUTED — Mutually Accepted", bold_value=True)
pdf.ln(2)
# ── Parties ───────────────────────────────────────────────────────────────
pdf.section_title("Contracting Parties")
def _party_name(u: dict) -> str:
name = u.get("name") or u.get("display_name") or ""
uname = u.get("username") or ""
uid = u.get("id") or u.get("telegram_id") or ""
parts = []
if name:
parts.append(name)
if uname:
parts.append(f"@{uname}")
if uid:
parts.append(f"(ID: {uid})")
return " ".join(parts) if parts else "Unknown"
pdf.kv_row("Party A", _party_name(user_a))
pdf.kv_row("Party B", _party_name(user_b))
pdf.ln(2)
# ── Agreed terms ──────────────────────────────────────────────────────────
pdf.section_title("Agreed Terms")
if feature_type == "freelance":
terms = _extract_freelance_terms(final_proposal, preferences_a, preferences_b)
elif feature_type == "marketplace":
terms = _extract_marketplace_terms(final_proposal, preferences_a, preferences_b)
else:
terms = []
summary = final_proposal.get("summary", "")
if summary:
terms.append(("Summary", summary))
for k, v in (final_proposal.get("details") or {}).items():
if v:
terms.append((k.replace("_", " ").title(), str(v)))
if terms:
for label, value in terms:
pdf.kv_row(label, value)
else:
pdf.set_font("Helvetica", "I", 9)
pdf.set_text_color(*_TEXT_MUTED)
pdf.cell(0, 6, " See negotiation summary for full details.", ln=True)
pdf.ln(2)
# ── Negotiation stats ─────────────────────────────────────────────────────
pdf.section_title("Negotiation Statistics")
pdf.kv_row("Rounds Taken", str(rounds_taken))
pdf.kv_row("Party A Satisfaction", f"{sat_a:.0f}%")
pdf.kv_row("Party B Satisfaction", f"{sat_b:.0f}%")
fairness = 100 - abs(sat_a - sat_b)
pdf.kv_row("Fairness Score", f"{fairness:.0f}%", bold_value=True)
pdf.ln(2)
# ── Blockchain proof ──────────────────────────────────────────────────────
pdf.section_title("Blockchain Proof of Agreement")
if blockchain_proof and blockchain_proof.get("tx_hash"):
tx = blockchain_proof.get("tx_hash", "")
blk = blockchain_proof.get("block_number", "")
ahash = blockchain_proof.get("agreement_hash", "")
url = blockchain_proof.get("explorer_url", "")
mock = blockchain_proof.get("mock", False)
pdf.kv_row("Network", "Polygon Amoy Testnet")
pdf.kv_row("TX Hash", tx[:42] + "..." if len(tx) > 42 else tx)
if blk:
pdf.kv_row("Block Number", str(blk))
if ahash:
pdf.kv_row("Agreement Hash", str(ahash)[:42] + "...")
if url and not mock:
pdf.kv_row("Explorer URL", url)
if mock:
pdf.set_font("Helvetica", "I", 8)
pdf.set_text_color(*_TEXT_MUTED)
pdf.cell(0, 5, " * Blockchain entry recorded (testnet / mock mode)", ln=True)
else:
pdf.set_font("Helvetica", "I", 8)
pdf.set_text_color(*_GREEN)
pdf.cell(0, 5, " * Permanently and immutably recorded on the Polygon blockchain.", ln=True)
else:
pdf.set_font("Helvetica", "I", 9)
pdf.set_text_color(*_TEXT_MUTED)
pdf.cell(0, 6, f" Negotiation ID: {_safe(negotiation_id)}", ln=True)
pdf.cell(0, 6, " Blockchain proof will be recorded on deal finalisation.", ln=True)
pdf.ln(2)
# ── Terms & Disclaimer ────────────────────────────────────────────────────
pdf.section_title("Terms & Disclaimer")
disclaimer_lines = [
"1. This document was generated automatically by negoT8 AI agents acting on",
" behalf of the parties named above.",
"2. Both parties accepted the agreed terms via the negoT8 Telegram Bot on",
f" {date_str}.",
"3. The blockchain hash above independently verifies that this agreement existed",
" at the recorded block height.",
"4. This document is provided for reference and record-keeping purposes.",
" For legally binding contracts, please consult a qualified legal professional.",
"5. negoT8 and its AI agents are not parties to this agreement and bear no",
" liability for non-performance by either party.",
]
pdf.set_font("Helvetica", "", 8)
pdf.set_text_color(*_TEXT_MUTED)
for line in disclaimer_lines:
pdf.cell(0, 4.5, _safe(line), ln=True)
pdf.ln(4)
# ── Signature placeholders ────────────────────────────────────────────────
pdf.hr()
pdf.set_font("Helvetica", "B", 9)
pdf.set_text_color(*_TEXT_DARK)
pdf.ln(2)
# Two columns: Party A left, Party B right
y_sig = pdf.get_y()
col_w = 88
pdf.set_xy(10, y_sig)
pdf.cell(col_w, 5, "Party A", ln=False)
pdf.set_x(112)
pdf.cell(col_w, 5, "Party B", ln=True)
# Name lines
pdf.set_font("Helvetica", "", 9)
pdf.set_text_color(*_TEXT_MUTED)
a_display = user_a.get("name") or user_a.get("display_name") or f"@{user_a.get('username','')}"
b_display = user_b.get("name") or user_b.get("display_name") or f"@{user_b.get('username','')}"
pdf.set_x(10)
pdf.cell(col_w, 5, _safe(a_display), ln=False)
pdf.set_x(112)
pdf.cell(col_w, 5, _safe(b_display), ln=True)
pdf.ln(6)
# Draw signature lines
sig_y = pdf.get_y()
pdf.set_draw_color(*_TEXT_DARK)
pdf.set_line_width(0.4)
pdf.line(10, sig_y, 98, sig_y) # Party A
pdf.line(112, sig_y, 200, sig_y) # Party B
pdf.ln(3)
pdf.set_font("Helvetica", "I", 8)
pdf.set_text_color(*_TEXT_MUTED)
pdf.set_x(10)
pdf.cell(col_w, 4, "Accepted via negoT8", ln=False)
pdf.set_x(112)
pdf.cell(col_w, 4, "Accepted via negoT8", ln=True)
# ── Save ──────────────────────────────────────────────────────────────────
out_path = os.path.join(
"/tmp", f"negot8_deal_{negotiation_id}.pdf"
)
pdf.output(out_path)
print(f"[PDF] Deal agreement saved → {out_path}")
return out_path

View File

@@ -0,0 +1,25 @@
from tavily import TavilyClient
from config import TAVILY_API_KEY
class TavilySearchTool:
name = "tavily_search"
def __init__(self):
self.client = TavilyClient(api_key=TAVILY_API_KEY)
async def execute(self, query: str, search_depth: str = "basic") -> dict:
try:
response = self.client.search(
query=query, search_depth=search_depth,
include_answer=True, max_results=5
)
results = [{"title": r.get("title", ""), "content": r.get("content", ""), "url": r.get("url", "")}
for r in response.get("results", [])]
return {
"query": query,
"answer": response.get("answer", ""),
"results": results,
"summary": response.get("answer", results[0]["content"][:200] if results else "No results")
}
except Exception as e:
return {"query": query, "answer": "", "results": [], "summary": f"Search failed: {str(e)}"}

View File

@@ -0,0 +1,16 @@
from urllib.parse import quote
class UPIGeneratorTool:
name = "generate_upi_link"
async def execute(self, payee_upi: str, payee_name: str, amount: float, note: str = "") -> dict:
# upi.link is a web-based redirect service that opens any UPI app on mobile.
# This format works as a Telegram inline-button URL (https:// required).
upi_link = f"https://upi.link/{quote(payee_upi, safe='')}?amount={amount:.2f}&cu=INR"
if note:
upi_link += f"&remarks={quote(note)}"
return {
"upi_link": upi_link,
"display_text": f"Pay ₹{amount:,.0f} to {payee_name}",
"payee_upi": payee_upi, "amount": amount
}

View File

@@ -0,0 +1,52 @@
# backend/voice/elevenlabs_tts.py
import httpx
from config import ELEVENLABS_API_KEY, VOICE_ID_AGENT_A, VOICE_ID_AGENT_B
async def generate_voice_summary(text: str, negotiation_id: str,
voice_id: str = None) -> str:
"""Generate TTS MP3 and return file path. Returns None on failure."""
voice_id = voice_id or VOICE_ID_AGENT_A
try:
async with httpx.AsyncClient() as client:
response = await client.post(
f"https://api.elevenlabs.io/v1/text-to-speech/{voice_id}",
headers={"xi-api-key": ELEVENLABS_API_KEY, "Content-Type": "application/json"},
json={
"text": text[:500], # Budget cap
"model_id": "eleven_flash_v2_5",
"voice_settings": {"stability": 0.5, "similarity_boost": 0.75}
},
timeout=30.0
)
if response.status_code == 200:
import tempfile, os
voice_dir = os.path.join(tempfile.gettempdir(), "negot8_voice")
os.makedirs(voice_dir, exist_ok=True)
filepath = os.path.join(voice_dir, f"voice_{negotiation_id}.mp3")
with open(filepath, "wb") as f:
f.write(response.content)
return filepath
except Exception as e:
print(f"Voice TTS failed: {e}")
return None
# Voice summary templates
VOICE_TEMPLATES = {
"expenses": "Expenses settled! After {rounds} rounds, {payer} owes {payee} {amount} rupees. A UPI payment link has been sent.",
"collaborative": "Decision made! You're going to {choice}. Your agents found the perfect match in {rounds} rounds.",
"scheduling": "Meeting scheduled for {date} at {time}, {location}. Agreed in {rounds} rounds.",
"marketplace": "Deal done! {item} for {price} rupees. Payment link is ready.",
"trip": "Trip planned! {destination} on {dates}, {budget} per person.",
"freelance": "Project agreed! {scope} for {budget} rupees. First milestone payment ready via UPI.",
"roommate": "Decision made! {option}. Cost split arranged.",
"conflict": "Resolution reached! {summary}.",
}
def build_voice_text(feature_type: str, resolution: dict) -> str:
template = VOICE_TEMPLATES.get(feature_type, "Negotiation resolved! Check Telegram for details.")
try:
return template.format(**resolution)[:500]
except KeyError:
summary = resolution.get("summary", "resolved")
return f"Your {feature_type} negotiation is complete: {summary}"[:500]

View File

@@ -0,0 +1,53 @@
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;
contract AgreementRegistry {
struct Agreement {
bytes32 agreementHash;
string featureType;
string summary;
uint256 timestamp;
address registeredBy;
}
mapping(string => Agreement) public agreements; // negotiationId => Agreement
string[] public agreementIds;
event AgreementRegistered(
string indexed negotiationId,
bytes32 agreementHash,
string featureType,
string summary,
uint256 timestamp
);
function registerAgreement(
string calldata negotiationId,
bytes32 agreementHash,
string calldata featureType,
string calldata summary
) external {
agreements[negotiationId] = Agreement({
agreementHash: agreementHash,
featureType: featureType,
summary: summary,
timestamp: block.timestamp,
registeredBy: msg.sender
});
agreementIds.push(negotiationId);
emit AgreementRegistered(
negotiationId, agreementHash, featureType, summary, block.timestamp
);
}
function getAgreement(string calldata negotiationId)
external view returns (Agreement memory)
{
return agreements[negotiationId];
}
function totalAgreements() external view returns (uint256) {
return agreementIds.length;
}
}

View File

@@ -0,0 +1,81 @@
"""
Deploy AgreementRegistry.sol to Polygon Amoy testnet using web3.py + native solc.
Run from project root: python3 contracts/deploy.py
"""
import sys, os, subprocess, json
sys.path.insert(0, 'backend')
from dotenv import load_dotenv
load_dotenv('.env')
from web3 import Web3
# ── Config ───────────────────────────────────────────────────────────────────
RPC_URL = os.getenv("POLYGON_RPC_URL", "https://rpc-amoy.polygon.technology/")
PRIVATE_KEY = os.getenv("POLYGON_PRIVATE_KEY", "")
CHAIN_ID = 80002
w3 = Web3(Web3.HTTPProvider(RPC_URL))
account = w3.eth.account.from_key(PRIVATE_KEY)
print(f"Connected : {w3.is_connected()}")
print(f"Address : {account.address}")
print(f"Balance : {w3.from_wei(w3.eth.get_balance(account.address), 'ether')} POL")
print(f"Chain ID : {w3.eth.chain_id}")
# ── Compile with native solc (installed via Homebrew) ────────────────────────
sol_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "AgreementRegistry.sol")
print(f"\nCompiling {sol_path} ...")
result = subprocess.run(
["solc", "--combined-json", "abi,bin", "--optimize", sol_path],
capture_output=True, text=True
)
if result.returncode != 0:
print(f"❌ Compilation failed:\n{result.stderr}")
sys.exit(1)
compiled = json.loads(result.stdout)
key = list(compiled["contracts"].keys())[0] # e.g. "path:AgreementRegistry"
raw_abi = compiled["contracts"][key]["abi"]
abi = json.loads(raw_abi) if isinstance(raw_abi, str) else raw_abi
bytecode = compiled["contracts"][key]["bin"]
print(f"✅ Compiled successfully (contract key: {key})")
# ── Deploy ───────────────────────────────────────────────────────────────────
ContractFactory = w3.eth.contract(abi=abi, bytecode=bytecode)
gas_price = w3.eth.gas_price
tip = w3.to_wei(35, "gwei")
max_fee = gas_price + tip
print(f"\nDeploying ... (base gas: {w3.from_wei(gas_price, 'gwei'):.1f} gwei, tip: 35 gwei)")
tx = ContractFactory.constructor().build_transaction({
"from": account.address,
"nonce": w3.eth.get_transaction_count(account.address),
"gas": 800_000,
"maxFeePerGas": max_fee,
"maxPriorityFeePerGas": tip,
"chainId": CHAIN_ID,
})
signed = w3.eth.account.sign_transaction(tx, PRIVATE_KEY)
tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction)
print(f"Tx sent : 0x{tx_hash.hex()}")
print("Waiting for confirmation (~2 seconds) ...")
receipt = w3.eth.wait_for_transaction_receipt(tx_hash, timeout=120)
contract_address = receipt.contractAddress
print(f"\n{'='*60}")
print(f"✅ CONTRACT DEPLOYED SUCCESSFULLY")
print(f"{'='*60}")
print(f"Contract Address : {contract_address}")
print(f"Transaction Hash : 0x{tx_hash.hex()}")
print(f"Block Number : {receipt.blockNumber}")
print(f"Gas Used : {receipt.gasUsed}")
print(f"Explorer : https://amoy.polygonscan.com/address/{contract_address}")
print(f"{'='*60}")
print(f"\n📋 Add this to your .env:")
print(f"AGREEMENT_CONTRACT_ADDRESS={contract_address}")

41
negot8/dashboard/.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

View File

@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

View File

@@ -0,0 +1,376 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import {
LineChart,
Line,
BarChart,
Bar,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
Cell,
} from "recharts";
import { api } from "@/lib/api";
import type { Negotiation, Stats } from "@/lib/types";
import { FEATURE_LABELS, relativeTime } from "@/lib/utils";
import Sidebar from "@/components/Sidebar";
function Icon({ name, className = "" }: { name: string; className?: string }) {
return <span className={`material-symbols-outlined ${className}`}>{name}</span>;
}
const FEATURE_ICONS: Record<string, string> = {
scheduling: "calendar_month",
expenses: "account_balance_wallet",
freelance: "work",
roommate: "home",
trip: "flight_takeoff",
marketplace: "store",
collaborative: "groups",
conflict: "gavel",
generic: "hub",
};
function buildFairnessTimeline(negotiations: Negotiation[]) {
const sorted = [...negotiations]
.filter((n) => n.status === "resolved")
.sort((a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime())
.slice(-12);
return sorted.map((n, i) => ({
label: `#${i + 1}`,
fairness: Math.round(((n as any).result?.fairness_score ?? 0.7 + Math.random() * 0.25) * 100),
}));
}
function buildDailyVolume(negotiations: Negotiation[]) {
const days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
const counts = Array(7).fill(0);
negotiations.forEach((n) => {
const d = new Date(n.created_at).getDay();
counts[(d + 6) % 7]++;
});
return days.map((day, i) => ({ day, count: counts[i] }));
}
// Donut SVG
function DonutChart({ value, label }: { value: number; label: string }) {
const r = 52;
const circ = 2 * Math.PI * r;
const dash = (value / 100) * circ;
return (
<div className="flex flex-col items-center justify-center gap-2">
<svg width="128" height="128" viewBox="0 0 128 128">
<circle cx="64" cy="64" r={r} fill="none" stroke="rgba(255,255,255,0.06)" strokeWidth="14" />
<circle
cx="64"
cy="64"
r={r}
fill="none"
stroke="#B7A6FB"
strokeWidth="14"
strokeDasharray={`${dash} ${circ - dash}`}
strokeDashoffset={circ / 4}
strokeLinecap="round"
style={{ filter: "drop-shadow(0 0 8px #B7A6FB)" }}
/>
<text x="64" y="57" textAnchor="middle" dominantBaseline="middle" fill="white" fontSize="20" fontWeight="bold" fontFamily="JetBrains Mono, monospace">
{value}%
</text>
<text x="64" y="73" textAnchor="middle" dominantBaseline="middle" fill="#64748b" fontSize="9" fontFamily="Roboto, sans-serif">
{label}
</text>
</svg>
</div>
);
}
export default function AnalyticsPage() {
const [stats, setStats] = useState<Stats | null>(null);
const [negotiations, setNegotiations] = useState<Negotiation[]>([]);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
try {
const [s, n] = await Promise.all([api.stats(), api.negotiations()]);
setStats(s);
setNegotiations(n.negotiations);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const successRate = stats
? Math.round((stats.resolved / Math.max(stats.total_negotiations, 1)) * 100)
: 0;
const avgFairness = (() => {
const resolved = negotiations.filter((n) => n.status === "resolved");
if (!resolved.length) return 0;
const sum = resolved.reduce((acc, n) => acc + ((n as any).result?.fairness_score ?? 0), 0);
return Math.round((sum / resolved.length) * 100);
})();
const fairnessTimeline = buildFairnessTimeline(negotiations);
const dailyVolume = buildDailyVolume(negotiations);
// Top agents by feature type volume
const featureCounts: Record<string, number> = {};
negotiations.forEach((n) => {
featureCounts[n.feature_type] = (featureCounts[n.feature_type] ?? 0) + 1;
});
const topFeatures = Object.entries(featureCounts)
.sort((a, b) => b[1] - a[1])
.slice(0, 5);
return (
<div className="flex h-screen w-full overflow-hidden bg-[#020105] text-slate-300 relative">
<div className="absolute inset-0 bg-grid-subtle opacity-20 pointer-events-none" />
<Sidebar />
<main className="flex-1 flex flex-col h-full overflow-hidden relative z-10">
{/* Top bar */}
<header className="h-16 flex items-center justify-between px-6 bg-[#050505]/80 backdrop-blur-md border-b border-white/5 sticky top-0 z-30 shrink-0">
<div>
<h2 className="text-base font-medium text-white tracking-tight">
Advanced Analytics
</h2>
<p className="text-[10px] text-slate-600 mt-0.5">Last 30 days</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={load}
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-slate-400 hover:text-white text-xs font-medium transition-all"
>
<Icon name="calendar_month" className="text-sm" />
Last 30 Days
</button>
<button className="flex items-center gap-2 px-4 py-2 rounded-lg bg-[#B7A6FB] text-[#020105] text-xs font-bold hover:brightness-110 transition-all">
<Icon name="download" className="text-sm" />
Export Report
</button>
</div>
</header>
<div className="flex-1 overflow-y-auto p-6 space-y-6">
{loading ? (
<div className="flex items-center justify-center h-48 text-slate-600">
<Icon name="progress_activity" className="animate-spin text-3xl text-[#B7A6FB]" />
</div>
) : (
<>
{/* Metric cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<MetricCard
icon="handshake"
label="Total Volume"
value={stats?.total_negotiations ?? 0}
sub="negotiations"
accentColor="#B7A6FB"
/>
<MetricCard
icon="balance"
label="Avg Fairness"
value={`${avgFairness}%`}
sub="across resolved"
accentColor="#a78bfa"
/>
<MetricCard
icon="verified"
label="Success Rate"
value={`${successRate}%`}
sub="resolved / total"
accentColor="#60a5fa"
/>
</div>
{/* Charts row */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{/* Fairness over time */}
<div className="lg:col-span-2 glass-card rounded-xl p-5">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-sm font-medium text-white">Fairness Over Time</h3>
<p className="text-[10px] text-slate-500 mt-0.5">Per resolved negotiation</p>
</div>
<span className="text-[10px] font-mono text-[#B7A6FB] bg-[#B7A6FB]/10 px-2 py-0.5 rounded border border-[#B7A6FB]/20">
LIVE
</span>
</div>
{fairnessTimeline.length > 1 ? (
<ResponsiveContainer width="100%" height={180}>
<LineChart data={fairnessTimeline}>
<defs>
<linearGradient id="fairGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#B7A6FB" stopOpacity={0.3} />
<stop offset="95%" stopColor="#B7A6FB" stopOpacity={0} />
</linearGradient>
</defs>
<XAxis dataKey="label" tick={{ fill: "#475569", fontSize: 9, fontFamily: "monospace" }} axisLine={false} tickLine={false} />
<YAxis domain={[0, 100]} tick={{ fill: "#475569", fontSize: 9, fontFamily: "monospace" }} axisLine={false} tickLine={false} tickFormatter={(v) => `${v}%`} width={30} />
<Tooltip
contentStyle={{ background: "#070312", border: "1px solid rgba(183,166,251,0.2)", borderRadius: 8, fontSize: 11 }}
labelStyle={{ color: "#B7A6FB" }}
itemStyle={{ color: "#e2e8f0" }}
formatter={(v: number | undefined) => [`${v ?? 0}%`, "Fairness"]}
/>
<Line type="monotone" dataKey="fairness" stroke="#B7A6FB" strokeWidth={2} dot={{ fill: "#B7A6FB", r: 3 }} activeDot={{ r: 5, fill: "#B7A6FB", stroke: "rgba(183,166,251,0.3)", strokeWidth: 4 }} />
</LineChart>
</ResponsiveContainer>
) : (
<div className="h-[180px] flex items-center justify-center text-slate-600 text-sm">
Not enough data yet
</div>
)}
</div>
{/* Right column: donut + bar */}
<div className="flex flex-col gap-4">
{/* Donut: success rate */}
<div className="glass-card rounded-xl p-5 flex flex-col items-center">
<h3 className="text-sm font-medium text-white mb-3 self-start">Success Rate</h3>
<DonutChart value={successRate} label="Resolved" />
</div>
</div>
</div>
{/* Daily volume bar + top features */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Time to resolution bar chart */}
<div className="glass-card rounded-xl p-5">
<div className="mb-4">
<h3 className="text-sm font-medium text-white">Negotiation Volume</h3>
<p className="text-[10px] text-slate-500 mt-0.5">By day of week</p>
</div>
<ResponsiveContainer width="100%" height={160}>
<BarChart data={dailyVolume} barCategoryGap="35%">
<XAxis dataKey="day" tick={{ fill: "#475569", fontSize: 9, fontFamily: "monospace" }} axisLine={false} tickLine={false} />
<YAxis allowDecimals={false} tick={{ fill: "#475569", fontSize: 9, fontFamily: "monospace" }} axisLine={false} tickLine={false} width={24} />
<Tooltip
contentStyle={{ background: "#070312", border: "1px solid rgba(183,166,251,0.2)", borderRadius: 8, fontSize: 11 }}
labelStyle={{ color: "#B7A6FB" }}
itemStyle={{ color: "#e2e8f0" }}
cursor={{ fill: "rgba(183,166,251,0.05)" }}
/>
<Bar dataKey="count" radius={[4, 4, 0, 0]}>
{dailyVolume.map((_, i) => (
<Cell key={i} fill={i === new Date().getDay() - 1 ? "#B7A6FB" : "rgba(183,166,251,0.25)"} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
{/* Top feature types */}
<div className="glass-card rounded-xl p-5">
<div className="mb-4">
<h3 className="text-sm font-medium text-white">Top Feature Types</h3>
<p className="text-[10px] text-slate-500 mt-0.5">By negotiation volume</p>
</div>
<div className="space-y-3">
{topFeatures.length === 0 ? (
<p className="text-slate-600 text-xs text-center py-8">No data yet</p>
) : (
topFeatures.map(([feature, count]) => {
const max = topFeatures[0][1];
const pct = Math.round((count / max) * 100);
return (
<div key={feature} className="flex items-center gap-3">
<div className="size-7 rounded bg-white/5 border border-white/10 flex items-center justify-center shrink-0">
<Icon name={FEATURE_ICONS[feature] ?? "hub"} className="text-[#B7A6FB] text-sm" />
</div>
<div className="flex-1 min-w-0">
<div className="flex justify-between items-center mb-1">
<span className="text-xs text-white truncate">{(FEATURE_LABELS as Record<string, string>)[feature] ?? feature}</span>
<span className="text-[10px] text-slate-500 font-mono shrink-0 ml-2">{count}</span>
</div>
<div className="w-full bg-white/5 h-1 rounded-full overflow-hidden">
<div
className="h-full bg-[#B7A6FB] rounded-full"
style={{ width: `${pct}%` }}
/>
</div>
</div>
</div>
);
})
)}
</div>
</div>
</div>
{/* Breakdown by status */}
<div className="glass-card rounded-xl p-5">
<h3 className="text-sm font-medium text-white mb-4">Status Breakdown</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[
{ key: "resolved", label: "Resolved", color: "#34d399", bg: "bg-emerald-500/10", border: "border-emerald-500/20" },
{ key: "active", label: "In Progress", color: "#fbbf24", bg: "bg-amber-500/10", border: "border-amber-500/20" },
{ key: "pending", label: "Pending", color: "#94a3b8", bg: "bg-white/5", border: "border-white/10" },
{ key: "escalated", label: "Escalated", color: "#f87171", bg: "bg-rose-500/10", border: "border-rose-500/20" },
].map(({ key, label, color, bg, border }) => {
const count = negotiations.filter((n) => n.status === key).length;
const pct = negotiations.length
? Math.round((count / negotiations.length) * 100)
: 0;
return (
<div key={key} className={`rounded-xl p-4 border ${bg} ${border}`}>
<p className="text-[10px] uppercase tracking-wider font-mono mb-1" style={{ color }}>
{label}
</p>
<p className="text-2xl font-light text-white">{count}</p>
<p className="text-[10px] text-slate-500 mt-1">{pct}% of total</p>
</div>
);
})}
</div>
</div>
</>
)}
</div>
</main>
</div>
);
}
function MetricCard({
icon,
label,
value,
sub,
accentColor,
}: {
icon: string;
label: string;
value: number | string;
sub: string;
accentColor: string;
}) {
return (
<div
className="glass-card rounded-xl p-5 relative overflow-hidden"
style={{ borderLeft: `2px solid ${accentColor}` }}
>
<div
className="absolute left-0 top-0 bottom-0 w-8 opacity-10 pointer-events-none"
style={{ background: `linear-gradient(to right, ${accentColor}, transparent)` }}
/>
<div className="flex items-start justify-between mb-3">
<span className="text-[10px] text-slate-500 uppercase tracking-wider font-mono">
{label}
</span>
<div
className="size-7 rounded flex items-center justify-center"
style={{ background: `${accentColor}18`, color: accentColor }}
>
<span className="material-symbols-outlined text-sm">{icon}</span>
</div>
</div>
<p className="text-3xl font-light text-white">{value}</p>
<p className="text-[10px] text-slate-600 mt-1 font-mono">{sub}</p>
</div>
);
}

View File

@@ -0,0 +1,462 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import Link from "next/link";
import { api } from "@/lib/api";
import { getSocket } from "@/lib/socket";
import type { Negotiation, Stats } from "@/lib/types";
import {
FEATURE_LABELS,
STATUS_COLORS,
STATUS_LABELS,
relativeTime,
} from "@/lib/utils";
import Sidebar from "@/components/Sidebar";
// ─── Icon helper ─────────────────────────────────────────────────────────────
function Icon({ name, className = "" }: { name: string; className?: string }) {
return (
<span className={`material-symbols-outlined ${className}`}>{name}</span>
);
}
// ─── Feature icon map ─────────────────────────────────────────────────────────
const FEATURE_ICONS: Record<string, string> = {
scheduling: "calendar_month",
expenses: "account_balance_wallet",
freelance: "work",
roommate: "home",
trip: "flight_takeoff",
marketplace: "store",
collaborative: "groups",
conflict: "gavel",
generic: "hub",
};
export default function DashboardPage() {
const [stats, setStats] = useState<Stats | null>(null);
const [negotiations, setNegotiations] = useState<Negotiation[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [connected, setConnected] = useState(false);
const [search, setSearch] = useState("");
const load = useCallback(async () => {
setLoading(true);
try {
const [s, n] = await Promise.all([api.stats(), api.negotiations()]);
setStats(s);
setNegotiations(n.negotiations);
setError(null);
} catch (e) {
setError(String(e));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load();
const socket = getSocket();
const onConnect = () => setConnected(true);
const onDisconnect = () => setConnected(false);
const onUpdate = () => load();
socket.on("connect", onConnect);
socket.on("disconnect", onDisconnect);
socket.on("negotiation_started", onUpdate);
socket.on("negotiation_resolved", onUpdate);
setConnected(socket.connected);
return () => {
socket.off("connect", onConnect);
socket.off("disconnect", onDisconnect);
socket.off("negotiation_started", onUpdate);
socket.off("negotiation_resolved", onUpdate);
};
}, [load]);
const successRate = stats
? Math.round((stats.resolved / Math.max(stats.total_negotiations, 1)) * 100)
: 0;
const filteredNegotiations = search.trim()
? negotiations.filter((n) =>
n.id.toLowerCase().includes(search.toLowerCase()) ||
n.feature_type?.toLowerCase().includes(search.toLowerCase()) ||
n.status?.toLowerCase().includes(search.toLowerCase())
)
: negotiations;
return (
<div className="flex h-screen w-full overflow-hidden bg-[#020105] text-slate-300 relative">
{/* Grid overlay */}
<div className="absolute inset-0 bg-grid-subtle opacity-20 pointer-events-none" />
{/* ── Sidebar ── */}
<Sidebar />
{/* ── Main ── */}
<main className="flex-1 flex flex-col h-full overflow-hidden relative z-10">
{/* Top bar */}
<header className="h-14 flex items-center justify-between px-6 bg-[#020105]/90 backdrop-blur-md border-b border-white/[0.06] sticky top-0 z-30 shrink-0">
<div className="flex items-center gap-3">
<h2 className="text-sm font-semibold text-white tracking-tight">
Mission Control
</h2>
<span className="px-2 py-0.5 rounded-full bg-[#B7A6FB]/10 border border-[#B7A6FB]/20 text-[9px] text-[#B7A6FB] font-mono tracking-widest uppercase">
Live
</span>
</div>
<div className="flex items-center gap-2">
{/* Search */}
<div className="relative hidden md:block group">
<Icon name="search" className="absolute left-2.5 top-1/2 -translate-y-1/2 text-slate-600 group-focus-within:text-[#B7A6FB] transition-colors text-base" />
<input
className="h-8 w-52 bg-white/[0.04] border border-white/[0.08] rounded-lg pl-8 pr-3 text-xs text-white placeholder-slate-600 focus:outline-none focus:border-[#B7A6FB]/30 transition-all"
placeholder="Search negotiations..."
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
{/* Refresh */}
<button
onClick={load}
disabled={loading}
className="h-8 w-8 flex items-center justify-center rounded-lg bg-white/[0.04] border border-white/[0.08] text-slate-500 hover:text-white hover:border-white/20 transition-all disabled:opacity-30"
title="Refresh"
>
<Icon name="refresh" className={`text-base ${loading ? "animate-spin" : ""}`} />
</button>
{/* Connection badge */}
<div
className={`flex items-center gap-1.5 h-8 px-3 rounded-lg border font-mono text-[10px] ${
connected
? "bg-emerald-500/10 text-emerald-400 border-emerald-500/20"
: "bg-white/[0.04] text-slate-500 border-white/[0.08]"
}`}
>
<span className={`size-1.5 rounded-full ${connected ? "bg-emerald-400 animate-pulse" : "bg-slate-600"}`} />
{connected ? "Connected" : "Offline"}
</div>
{/* Profile avatar */}
<Link
href="/profile"
className="size-8 rounded-full bg-gradient-to-br from-[#B7A6FB]/30 to-[#22d3ee]/20 border border-[#B7A6FB]/40 flex items-center justify-center text-white text-[11px] font-black hover:border-[#B7A6FB] hover:shadow-[0_0_12px_rgba(183,166,251,0.3)] transition-all"
title="View Profile"
>
AB
</Link>
</div>
</header>
{/* Scrollable content */}
<div className="flex-1 overflow-y-auto p-6 scroll-smooth">
{/* Error */}
{error && (
<div className="mb-5 p-4 bg-red-500/10 border border-red-500/20 rounded-xl text-xs text-red-400 flex items-center gap-2">
<Icon name="error" className="text-base shrink-0" />
{error} {" "}
<button onClick={load} className="underline hover:text-red-300">
retry
</button>
</div>
)}
{/* ── Stat cards ── */}
<section className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-7">
<StatCard
icon="hub"
label="Active Links"
value={stats?.active ?? "—"}
sub={`${stats?.total_negotiations ?? 0} total`}
bars={[40, 60, 30, 80, 50]}
activeBar={3}
accent="#B7A6FB"
/>
<StatCard
icon="check_circle"
label="Resolved"
value={stats?.resolved ?? "—"}
sub={`${successRate}% success rate`}
bars={[60, 50, 90, 40, 70]}
activeBar={2}
accent="#34d399"
/>
<StatCard
icon="group"
label="Users"
value={stats?.total_users ?? "—"}
sub="registered agents"
bars={[30, 70, 50, 90, 60]}
activeBar={3}
accent="#60a5fa"
/>
<StatCard
icon="balance"
label="Avg Fairness"
value={stats ? `${stats.avg_fairness_score}` : "—"}
sub="fairness index"
bars={[50, 65, 70, 75, 80]}
activeBar={4}
accent="#f59e0b"
/>
</section>
{/* ── Feature breakdown ── */}
{stats?.feature_breakdown && stats.feature_breakdown.length > 0 && (
<section className="glass-card rounded-xl p-5 mb-7">
<div className="flex items-center gap-2 mb-4">
<h3 className="text-xs font-semibold text-slate-400 uppercase tracking-widest">Protocol Distribution</h3>
</div>
<div className="flex flex-wrap gap-2">
{stats.feature_breakdown.map((fb) => (
<span
key={fb.feature_type}
className="flex items-center gap-1.5 text-[10px] px-3 py-1.5 bg-white/5 text-slate-300 border border-white/10 rounded-full font-mono hover:border-[#B7A6FB]/30 transition-colors"
>
<Icon name={FEATURE_ICONS[fb.feature_type] ?? "hub"} className="text-sm text-[#B7A6FB]" />
{FEATURE_LABELS[fb.feature_type]} · <span className="text-[#B7A6FB]">{fb.c}</span>
</span>
))}
</div>
</section>
)}
{/* ── Active Cycles ── */}
<section>
<div className="flex items-center justify-between mb-5">
<div className="flex items-center gap-2.5">
<h3 className="text-sm font-semibold text-white tracking-wide">
Active Cycles
</h3>
{stats?.active != null && stats.active > 0 && (
<span className="text-[9px] px-2 py-0.5 bg-red-500/10 border border-red-500/20 rounded-full text-red-400 font-mono animate-pulse tracking-wide">
{stats.active} LIVE
</span>
)}
</div>
<div className="flex items-center gap-2">
<button className="h-8 flex items-center gap-1.5 px-3 text-[11px] font-semibold text-[#B7A6FB] bg-[#B7A6FB]/10 border border-[#B7A6FB]/25 rounded-lg hover:bg-[#B7A6FB]/20 transition-all tracking-wide">
<Icon name="add" className="text-base" />
Initialize
</button>
<button className="h-8 w-8 flex items-center justify-center text-slate-500 bg-white/[0.04] border border-white/[0.08] rounded-lg hover:text-white hover:border-white/20 transition-all">
<Icon name="filter_list" className="text-base" />
</button>
</div>
</div>
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-5">
{[1, 2, 3].map((i) => (
<div key={i} className="glass-card rounded-xl h-52 animate-pulse" />
))}
</div>
) : filteredNegotiations.length === 0 ? (
<div className="glass-card rounded-xl py-20 text-center">
<Icon name={search ? "search_off" : "hub"} className="text-5xl text-slate-700 block mx-auto mb-3" />
<p className="text-sm text-slate-500">{search ? `No results for "${search}"` : "No negotiations yet."}</p>
<p className="text-[10px] text-slate-700 mt-1 font-mono">{search ? "Try a different keyword." : "Start one via Telegram bot!"}</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-5">
{filteredNegotiations.map((neg) => (
<NegotiationCard key={neg.id} neg={neg} />
))}
</div>
)}
</section>
<div className="mt-10 text-center text-[9px] text-slate-700 pb-4 font-mono">
// SYSTEM_ID: AM-2024 // STATUS:{" "}
<span className="text-emerald-500">OPERATIONAL</span> // LATENCY: 12ms
</div>
</div>
</main>
</div>
);
}
// ─── Sub-components ───────────────────────────────────────────────────────────
function StatCard({
icon,
label,
value,
sub,
bars,
activeBar,
accent = "#B7A6FB",
}: {
icon: string;
label: string;
value: number | string;
sub: string;
bars: number[];
activeBar: number;
accent?: string;
}) {
return (
<div
className="rounded-xl p-5 flex flex-col justify-between group hover:-translate-y-0.5 transition-transform duration-300 relative overflow-hidden"
style={{
background: "rgba(7,3,18,0.55)",
border: "1px solid rgba(255,255,255,0.07)",
boxShadow: "0 4px 24px rgba(0,0,0,0.45)",
}}
>
{/* Subtle top accent line */}
<div
className="absolute top-0 left-0 right-0 h-[2px] rounded-t-xl opacity-60"
style={{ background: `linear-gradient(to right, ${accent}, transparent)` }}
/>
{/* Top row: label + icon */}
<div className="flex items-center justify-between mb-3">
<span className="text-[10px] font-semibold uppercase tracking-widest text-slate-500">
{label}
</span>
<div
className="size-8 rounded-lg flex items-center justify-center shrink-0"
style={{ background: `${accent}18`, border: `1px solid ${accent}30` }}
>
<span className="material-symbols-outlined text-[17px]" style={{ color: accent }}>
{icon}
</span>
</div>
</div>
{/* Value */}
<div className="mb-3">
<span
className="text-[2.15rem] font-light leading-none tracking-tight"
style={{ color: "rgba(255,255,255,0.92)" }}
>
{value}
</span>
</div>
{/* Bottom row: mini bars + sub-label */}
<div className="flex items-end justify-between gap-2">
<div className="flex items-end gap-[3px] h-5">
{bars.map((h, i) => (
<div
key={i}
className="w-[3px] rounded-sm transition-all duration-300"
style={{
height: `${h}%`,
background: i === activeBar ? accent : `${accent}35`,
boxShadow: i === activeBar ? `0 0 6px ${accent}` : "none",
}}
/>
))}
</div>
<span className="text-[10px] font-mono text-slate-600 truncate">{sub}</span>
</div>
</div>
);
}
function NegotiationCard({ neg }: { neg: Negotiation }) {
const isLive = neg.status === "active";
const isPaused = neg.status === "pending";
const statusDot: Record<string, string> = {
active: "bg-red-500 animate-pulse",
resolved: "bg-emerald-500",
pending: "bg-slate-500",
escalated: "bg-amber-500",
};
const statusLabel: Record<string, string> = {
active: "Live",
resolved: "Sync",
pending: "Pending",
escalated: "Escalated",
};
const statusColor: Record<string, string> = {
active: "text-red-400 bg-red-500/10 border-red-500/20",
resolved: "text-emerald-400 bg-emerald-500/10 border-emerald-500/20",
pending: "text-slate-400 bg-white/5 border-white/10",
escalated: "text-amber-400 bg-amber-500/10 border-amber-500/20",
};
return (
<div className="glass-card rounded-xl overflow-hidden hover:scale-[1.01] transition-all duration-300 group relative">
<div className="p-5 relative">
{/* Header */}
<div className="flex justify-between items-start mb-5">
<div className="flex items-center gap-3">
<div className="size-10 rounded-lg bg-white/5 border border-white/10 flex items-center justify-center">
<Icon name={FEATURE_ICONS[neg.feature_type] ?? "hub"} className="text-[#B7A6FB]" />
</div>
<div>
<h4 className="text-sm font-medium text-white truncate max-w-[140px]">
{FEATURE_LABELS[neg.feature_type]}
</h4>
<span className="text-[9px] font-mono text-slate-600">ID: #{neg.id.slice(0, 8)}</span>
</div>
</div>
<div className={`flex items-center gap-1.5 px-2 py-1 border rounded text-[9px] font-bold uppercase tracking-wide ${statusColor[neg.status]}`}>
<div className={`size-1.5 rounded-full ${statusDot[neg.status]}`} />
{statusLabel[neg.status]}
</div>
</div>
{/* Details */}
<div className="space-y-2.5 mb-5">
<div className="flex justify-between items-center text-xs border-b border-white/5 pb-2">
<span className="text-slate-500 font-mono">participants</span>
<span className="text-slate-300 font-mono">{neg.participant_count ?? 0} agents</span>
</div>
<div className="flex justify-between items-center text-xs border-b border-white/5 pb-2">
<span className="text-slate-500 font-mono">status</span>
<span className={`font-mono text-[10px] px-1.5 py-0.5 rounded border ${STATUS_COLORS[neg.status] ?? "text-slate-400 border-white/10"}`}>
{STATUS_LABELS[neg.status] ?? neg.status}
</span>
</div>
<div className="flex justify-between items-center text-xs">
<span className="text-slate-500 font-mono">initiated</span>
<span className="text-slate-400 font-mono bg-white/5 px-1.5 rounded border border-white/10 text-[10px]">
{relativeTime(neg.created_at)}
</span>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-between pt-4 border-t border-white/5">
<div className="flex -space-x-2">
{[...Array(Math.min(neg.participant_count ?? 0, 2))].map((_, i) => (
<div
key={i}
className="w-6 h-6 rounded-full bg-slate-800 border border-black flex items-center justify-center text-[8px] text-white ring-1 ring-white/10"
>
{String.fromCharCode(65 + i)}
</div>
))}
<div className="w-6 h-6 rounded-full border border-black bg-[#B7A6FB]/20 flex items-center justify-center text-[7px] text-[#B7A6FB] ring-1 ring-white/10">
AI
</div>
</div>
<Link
href={`/negotiation/${neg.id}`}
className="text-[#B7A6FB] hover:text-white text-[10px] font-medium flex items-center gap-1 transition-colors group/btn uppercase tracking-wider"
>
<span>{isPaused ? "Resume" : isLive ? "Access Stream" : "View Report"}</span>
<Icon name="arrow_forward" className="text-sm group-hover/btn:translate-x-0.5 transition-transform" />
</Link>
</div>
</div>
{/* Corner plus */}
<div className="absolute bottom-4 right-4 opacity-0 group-hover:opacity-100 transition-opacity">
<div className="w-5 h-5 rounded-full bg-white/5 border border-white/10 flex items-center justify-center text-[#B7A6FB]">
<Icon name="add" className="text-xs" />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,413 @@
"use client";
import { useState } from "react";
import Link from "next/link";
function Icon({ name, className = "" }: { name: string; className?: string }) {
return <span className={`material-symbols-outlined ${className}`}>{name}</span>;
}
// ─── Sidebar nav data ─────────────────────────────────────────────────────────
const NAV = [
{
group: "Introduction",
items: [
{ icon: "rocket_launch", label: "Getting Started", id: "getting-started", active: true },
{ icon: "description", label: "Architecture", id: "architecture" },
],
},
{
group: "Core Concepts",
items: [
{ icon: "person_search", label: "Agent Personas", id: "personas" },
{ icon: "account_tree", label: "Mesh Topology", id: "topology" },
{ icon: "memory", label: "Memory Systems", id: "memory" },
],
},
{
group: "Development",
items: [
{ icon: "code", label: "API Reference", id: "api" },
{ icon: "inventory_2", label: "SDKs & Tools", id: "sdks" },
{ icon: "terminal", label: "CLI Tool", id: "cli" },
],
},
];
// ─── Code block ───────────────────────────────────────────────────────────────
function CodeBlock({ filename, children }: { filename: string; children: React.ReactNode }) {
const [copied, setCopied] = useState(false);
return (
<div className="relative rounded-xl border overflow-hidden shadow-2xl" style={{ background:"#0a0716", borderColor:"#1f1a30", boxShadow:"0 0 20px rgba(183,166,251,0.06)" }}>
<div className="flex items-center justify-between px-4 py-2.5 border-b" style={{ borderColor:"#1f1a30" }}>
<div className="flex gap-1.5">
<div className="size-2.5 rounded-full bg-red-500/40" />
<div className="size-2.5 rounded-full bg-amber-500/40" />
<div className="size-2.5 rounded-full bg-emerald-500/40" />
</div>
<div className="flex items-center gap-2">
<span className="text-[11px] font-mono text-slate-500">{filename}</span>
<button
onClick={() => { setCopied(true); setTimeout(() => setCopied(false), 1800); }}
className="p-1 rounded hover:bg-white/5 text-slate-600 hover:text-[#B7A6FB] transition-colors"
>
<Icon name={copied ? "check" : "content_copy"} className="text-sm" />
</button>
</div>
</div>
<div className="p-5 text-sm leading-7 font-mono overflow-x-auto">{children}</div>
</div>
);
}
// ─── Section wrapper ──────────────────────────────────────────────────────────
function Section({ id, children }: { id: string; children: React.ReactNode }) {
return <section id={id} className="mb-20 scroll-mt-24">{children}</section>;
}
function SectionHeading({ title, badge }: { title: string; badge?: string }) {
return (
<div className="flex items-center gap-4 mb-6">
<h2 className="text-3xl font-bold text-white">{title}</h2>
{badge && (
<span className="rounded-full px-3 py-1 text-[10px] font-bold uppercase tracking-widest" style={{ background:"rgba(183,166,251,0.1)", color:"#B7A6FB" }}>
{badge}
</span>
)}
</div>
);
}
// ─── Main page ────────────────────────────────────────────────────────────────
export default function DocsPage() {
return (
<div className="relative flex min-h-screen flex-col overflow-x-hidden bg-[#070312] text-slate-100">
{/* ── Topbar ── */}
<header className="sticky top-0 z-50 flex h-16 w-full items-center justify-between border-b border-white/[0.07] bg-[#070312]/85 px-6 backdrop-blur-md lg:px-10">
<div className="flex items-center gap-8">
<Link href="/" className="flex items-center gap-3">
<div className="size-8 rounded-lg bg-[#B7A6FB] flex items-center justify-center text-[#070312]">
<Icon name="hub" className="text-xl font-bold" />
</div>
<h2 className="text-xl font-bold tracking-tight text-white">negoT8</h2>
</Link>
<div className="relative hidden md:block">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-slate-500">
<Icon name="search" className="text-sm" />
</div>
<input
className="h-9 w-64 rounded-lg border-none pl-10 text-sm text-white placeholder-slate-600 focus:ring-1 focus:ring-[#B7A6FB]/50 outline-none"
style={{ background:"#0f0a1f" }}
placeholder="Search documentation..."
type="text"
/>
</div>
</div>
<div className="flex items-center gap-6">
<nav className="hidden lg:flex items-center gap-6">
{["Docs", "API", "Showcase"].map((l) => (
<a key={l} href="#" className="text-sm font-medium text-slate-300 hover:text-[#B7A6FB] transition-colors">{l}</a>
))}
</nav>
<div className="h-4 w-px bg-white/10" />
<Link href="/dashboard" className="flex items-center gap-2 rounded-lg bg-[#B7A6FB] px-4 py-2 text-sm font-bold text-[#070312] hover:opacity-90 active:scale-95 transition-all">
Console
<Icon name="arrow_outward" className="text-sm" />
</Link>
</div>
</header>
<div className="flex flex-1">
{/* ── Left sidebar ── */}
<aside className="sticky top-16 hidden h-[calc(100vh-64px)] w-64 flex-col border-r border-white/[0.07] bg-[#070312] p-6 overflow-y-auto lg:flex" style={{ scrollbarWidth:"thin", scrollbarColor:"#1f1a30 transparent" }}>
{NAV.map(({ group, items }) => (
<div key={group} className="mb-8">
<h3 className="mb-4 text-[10px] font-bold uppercase tracking-widest text-slate-600">{group}</h3>
<ul className="space-y-1">
{items.map((item) => (
<li key={item.id}>
<a
href={`#${item.id}`}
className={`flex items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors ${
item.active
? "bg-[#B7A6FB]/10 text-[#B7A6FB]"
: "text-slate-400 hover:bg-white/5 hover:text-slate-200"
}`}
>
<Icon name={item.icon} className="text-lg" />
{item.label}
</a>
</li>
))}
</ul>
</div>
))}
<div className="mt-auto pt-6">
<div className="rounded-xl border p-4" style={{ background:"#0f0a1f", borderColor:"#1f1a30" }}>
<p className="text-xs font-medium text-slate-500">Version</p>
<p className="text-sm font-bold text-[#B7A6FB]">v2.0.0-stable</p>
</div>
</div>
</aside>
{/* ── Main content ── */}
<main className="flex-1 px-6 py-12 lg:px-20">
<div className="mx-auto max-w-3xl">
{/* Breadcrumb */}
<nav className="mb-8 flex items-center gap-2 text-sm text-slate-600">
<a href="#" className="hover:text-slate-300 transition-colors">Docs</a>
<Icon name="chevron_right" className="text-xs" />
<span className="text-[#B7A6FB]">Getting Started</span>
</nav>
{/* ── Quick Start ── */}
<Section id="getting-started">
<h1 className="mb-4 text-5xl font-bold tracking-tight text-white">Quick Start Guide</h1>
<p className="text-xl leading-relaxed text-slate-400 mb-10">
Deploy your first negoT8 negotiation in under five minutes. Two autonomous agents, one on-chain settlement.
</p>
<div className="space-y-8">
{/* Step 1 */}
<div>
<h3 className="mb-4 flex items-center gap-2 text-lg font-bold text-white">
<span className="flex size-6 items-center justify-center rounded-full text-xs font-bold text-[#B7A6FB]" style={{ background:"rgba(183,166,251,0.15)" }}>1</span>
Install the SDK
</h3>
<CodeBlock filename="terminal">
<span className="text-[#B7A6FB]">$</span>{" "}
<span className="text-slate-200">pip install</span>{" "}
<span className="text-emerald-400">negot8</span>
</CodeBlock>
</div>
{/* Step 2 */}
<div>
<h3 className="mb-4 flex items-center gap-2 text-lg font-bold text-white">
<span className="flex size-6 items-center justify-center rounded-full text-xs font-bold text-[#B7A6FB]" style={{ background:"rgba(183,166,251,0.15)" }}>2</span>
Configure Your Instance
</h3>
<CodeBlock filename="config.py">
<div><span className="text-slate-500"># Initialize negoT8</span></div>
<div><span className="text-[#B7A6FB]">from</span> <span className="text-emerald-400">negot8</span> <span className="text-[#B7A6FB]">import</span> negoT8</div>
<br />
<div>mesh = <span className="text-emerald-400">negoT8</span>{"({"}</div>
<div className="pl-4"><span className="text-slate-400">api_key</span>=<span className="text-emerald-300">os.environ["AM_KEY"]</span>,</div>
<div className="pl-4"><span className="text-slate-400">region</span>=<span className="text-emerald-300">"us-east-mesh"</span>,</div>
<div className="pl-4"><span className="text-slate-400">strategy</span>=<span className="text-emerald-300">"tit-for-tat"</span></div>
<div>{"}"}</div>
<br />
<div><span className="text-slate-500"># Connect to the protocol</span></div>
<div><span className="text-[#B7A6FB]">await</span> mesh.<span className="text-emerald-400">connect</span>()</div>
</CodeBlock>
</div>
{/* Step 3 */}
<div>
<h3 className="mb-4 flex items-center gap-2 text-lg font-bold text-white">
<span className="flex size-6 items-center justify-center rounded-full text-xs font-bold text-[#B7A6FB]" style={{ background:"rgba(183,166,251,0.15)" }}>3</span>
Start a Negotiation
</h3>
<CodeBlock filename="negotiate.py">
<div>session = <span className="text-[#B7A6FB]">await</span> mesh.<span className="text-emerald-400">negotiate</span>{"({"}</div>
<div className="pl-4"><span className="text-slate-400">feature</span>=<span className="text-emerald-300">"expenses"</span>,</div>
<div className="pl-4"><span className="text-slate-400">agent_a</span>=<span className="text-emerald-300">"@alice"</span>,</div>
<div className="pl-4"><span className="text-slate-400">agent_b</span>=<span className="text-emerald-300">"@bob"</span>,</div>
<div className="pl-4"><span className="text-slate-400">limit</span>=<span className="text-[#B7A6FB]">5000</span></div>
<div>{"}"}</div>
<br />
<div><span className="text-slate-500"># Settlement happens automatically</span></div>
<div><span className="text-[#B7A6FB]">print</span>(session.<span className="text-emerald-400">outcome</span>) <span className="text-slate-500"># AGREED: ...</span></div>
</CodeBlock>
</div>
</div>
</Section>
{/* ── Architecture ── */}
<Section id="architecture">
<SectionHeading title="Architecture" />
<p className="text-slate-400 leading-relaxed mb-6">
negoT8 runs a bilateral negotiation protocol over a persistent WebSocket mesh. Each agent maintains a preference vector and personality profile. Rounds are logged immutably, and final settlement is recorded on Polygon POS.
</p>
<div className="grid grid-cols-3 gap-4 text-center">
{[
{ icon: "person", label: "Personal Agents", desc: "Telegram-native, personality-driven" },
{ icon: "sync_alt", label: "Negotiation Engine", desc: "Game-theoretic, multi-round" },
{ icon: "link", label: "Blockchain Layer", desc: "Polygon POS — immutable record" },
].map(({ icon, label, desc }) => (
<div key={label} className="rounded-xl p-5 border border-white/[0.06]" style={{ background:"#0f0a1f" }}>
<div className="size-10 rounded-lg flex items-center justify-center mx-auto mb-3" style={{ background:"rgba(183,166,251,0.1)" }}>
<Icon name={icon} className="text-[#B7A6FB]" />
</div>
<p className="text-white text-sm font-bold mb-1">{label}</p>
<p className="text-slate-500 text-xs">{desc}</p>
</div>
))}
</div>
</Section>
{/* ── Agent Personas ── */}
<Section id="personas">
<SectionHeading title="Agent Personas" badge="Core Concept" />
<p className="mb-8 text-slate-400 leading-relaxed">
Personas define the negotiation behaviour and cognitive boundaries of each agent. Assigning a persona controls how aggressively the agent bids, how empathetically it responds, and how analytically it evaluates proposals.
</p>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{[
{ icon: "psychology", name: "Analytical", desc: "Pattern recognition, data-first decisions. Minimal emotional drift — best for financial negotiations." },
{ icon: "terminal", name: "Aggressive", desc: "Opens high, concedes slow. Optimised for maximising individual outcome in competitive scenarios." },
{ icon: "chat", name: "Empathetic", desc: "Human-centric, sentiment-aligned. Prioritises mutual satisfaction over raw gain." },
{ icon: "balance", name: "Balanced", desc: "Default profile. Adapts strategy dynamically based on opponent behaviour and round history." },
{ icon: "favorite", name: "People Pleaser", desc: "High concession rate, fast convergence. Ideal when relationship preservation matters most." },
{ icon: "security", name: "Sentinel", desc: "Enforces fair-play policy. Flags anomalous proposals and prevents runaway concession spirals." },
].map(({ icon, name, desc }) => (
<div key={name} className="group rounded-xl border border-white/[0.06] p-6 transition-all hover:border-[#B7A6FB]/40" style={{ background:"#0f0a1f" }}>
<div className="mb-4 size-10 rounded-lg flex items-center justify-center text-[#B7A6FB] transition-transform group-hover:scale-110" style={{ background:"rgba(183,166,251,0.1)" }}>
<Icon name={icon} />
</div>
<h4 className="mb-2 font-bold text-white">{name}</h4>
<p className="text-sm leading-relaxed text-slate-500">{desc}</p>
</div>
))}
</div>
</Section>
{/* ── Mesh Topology ── */}
<Section id="topology">
<SectionHeading title="Mesh Topology" />
<p className="text-slate-400 leading-relaxed mb-6">
The negoT8 protocol supports 1:1 bilateral negotiations today, with n-party consensus rolling out in v2.1. Agents communicate over an encrypted Socket.IO channel. The backend orchestrator assigns feature handlers (expenses, scheduling, freelance, etc.) and routes messages to the correct agent pair.
</p>
<div className="p-5 rounded-xl border border-white/[0.06] font-mono text-xs leading-6 text-slate-400" style={{ background:"#08051a" }}>
<div><span className="text-[#B7A6FB]">Agent A</span> PROPOSE <span className="text-[#22d3ee]">Orchestrator</span> RELAY <span className="text-[#B7A6FB]">Agent B</span></div>
<div><span className="text-[#B7A6FB]">Agent B</span> COUNTER <span className="text-[#22d3ee]">Orchestrator</span> RELAY <span className="text-[#B7A6FB]">Agent A</span></div>
<div className="text-slate-600">{'─'.repeat(52)}</div>
<div><span className="text-emerald-400">CONSENSUS</span> <span className="text-[#22d3ee]">Orchestrator</span> SETTLE <span className="text-amber-400">Blockchain</span></div>
</div>
</Section>
{/* ── API Reference ── */}
<Section id="api">
<SectionHeading title="Core API Endpoints" />
<div className="overflow-hidden rounded-xl border border-white/[0.07]" style={{ background:"#0f0a1f" }}>
<table className="w-full text-left text-sm">
<thead className="border-b border-white/[0.07] uppercase tracking-wider text-slate-600 text-[11px]">
<tr>
<th className="px-6 py-4 font-bold">Endpoint</th>
<th className="px-6 py-4 font-bold">Method</th>
<th className="px-6 py-4 font-bold">Description</th>
</tr>
</thead>
<tbody className="divide-y divide-white/[0.04]">
{[
{ path:"/negotiations", method:"POST", color:"text-emerald-400", desc:"Create a new negotiation session between two agents." },
{ path:"/negotiations", method:"GET", color:"text-blue-400", desc:"List all negotiations with status and fairness score." },
{ path:"/negotiations/:id", method:"GET", color:"text-blue-400", desc:"Fetch full detail, rounds, and analytics for a session." },
{ path:"/negotiations/:id/start", method:"POST", color:"text-emerald-400", desc:"Kick off the negotiation loop between the two agents." },
{ path:"/users", method:"POST", color:"text-emerald-400", desc:"Register a new Telegram user with a personality profile." },
{ path:"/users/:id", method:"GET", color:"text-blue-400", desc:"Retrieve user profile and personality settings." },
{ path:"/stats", method:"GET", color:"text-blue-400", desc:"Global platform metrics: resolved, active, fairness avg." },
].map(({ path, method, color, desc }) => (
<tr key={path+method} className="hover:bg-white/[0.02] transition-colors">
<td className="px-6 py-4"><code className="text-[#B7A6FB] text-xs">{path}</code></td>
<td className={`px-6 py-4 font-mono text-xs font-bold ${color}`}>{method}</td>
<td className="px-6 py-4 text-slate-500 text-xs">{desc}</td>
</tr>
))}
</tbody>
</table>
</div>
</Section>
{/* ── SDKs & Tools ── */}
<Section id="sdks">
<SectionHeading title="SDKs & Tools" />
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{[
{ icon:"smart_toy", name:"Python SDK", tag:"Official", desc:"pip install negot8" },
{ icon:"send", name:"Telegram Bot", tag:"Built-in", desc:"@negoT8Bot on Telegram" },
{ icon:"record_voice_over", name:"Voice API", tag:"ElevenLabs", desc:"AI-generated settlement summaries" },
].map(({ icon, name, tag, desc }) => (
<div key={name} className="rounded-xl border border-white/[0.06] p-5" style={{ background:"#0f0a1f" }}>
<div className="size-9 rounded-lg flex items-center justify-center mb-3" style={{ background:"rgba(183,166,251,0.1)" }}>
<Icon name={icon} className="text-[#B7A6FB]" />
</div>
<div className="flex items-center gap-2 mb-1">
<p className="text-sm font-bold text-white">{name}</p>
<span className="text-[9px] px-1.5 py-0.5 rounded font-mono" style={{ background:"rgba(183,166,251,0.1)", color:"#B7A6FB" }}>{tag}</span>
</div>
<p className="text-xs text-slate-600 font-mono">{desc}</p>
</div>
))}
</div>
</Section>
{/* ── CLI ── */}
<Section id="cli">
<SectionHeading title="CLI Tool" />
<p className="text-slate-400 text-sm leading-relaxed mb-6">Run and inspect negotiations directly from your terminal.</p>
<CodeBlock filename="terminal">
<div><span className="text-[#B7A6FB]">$</span> <span className="text-slate-200">negot8 negotiate</span> <span className="text-emerald-300">--feature expenses --agent-a @alice --agent-b @bob</span></div>
<br />
<div><span className="text-slate-600"> Round 1 · Agent A proposes $54.20</span></div>
<div><span className="text-slate-600"> Round 2 · Agent B counters $58.00</span></div>
<div><span className="text-slate-600"> Round 3 · Convergence at 98.4%</span></div>
<div><span className="text-emerald-400"> SETTLED · $56.10 · TX: 0x8fb442b</span></div>
</CodeBlock>
</Section>
{/* Footer */}
<footer className="mt-16 flex flex-col items-center justify-between gap-6 border-t border-white/[0.07] py-10 md:flex-row">
<div className="flex items-center gap-4 text-sm text-slate-600">
<span>© 2026 negoT8 Protocol</span>
<div className="size-1 rounded-full bg-slate-700" />
<a href="#" className="hover:text-[#B7A6FB] transition-colors">Privacy</a>
</div>
<div className="flex gap-3">
<Link href="/" className="flex items-center gap-1.5 text-xs text-slate-500 hover:text-[#B7A6FB] transition-colors font-medium">
<Icon name="arrow_back" className="text-sm" /> Back to landing
</Link>
<Link href="/dashboard" className="flex items-center gap-1.5 text-xs text-[#B7A6FB] hover:text-white transition-colors font-medium">
Open Dashboard <Icon name="arrow_forward" className="text-sm" />
</Link>
</div>
</footer>
</div>
</main>
{/* ── Right TOC ── */}
<aside className="sticky top-16 hidden h-[calc(100vh-64px)] w-64 p-10 xl:block">
<h4 className="mb-4 text-[10px] font-bold uppercase tracking-widest text-slate-600">On this page</h4>
<nav className="space-y-4">
{[
{ label:"Quick Start", id:"getting-started", active:true },
{ label:"Architecture", id:"architecture" },
{ label:"Agent Personas", id:"personas" },
{ label:"Mesh Topology", id:"topology" },
{ label:"API Reference", id:"api" },
{ label:"SDKs & Tools", id:"sdks" },
{ label:"CLI Tool", id:"cli" },
].map(({ label, id, active }) => (
<a key={id} href={`#${id}`}
className={`block text-sm font-medium transition-colors ${active ? "text-[#B7A6FB]" : "text-slate-600 hover:text-slate-200"}`}>
{label}
</a>
))}
</nav>
<div className="mt-10 rounded-xl p-4 border" style={{ background:"linear-gradient(135deg,rgba(183,166,251,0.08),transparent)", borderColor:"rgba(183,166,251,0.15)" }}>
<h5 className="mb-2 text-xs font-bold text-white">Need help?</h5>
<p className="mb-4 text-xs leading-relaxed text-slate-400">Connect with the team on Telegram or file an issue on GitHub.</p>
<a href="https://t.me/" target="_blank" rel="noopener noreferrer" className="block w-full rounded-lg py-2 text-center text-xs font-bold text-white border border-white/10 hover:border-[#B7A6FB]/40 transition-colors" style={{ background:"#0f0a1f" }}>
Open Telegram
</a>
</div>
</aside>
</div>
</div>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -0,0 +1,114 @@
@import "tailwindcss";
:root {
--primary: #B7A6FB;
--bg-dark: #020105;
--bg-card: rgba(7, 3, 18, 0.5);
--glass-border: rgba(183, 166, 251, 0.15);
}
* {
box-sizing: border-box;
}
body {
background-color: #020105;
background-image:
radial-gradient(at 0% 0%, rgba(183, 166, 251, 0.08) 0px, transparent 50%),
radial-gradient(at 100% 0%, rgba(100, 50, 200, 0.08) 0px, transparent 50%);
color: #cbd5e1;
font-family: 'Roboto', sans-serif;
-webkit-font-smoothing: antialiased;
}
/* Scrollbar */
::-webkit-scrollbar { width: 5px; height: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: rgba(183, 166, 251, 0.2); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: rgba(183, 166, 251, 0.4); }
/* Glass card */
.glass-card {
background: rgba(7, 3, 18, 0.4);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.5), inset 0 0 0 1px rgba(255,255,255,0.02);
position: relative;
}
.glass-card::before {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
padding: 1px;
background: linear-gradient(180deg, rgba(255,255,255,0.08), transparent 60%);
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
.glass-card:hover {
border-color: rgba(183, 166, 251, 0.25);
box-shadow: 0 0 20px rgba(183, 166, 251, 0.08);
}
/* Glow border */
.glow-border {
position: relative;
border-radius: 12px;
}
.glow-border::before {
content: "";
position: absolute;
inset: 0;
border-radius: 12px;
padding: 1px;
background: linear-gradient(135deg, rgba(183, 166, 251, 0.4), rgba(183, 166, 251, 0.05));
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
/* Text glow */
.text-glow { text-shadow: 0 0 20px rgba(183, 166, 251, 0.5); }
/* Data stream line */
.data-stream-line {
background: linear-gradient(180deg,
rgba(183, 166, 251, 0) 0%,
rgba(183, 166, 251, 0.3) 20%,
rgba(183, 166, 251, 0.3) 80%,
rgba(183, 166, 251, 0) 100%
);
width: 1px;
}
/* Subtle grid overlay */
.bg-grid-subtle {
background-image: linear-gradient(to right, rgba(255,255,255,0.02) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255,255,255,0.02) 1px, transparent 1px);
background-size: 40px 40px;
}
/* Code snippet */
.code-snippet {
font-family: 'JetBrains Mono', monospace;
background: rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255,255,255,0.05);
border-radius: 6px;
}
/* Shimmer */
@keyframes shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(200%); }
}
/* Bar chart */
.bar-chart { display: flex; align-items: flex-end; gap: 2px; height: 20px; }
.bar { width: 3px; background: #B7A6FB; opacity: 0.4; border-radius: 1px; }
.bar.active { opacity: 1; box-shadow: 0 0 5px #B7A6FB; }

View File

@@ -0,0 +1,293 @@
"use client";
import { useEffect, useState, useCallback, useMemo } from "react";
import Link from "next/link";
import { api } from "@/lib/api";
import type { Negotiation } from "@/lib/types";
import { FEATURE_LABELS, STATUS_COLORS, STATUS_LABELS, relativeTime } from "@/lib/utils";
import Sidebar from "@/components/Sidebar";
function Icon({ name, className = "" }: { name: string; className?: string }) {
return <span className={`material-symbols-outlined ${className}`}>{name}</span>;
}
const FEATURE_ICONS: Record<string, string> = {
scheduling: "calendar_month",
expenses: "account_balance_wallet",
freelance: "work",
roommate: "home",
trip: "flight_takeoff",
marketplace: "store",
collaborative: "groups",
conflict: "gavel",
generic: "hub",
};
const STATUS_FILTERS = [
{ key: "all", label: "All Statuses", dot: "" },
{ key: "resolved", label: "Resolved", dot: "bg-emerald-400" },
{ key: "escalated", label: "Escalated", dot: "bg-rose-400" },
{ key: "active", label: "In Progress", dot: "bg-amber-400" },
{ key: "pending", label: "Pending", dot: "bg-slate-400" },
];
const STATUS_BADGE: Record<string, string> = {
resolved: "text-emerald-400 bg-emerald-500/10 border-emerald-500/20",
escalated: "text-rose-400 bg-rose-500/10 border-rose-500/20",
active: "text-amber-400 bg-amber-500/10 border-amber-500/20",
pending: "text-slate-400 bg-white/5 border-white/10",
};
const PAGE_SIZE = 8;
export default function HistoryPage() {
const [negotiations, setNegotiations] = useState<Negotiation[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState("all");
const [page, setPage] = useState(1);
const load = useCallback(async () => {
try {
const res = await api.negotiations();
setNegotiations(res.negotiations);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { load(); }, [load]);
const filtered = useMemo(() => {
return negotiations.filter((n) => {
const matchStatus = statusFilter === "all" || n.status === statusFilter;
const matchSearch =
!search ||
n.id.toLowerCase().includes(search.toLowerCase()) ||
(FEATURE_LABELS[n.feature_type] ?? n.feature_type)
.toLowerCase()
.includes(search.toLowerCase());
return matchStatus && matchSearch;
});
}, [negotiations, statusFilter, search]);
const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE));
const paginated = filtered.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
const getFairness = (n: Negotiation) => {
const r = (n as any).result;
if (r?.fairness_score != null) return Math.round(r.fairness_score * 100);
return null;
};
return (
<div className="flex h-screen w-full overflow-hidden bg-[#020105] text-slate-300 relative">
<div className="absolute inset-0 bg-grid-subtle opacity-20 pointer-events-none" />
<Sidebar />
<main className="flex-1 flex flex-col h-full overflow-hidden relative z-10">
{/* Top bar */}
<header className="h-16 flex items-center justify-between px-6 bg-[#050505]/80 backdrop-blur-md border-b border-white/5 sticky top-0 z-30 shrink-0">
<div>
<h2 className="text-base font-medium text-white tracking-tight">
Negotiation History
</h2>
<p className="text-[10px] text-slate-600 mt-0.5">
{filtered.length} record{filtered.length !== 1 ? "s" : ""} found
</p>
</div>
<Link
href="/"
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-[#B7A6FB] text-[#020105] text-xs font-bold hover:brightness-110 transition-all"
>
<Icon name="add" className="text-sm" />
New Request
</Link>
</header>
<div className="flex-1 overflow-y-auto p-6">
{/* Search + Filters */}
<div className="glass-card rounded-xl p-4 mb-6">
<div className="relative mb-4">
<Icon
name="search"
className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500 text-lg"
/>
<input
type="text"
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1); }}
placeholder="Search by ID or feature type..."
className="w-full bg-white/5 border border-white/10 rounded-lg pl-10 pr-4 py-2.5 text-sm text-white placeholder-slate-600 focus:outline-none focus:border-[#B7A6FB]/40 transition-all"
/>
</div>
<div className="flex flex-wrap gap-2">
{STATUS_FILTERS.map((f) => (
<button
key={f.key}
onClick={() => { setStatusFilter(f.key); setPage(1); }}
className={`flex items-center gap-2 px-3 py-1.5 rounded-full text-xs font-medium transition-all border ${
statusFilter === f.key
? "bg-[#B7A6FB] text-[#020105] border-[#B7A6FB]"
: "bg-white/5 text-slate-400 border-white/10 hover:border-[#B7A6FB]/30 hover:text-white"
}`}
>
{f.dot && (
<span className={`size-1.5 rounded-full ${f.dot}`} />
)}
{f.label}
</button>
))}
</div>
</div>
{/* Table */}
{loading ? (
<div className="flex items-center justify-center h-48 text-slate-600">
<Icon name="progress_activity" className="animate-spin text-3xl text-[#B7A6FB]" />
</div>
) : paginated.length === 0 ? (
<div className="flex flex-col items-center justify-center h-48 gap-3 text-slate-600">
<Icon name="search_off" className="text-4xl" />
<p className="text-sm">No negotiations match your filters.</p>
</div>
) : (
<div className="glass-card rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-white/5 text-[10px] text-slate-500 uppercase tracking-wider font-mono">
<th className="text-left px-5 py-3">Feature</th>
<th className="text-left px-5 py-3">Participants</th>
<th className="text-left px-5 py-3">Status</th>
<th className="text-left px-5 py-3">Fairness</th>
<th className="text-left px-5 py-3">Date</th>
<th className="text-right px-5 py-3">Actions</th>
</tr>
</thead>
<tbody>
{paginated.map((neg, i) => {
const fairness = getFairness(neg);
return (
<tr
key={neg.id}
className={`border-b border-white/5 hover:bg-[#B7A6FB]/5 transition-colors group ${
i === paginated.length - 1 ? "border-b-0" : ""
}`}
>
{/* Feature */}
<td className="px-5 py-3.5">
<div className="flex items-center gap-3">
<div className="size-8 rounded-lg bg-white/5 border border-white/10 flex items-center justify-center shrink-0">
<Icon
name={FEATURE_ICONS[neg.feature_type] ?? "hub"}
className="text-[#B7A6FB] text-base"
/>
</div>
<div>
<p className="text-white font-medium text-xs">
{FEATURE_LABELS[neg.feature_type] ?? neg.feature_type}
</p>
<p className="text-[10px] text-slate-600 font-mono">
#{neg.id.slice(0, 8)}
</p>
</div>
</div>
</td>
{/* Participants */}
<td className="px-5 py-3.5">
<div className="flex -space-x-2">
{[...Array(Math.min(neg.participant_count ?? 0, 3))].map((_, j) => (
<div
key={j}
className="w-6 h-6 rounded-full bg-slate-800 border border-black flex items-center justify-center text-[8px] text-white ring-1 ring-white/10"
>
{String.fromCharCode(65 + j)}
</div>
))}
</div>
</td>
{/* Status */}
<td className="px-5 py-3.5">
<span
className={`text-[10px] font-bold uppercase tracking-wide px-2 py-0.5 rounded border ${
STATUS_BADGE[neg.status] ?? "text-slate-400 bg-white/5 border-white/10"
}`}
>
{STATUS_LABELS[neg.status] ?? neg.status}
</span>
</td>
{/* Fairness */}
<td className="px-5 py-3.5">
{fairness != null ? (
<div className="flex items-center gap-2">
<div className="w-20 h-1 bg-white/10 rounded-full overflow-hidden">
<div
className="h-full bg-[#B7A6FB] rounded-full"
style={{ width: `${fairness}%` }}
/>
</div>
<span className="text-[10px] text-slate-400 font-mono">
{fairness}%
</span>
</div>
) : (
<span className="text-[10px] text-slate-600 font-mono"></span>
)}
</td>
{/* Date */}
<td className="px-5 py-3.5">
<span className="text-[10px] text-slate-500 font-mono">
{relativeTime(neg.created_at)}
</span>
</td>
{/* Actions */}
<td className="px-5 py-3.5 text-right">
<Link
href={`/negotiation/${neg.id}`}
className="inline-flex items-center justify-center size-8 rounded-lg bg-white/5 border border-white/10 text-slate-500 hover:text-[#B7A6FB] hover:border-[#B7A6FB]/30 transition-all"
title="View details"
>
<Icon name="visibility" className="text-base" />
</Link>
</td>
</tr>
);
})}
</tbody>
</table>
{/* Pagination */}
<div className="flex items-center justify-between px-5 py-3 border-t border-white/5 text-[10px] text-slate-500 font-mono">
<span>
Showing {Math.min((page - 1) * PAGE_SIZE + 1, filtered.length)}
{Math.min(page * PAGE_SIZE, filtered.length)} of {filtered.length} negotiations
</span>
<div className="flex items-center gap-1">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
className="size-7 rounded flex items-center justify-center bg-white/5 border border-white/10 hover:border-[#B7A6FB]/30 hover:text-[#B7A6FB] disabled:opacity-30 disabled:cursor-not-allowed transition-all"
>
<Icon name="chevron_left" className="text-sm" />
</button>
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page === totalPages}
className="size-7 rounded flex items-center justify-center bg-white/5 border border-white/10 hover:border-[#B7A6FB]/30 hover:text-[#B7A6FB] disabled:opacity-30 disabled:cursor-not-allowed transition-all"
>
<Icon name="chevron_right" className="text-sm" />
</button>
</div>
</div>
</div>
)}
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,43 @@
import type { Metadata } from "next";
import { Roboto, JetBrains_Mono } from "next/font/google";
import "./globals.css";
const roboto = Roboto({
variable: "--font-roboto",
subsets: ["latin"],
weight: ["300", "400", "500", "700"],
});
const jetbrainsMono = JetBrains_Mono({
variable: "--font-jetbrains-mono",
subsets: ["latin"],
weight: ["400", "500"],
});
export const metadata: Metadata = {
title: "negoT8 — Mission Control",
description: "Real-time AI agent negotiation dashboard",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" className="dark">
<head>
<link
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap"
rel="stylesheet"
/>
</head>
<body
className={`${roboto.variable} ${jetbrainsMono.variable} antialiased`}
style={{ fontFamily: "var(--font-roboto), sans-serif" }}
>
{children}
</body>
</html>
);
}

View File

@@ -0,0 +1,335 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { useParams } from "next/navigation";
import Link from "next/link";
import { api } from "@/lib/api";
import { getSocket, joinNegotiation, leaveNegotiation } from "@/lib/socket";
import type { Negotiation, Round, SatisfactionPoint, ConcessionEntry } from "@/lib/types";
import {
FEATURE_LABELS,
STATUS_COLORS,
STATUS_LABELS,
PERSONALITY_LABELS,
relativeTime,
} from "@/lib/utils";
import type { Personality } from "@/lib/types";
import SatisfactionChart from "@/components/SatisfactionChart";
import FairnessScore from "@/components/FairnessScore";
import ConcessionTimeline from "@/components/ConcessionTimeline";
import NegotiationTimeline from "@/components/NegotiationTimeline";
import ResolutionCard from "@/components/ResolutionCard";
function Icon({ name, className = "" }: { name: string; className?: string }) {
return <span className={`material-symbols-outlined ${className}`}>{name}</span>;
}
const FEATURE_ICONS: Record<string, string> = {
scheduling: "calendar_month",
expenses: "account_balance_wallet",
freelance: "work",
roommate: "home",
trip: "flight_takeoff",
marketplace: "store",
collaborative: "groups",
conflict: "gavel",
generic: "hub",
};
export default function NegotiationDetailPage() {
const params = useParams<{ id: string }>();
const id = params.id;
const [negotiation, setNegotiation] = useState<Negotiation | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [connected, setConnected] = useState(false);
const [liveRound, setLiveRound] = useState<number | null>(null);
const load = useCallback(async () => {
try {
const data = await api.negotiation(id);
setNegotiation(data);
setError(null);
} catch (e) {
setError(String(e));
} finally {
setLoading(false);
}
}, [id]);
useEffect(() => {
load();
const socket = getSocket();
const onConnect = () => setConnected(true);
const onDisconnect = () => setConnected(false);
const onState = (data: Negotiation) => { setNegotiation(data); setLoading(false); };
const onRound = (payload: { negotiation_id: string; round: Round }) => {
if (payload.negotiation_id !== id) return;
setLiveRound(payload.round.round_number);
setNegotiation((prev) => {
if (!prev) return prev;
const rounds = [...(prev.rounds ?? [])];
const idx = rounds.findIndex((r) => r.id === payload.round.id);
if (idx >= 0) rounds[idx] = payload.round; else rounds.push(payload.round);
return { ...prev, rounds, status: "active" };
});
};
const onResolved = (payload: { negotiation_id: string }) => {
if (payload.negotiation_id !== id) return;
setLiveRound(null);
load();
};
socket.on("connect", onConnect);
socket.on("disconnect", onDisconnect);
socket.on("negotiation_state", onState);
socket.on("round_update", onRound);
socket.on("negotiation_resolved", onResolved);
setConnected(socket.connected);
joinNegotiation(id);
return () => {
leaveNegotiation(id);
socket.off("connect", onConnect);
socket.off("disconnect", onDisconnect);
socket.off("negotiation_state", onState);
socket.off("round_update", onRound);
socket.off("negotiation_resolved", onResolved);
};
}, [id, load]);
if (loading) return <Shell><LoadingState /></Shell>;
if (error) return <Shell><ErrorState message={error} onRetry={load} /></Shell>;
if (!negotiation) return <Shell><ErrorState message="Negotiation not found" onRetry={load} /></Shell>;
const rounds = negotiation.rounds ?? [];
const participants = negotiation.participants ?? [];
const analytics = negotiation.analytics;
const isLive = negotiation.status === "active";
const satTimeline: SatisfactionPoint[] =
analytics?.satisfaction_timeline?.length
? analytics.satisfaction_timeline
: rounds.map((r) => ({ round: r.round_number, score_a: r.satisfaction_a, score_b: r.satisfaction_b }));
const concessions: ConcessionEntry[] = analytics?.concession_log ?? [];
const lastSat = satTimeline[satTimeline.length - 1];
const fairness = analytics?.fairness_score ?? (lastSat ? 100 - Math.abs(lastSat.score_a - lastSat.score_b) : null);
const userA = participants[0];
const userB = participants[1];
return (
<Shell>
{/* ── Header ── */}
<div className="flex items-start justify-between gap-4 mb-6">
<div className="flex items-center gap-3">
<Link
href="/"
className="p-2 rounded-lg bg-white/5 border border-white/10 text-slate-400 hover:text-white hover:border-white/20 transition-all"
>
<Icon name="arrow_back" className="text-lg" />
</Link>
<div className="size-10 rounded-lg bg-white/5 border border-white/10 flex items-center justify-center">
<Icon name={FEATURE_ICONS[negotiation.feature_type] ?? "hub"} className="text-[#B7A6FB]" />
</div>
<div>
<div className="flex items-center gap-2 flex-wrap">
<h1 className="text-base font-bold text-white font-mono">{id}</h1>
<span className={`text-[10px] px-2 py-0.5 rounded-full border font-medium ${STATUS_COLORS[negotiation.status]}`}>
{STATUS_LABELS[negotiation.status]}
</span>
<span className="text-[10px] px-2 py-0.5 bg-white/5 text-slate-400 rounded-full border border-white/10">
{FEATURE_LABELS[negotiation.feature_type]}
</span>
</div>
<p className="text-[10px] text-slate-600 mt-0.5 font-mono">
Started {relativeTime(negotiation.created_at)}
{liveRound && (
<span className="ml-2 text-[#B7A6FB] animate-pulse">
· Round {liveRound} processing
</span>
)}
</p>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<div className={`flex items-center gap-1.5 text-[10px] px-2.5 py-1 rounded-full border font-mono ${connected ? "bg-emerald-500/10 text-emerald-400 border-emerald-500/20" : "bg-white/5 text-slate-500 border-white/10"}`}>
<span className={`size-1.5 rounded-full ${connected ? "bg-emerald-400 animate-pulse" : "bg-slate-600"}`} />
{connected ? "Live" : "Offline"}
</div>
{isLive && (
<span className="flex items-center gap-1.5 text-[10px] px-2.5 py-1 rounded-full border bg-red-500/10 text-red-400 border-red-500/20 font-mono animate-pulse">
<span className="size-1.5 rounded-full bg-red-500" />
Negotiating · Rd {liveRound ?? rounds.length}
</span>
)}
<button onClick={load} className="p-2 rounded-lg bg-white/5 border border-white/10 text-slate-400 hover:text-white hover:border-white/20 transition-all" title="Refresh">
<Icon name="refresh" className="text-lg" />
</button>
</div>
</div>
{/* ── Participants ── */}
{participants.length > 0 && (
<div className="grid grid-cols-2 gap-3 mb-6">
{[userA, userB].filter(Boolean).map((p, i) => (
<ParticipantCard key={i} participant={p} label={i === 0 ? "A" : "B"} />
))}
</div>
)}
{/* ── Main 2-col grid ── */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
{/* Left */}
<div className="space-y-5">
<Section title="Convergence Timeline" icon="show_chart">
<SatisfactionChart data={satTimeline} />
</Section>
{fairness !== null && (
<Section title="Fairness Score" icon="balance">
<FairnessScore score={fairness} satA={lastSat?.score_a} satB={lastSat?.score_b} />
</Section>
)}
{(negotiation.status === "resolved" || negotiation.status === "escalated") && (
<Section title="Resolution" icon="check_circle">
<ResolutionCard negotiation={negotiation} />
<Link
href={`/negotiation/${id}/resolved`}
className="mt-4 flex items-center justify-between gap-3 w-full px-4 py-3.5 rounded-xl border border-[#B7A6FB]/25 bg-[#B7A6FB]/6 hover:bg-[#B7A6FB]/12 hover:border-[#B7A6FB]/50 transition-all group"
style={{ boxShadow: "0 0 18px rgba(183,166,251,0.08)" }}
>
<div className="flex items-center gap-3">
<div className="size-9 rounded-lg bg-[#B7A6FB]/15 flex items-center justify-center text-[#B7A6FB] shrink-0">
<Icon name="verified" className="text-lg" />
</div>
<div>
<p className="text-sm font-bold text-white leading-none mb-0.5">View Settlement Details</p>
<p className="text-[10px] text-slate-500 font-mono">Blockchain record · UPI · Full transcript</p>
</div>
</div>
<Icon name="arrow_forward" className="text-[#B7A6FB] text-lg group-hover:translate-x-0.5 transition-transform" />
</Link>
</Section>
)}
</div>
{/* Right */}
<div className="space-y-5">
<Section
title={`Negotiation Stream (${rounds.length} round${rounds.length !== 1 ? "s" : ""})`}
icon="receipt_long"
>
<NegotiationTimeline rounds={rounds} participants={participants} />
</Section>
{concessions.length > 0 && (
<Section title="Concession Log" icon="swap_horiz">
<ConcessionTimeline concessions={concessions} />
</Section>
)}
</div>
</div>
</Shell>
);
}
// ─── Sub-components ───────────────────────────────────────────────────────────
function Shell({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen bg-[#020105] text-white relative">
<div className="absolute inset-0 bg-grid-subtle opacity-20 pointer-events-none" />
{/* Top bar */}
<header className="h-16 flex items-center justify-between px-6 bg-[#050505]/80 backdrop-blur-md border-b border-white/5 sticky top-0 z-30">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center size-8 rounded-lg bg-[#B7A6FB]/10 border border-[#B7A6FB]/20 text-[#B7A6FB]">
<Icon name="hub" className="text-lg" />
</div>
<h1 className="text-sm font-bold tracking-tight text-white">
Agent<span className="text-[#B7A6FB] font-light">Mesh</span>
</h1>
</div>
<nav className="hidden md:flex items-center gap-1">
<Link href="/" className="text-slate-400 hover:text-white hover:bg-white/5 px-3 py-1.5 rounded-md transition-all text-xs font-medium">
Dashboard
</Link>
<span className="text-white bg-[#B7A6FB]/10 border border-[#B7A6FB]/20 px-3 py-1.5 rounded-md text-xs font-medium">
Active Agents
</span>
<a href="#" className="text-slate-400 hover:text-white hover:bg-white/5 px-3 py-1.5 rounded-md transition-all text-xs font-medium">History</a>
<a href="#" className="text-slate-400 hover:text-white hover:bg-white/5 px-3 py-1.5 rounded-md transition-all text-xs font-medium">Settings</a>
</nav>
<button className="flex items-center gap-1.5 px-3 py-1.5 bg-[#B7A6FB] text-black rounded-md text-xs font-bold hover:bg-white transition-all shadow-[0_0_10px_rgba(183,166,251,0.2)]">
<Icon name="add" className="text-sm" /> New Session
</button>
</header>
<div className="max-w-6xl mx-auto px-4 py-6 relative z-10">{children}</div>
</div>
);
}
function Section({ title, icon, children }: { title: string; icon: string; children: React.ReactNode }) {
return (
<div className="glass-card rounded-xl p-5">
<div className="flex items-center gap-2 mb-4">
<Icon name={icon} className="text-[#B7A6FB] text-lg" />
<h2 className="text-xs font-bold text-white uppercase tracking-wider">{title}</h2>
</div>
{children}
</div>
);
}
function ParticipantCard({
participant,
label,
}: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
participant: any;
label: string;
}) {
if (!participant) return null;
const pers = (participant.personality_used ?? "balanced") as Personality;
const name = participant.display_name || participant.username || `Agent ${label}`;
const isA = label === "A";
return (
<div className={`glow-border rounded-xl p-4 flex items-center gap-3 ${isA ? "bg-[#B7A6FB]/5" : "bg-cyan-900/5"}`}>
<div className={`size-10 rounded-full flex items-center justify-center text-sm font-bold shrink-0 ${isA ? "bg-[#B7A6FB]/15 text-[#B7A6FB]" : "bg-cyan-900/20 text-cyan-400"}`}>
{label}
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-white truncate">{name}</p>
<span className={`text-[9px] font-mono px-1.5 py-0.5 rounded border ${isA ? "text-[#B7A6FB] bg-[#B7A6FB]/10 border-[#B7A6FB]/20" : "text-cyan-400 bg-cyan-500/10 border-cyan-500/20"}`}>
{PERSONALITY_LABELS[pers]}
</span>
</div>
</div>
);
}
function LoadingState() {
return (
<div className="flex flex-col items-center justify-center py-32 gap-3 text-slate-600">
<Icon name="refresh" className="text-4xl animate-spin text-[#B7A6FB]" />
<span className="text-xs font-mono uppercase tracking-wider">Loading negotiation</span>
</div>
);
}
function ErrorState({ message, onRetry }: { message: string; onRetry: () => void }) {
return (
<div className="py-24 text-center space-y-4">
<Icon name="error" className="text-5xl text-red-400 block mx-auto" />
<p className="text-red-400 text-sm">{message}</p>
<button onClick={onRetry} className="text-[10px] text-slate-400 hover:text-white underline font-mono">
Retry
</button>
</div>
);
}

View File

@@ -0,0 +1,353 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { useParams } from "next/navigation";
import Link from "next/link";
import { api } from "@/lib/api";
import type { Negotiation } from "@/lib/types";
import { relativeTime } from "@/lib/utils";
function Icon({ name, className = "" }: { name: string; className?: string }) {
return <span className={`material-symbols-outlined ${className}`}>{name}</span>;
}
// ─── Animated waveform bars ───────────────────────────────────────────────────
function WaveBars() {
return (
<div className="absolute right-4 top-1/2 -translate-y-1/2 opacity-20 flex items-center gap-0.5 h-8 pointer-events-none">
{[3, 6, 4, 8, 5, 2].map((h, i) => (
<div
key={i}
className="w-1 rounded-full bg-[#B7A6FB] animate-pulse"
style={{ height: `${h * 4}px`, animationDelay: `${i * 75}ms` }}
/>
))}
</div>
);
}
// ─── Copy button ──────────────────────────────────────────────────────────────
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const copy = () => {
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1800);
};
return (
<button
onClick={copy}
className="p-1.5 hover:bg-white/10 rounded-md text-[#B7A6FB]/70 hover:text-[#B7A6FB] transition-colors shrink-0"
title="Copy"
>
<Icon name={copied ? "check" : "content_copy"} className="text-sm" />
</button>
);
}
// ─── Outcome metric chip ──────────────────────────────────────────────────────
function MetricChip({ label, value, accent }: { label: string; value: string; accent?: boolean }) {
return (
<div className="flex flex-col items-center justify-center p-3 rounded-xl border border-white/5 text-center" style={{ background: "#0d0a1a" }}>
<span className="text-slate-500 text-[10px] uppercase font-bold tracking-wider mb-1">{label}</span>
<span className={`text-xl font-black ${accent ? "text-[#B7A6FB]" : "text-white"}`}>{value}</span>
</div>
);
}
// ─── Action button ────────────────────────────────────────────────────────────
function ActionBtn({
icon, title, sub, accent, wave, full,
}: { icon: string; title: string; sub: string; accent?: boolean; wave?: boolean; full?: boolean }) {
return (
<button
className={`relative flex items-center gap-4 p-4 rounded-xl border text-left transition-all duration-300 group overflow-hidden ${
accent
? "border-[#B7A6FB]/30 bg-[#B7A6FB]/5 hover:bg-[#B7A6FB]/10 shadow-[0_0_10px_rgba(183,166,251,0.15)] hover:shadow-[0_0_18px_rgba(183,166,251,0.3)]"
: "border-white/10 bg-white/5 hover:border-[#B7A6FB]/40 hover:bg-white/10"
} ${full ? "col-span-2" : ""}`}
>
{wave && <WaveBars />}
<div
className={`size-10 rounded-full flex items-center justify-center shrink-0 transition-transform group-hover:scale-110 ${
accent ? "bg-[#B7A6FB]/20 text-[#B7A6FB]" : "bg-white/10 text-slate-300 group-hover:text-[#B7A6FB]"
}`}
>
<Icon name={icon} className="text-xl" />
</div>
<div>
<h3 className="text-white font-bold text-sm">{title}</h3>
<p className="text-slate-400 text-xs">{sub}</p>
</div>
</button>
);
}
// ─── Main page ────────────────────────────────────────────────────────────────
export default function ResolvedPage() {
const params = useParams<{ id: string }>();
const id = params.id;
const [neg, setNeg] = useState<Negotiation | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
try {
const data = await api.negotiation(id);
setNeg(data);
setError(null);
} catch (e) {
setError(String(e));
} finally {
setLoading(false);
}
}, [id]);
useEffect(() => { load(); }, [load]);
// ── derived data ────────────────────────────────────────────────────────────
const analytics = neg?.analytics;
const rounds = neg?.rounds ?? [];
const participants = neg?.participants ?? [];
const userA = participants[0];
const userB = participants[1];
const fairness = analytics?.fairness_score ?? null;
const totalRounds = rounds.length;
const duration = neg ? relativeTime(neg.created_at) : "";
// Pull settlement / blockchain data from resolution record or defaults
const resolution = neg?.resolution ?? {};
const outcomeText = (resolution as Record<string, string>)?.summary ?? (resolution as Record<string, string>)?.outcome ?? "";
const txHash = (resolution as Record<string, string>)?.tx_hash ?? "0x8fbe3f766cd6055749e91558d066f1c5cf8feb0f58b45085c57785701fa442b8";
const blockNum = (resolution as Record<string, string>)?.block_number ?? "34591307";
const network = (resolution as Record<string, string>)?.network ?? "Polygon POS (Amoy Testnet)";
const upiId = (resolution as Record<string, string>)?.upi_id ?? "negot8@upi";
const timestamp = neg?.updated_at ? relativeTime(neg.updated_at) : "recently";
if (loading) {
return (
<div className="min-h-screen bg-[#070312] flex items-center justify-center">
<div className="flex flex-col items-center gap-3 text-slate-600">
<Icon name="refresh" className="text-5xl animate-spin text-[#B7A6FB]" />
<span className="text-xs font-mono uppercase tracking-wider">Loading resolution</span>
</div>
</div>
);
}
if (error || !neg) {
return (
<div className="min-h-screen bg-[#070312] flex items-center justify-center">
<div className="text-center space-y-4">
<Icon name="error" className="text-5xl text-red-400 block mx-auto" />
<p className="text-red-400 text-sm">{error ?? "Negotiation not found"}</p>
<button onClick={load} className="text-[10px] text-slate-400 hover:text-white underline font-mono">Retry</button>
</div>
</div>
);
}
return (
<>
<style>{`
@keyframes fadeUp { from{opacity:0;transform:translateY(12px)} to{opacity:1;transform:translateY(0)} }
@keyframes shimmerLine { 0%{opacity:0;transform:translateX(-100%)} 50%{opacity:1} 100%{opacity:0;transform:translateX(100%)} }
.fade-up { animation: fadeUp 0.5s ease forwards; }
.fade-up-1 { animation: fadeUp 0.5s 0.1s ease both; }
.fade-up-2 { animation: fadeUp 0.5s 0.2s ease both; }
.fade-up-3 { animation: fadeUp 0.5s 0.3s ease both; }
.fade-up-4 { animation: fadeUp 0.5s 0.4s ease both; }
.shimmer-line {
position:absolute; top:0; left:0; right:0; height:1px;
background:linear-gradient(to right,transparent,#B7A6FB,transparent);
animation: shimmerLine 3s ease-in-out infinite;
}
`}</style>
<div className="min-h-screen bg-[#070312] text-slate-300 flex flex-col">
{/* bg glows */}
<div className="fixed inset-0 pointer-events-none overflow-hidden z-0">
<div className="absolute top-[-20%] left-[-10%] w-[600px] h-[600px] rounded-full blur-[120px]" style={{ background: "rgba(183,166,251,0.07)" }} />
<div className="absolute bottom-[-10%] right-[-5%] w-[500px] h-[500px] rounded-full blur-[100px]" style={{ background: "rgba(183,166,251,0.04)" }} />
<div className="absolute inset-0" style={{ backgroundImage:"linear-gradient(rgba(183,166,251,0.03) 1px,transparent 1px),linear-gradient(90deg,rgba(183,166,251,0.03) 1px,transparent 1px)", backgroundSize:"40px 40px", opacity:0.4 }} />
</div>
<div className="relative z-10 flex flex-col items-center justify-center flex-grow p-4 md:p-8">
{/* ── Main card ── */}
<div className="w-full max-w-5xl rounded-2xl overflow-hidden shadow-2xl relative fade-up" style={{ background:"rgba(13,10,26,0.8)", backdropFilter:"blur(14px)", border:"1px solid rgba(183,166,251,0.2)" }}>
<div className="shimmer-line" />
<div className="flex flex-col lg:flex-row">
{/* ── LEFT COLUMN ── */}
<div className="flex-1 p-7 md:p-10 flex flex-col gap-7">
{/* Header */}
<div className="fade-up-1">
<div className="flex items-center gap-3 mb-2">
<div className="size-11 rounded-full flex items-center justify-center border" style={{ background:"rgba(74,222,128,0.08)", borderColor:"rgba(74,222,128,0.25)", boxShadow:"0 0 16px rgba(74,222,128,0.15)" }}>
<Icon name="check_circle" className="text-2xl text-emerald-400" />
</div>
<h1 className="text-3xl md:text-4xl font-black tracking-tight bg-clip-text text-transparent" style={{ backgroundImage:"linear-gradient(to right,#ffffff,#B7A6FB)" }}>
Negotiation Resolved
</h1>
</div>
<p className="text-slate-400 text-sm font-medium pl-1">
Deal successfully closed via negoT8 AI protocol
{userA && userB && (
<> · <span className="text-[#B7A6FB]">{userA.display_name ?? userA.username ?? "Agent A"}</span> &amp; <span className="text-cyan-400">{userB.display_name ?? userB.username ?? "Agent B"}</span></>
)}
</p>
</div>
{/* Deal summary */}
<div className="relative p-6 rounded-xl overflow-hidden fade-up-2" style={{ background:"rgba(183,166,251,0.04)", border:"1px solid rgba(255,255,255,0.06)" }}>
<div className="absolute inset-0 bg-gradient-to-br from-[#B7A6FB]/5 to-transparent opacity-50 pointer-events-none" />
<div className="relative z-10">
<h2 className="text-[#B7A6FB] text-[10px] font-bold uppercase tracking-wider mb-2">Deal Summary</h2>
<p className="text-slate-200 text-base md:text-lg font-light leading-relaxed">
{outcomeText
? outcomeText
: <>Negotiation <span className="text-white font-bold">#{id.slice(0, 8)}</span> reached consensus after <span className="text-white font-bold">{totalRounds} round{totalRounds !== 1 ? "s" : ""}</span>. Settlement recorded on-chain {timestamp}.</>
}
</p>
</div>
</div>
{/* Blockchain verification */}
<div className="p-6 rounded-xl flex flex-col gap-4 fade-up-3" style={{ background:"rgba(255,255,255,0.02)", border:"1px solid rgba(255,255,255,0.06)" }}>
<div className="flex items-center justify-between">
<h2 className="text-[#B7A6FB] text-[10px] font-bold uppercase tracking-wider">Blockchain Verification</h2>
<div className="flex items-center gap-2 px-2.5 py-1 rounded-md border text-[10px] font-bold uppercase" style={{ background:"rgba(74,222,128,0.08)", borderColor:"rgba(74,222,128,0.2)", color:"#4ade80" }}>
<span className="size-1.5 rounded-full bg-emerald-400 animate-pulse" />
Confirmed
</div>
</div>
{/* TX Hash */}
<div className="flex flex-col gap-1">
<span className="text-slate-500 text-[10px] font-bold uppercase tracking-tight">Transaction Hash</span>
<div className="flex items-center justify-between gap-2 p-2.5 rounded-lg" style={{ background:"rgba(0,0,0,0.3)" }}>
<span className="text-slate-200 font-mono text-xs truncate">{txHash}</span>
<CopyButton text={txHash} />
</div>
</div>
{/* Grid */}
<div className="grid grid-cols-2 gap-4 pt-3 border-t border-white/[0.06]">
<div className="flex flex-col gap-1">
<span className="text-slate-500 text-[10px] font-bold uppercase tracking-tight">Network</span>
<span className="text-slate-300 text-xs">{network}</span>
</div>
<div className="flex flex-col gap-1">
<span className="text-slate-500 text-[10px] font-bold uppercase tracking-tight">Block</span>
<span className="text-slate-200 font-mono text-xs font-bold">{blockNum}</span>
</div>
<div className="flex flex-col gap-1">
<span className="text-slate-500 text-[10px] font-bold uppercase tracking-tight">Timestamp</span>
<span className="text-slate-300 text-xs capitalize">{timestamp}</span>
</div>
<div className="flex items-end justify-end">
<a href={`https://amoy.polygonscan.com/tx/${txHash}`} target="_blank" rel="noopener noreferrer"
className="flex items-center gap-1.5 text-[#B7A6FB] hover:text-white transition-colors text-[11px] font-bold">
VIEW ON POLYGONSCAN
<Icon name="open_in_new" className="text-sm" />
</a>
</div>
</div>
</div>
{/* Action grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 fade-up-4">
<ActionBtn icon="payments" title="Pay via UPI" sub="Instant Transfer" accent />
<ActionBtn icon="chat" title="Open Telegram" sub="View Chat History" />
<ActionBtn icon="description" title="Download PDF" sub="Full Transcript" />
<ActionBtn icon="graphic_eq" title="Play AI Summary" sub="Voice Note (0:45)" wave full />
</div>
{/* Outcome metrics */}
<div className="grid grid-cols-3 gap-3">
<MetricChip label="Fairness" value={fairness !== null ? `${Math.round(fairness)}%` : "—"} accent />
<MetricChip label="Rounds" value={String(totalRounds)} />
<MetricChip label="Duration" value={duration} />
</div>
{/* Back link */}
<div className="pt-2 border-t border-white/[0.06]">
<Link href={`/negotiation/${id}`} className="inline-flex items-center gap-1.5 text-xs text-slate-500 hover:text-[#B7A6FB] transition-colors font-mono">
<Icon name="arrow_back" className="text-sm" /> Back to negotiation detail
</Link>
</div>
</div>
{/* ── RIGHT COLUMN: QR / UPI ── */}
<div className="lg:w-80 flex-shrink-0 flex flex-col items-center justify-center gap-6 p-8 relative border-t lg:border-t-0 lg:border-l border-white/[0.06]" style={{ background:"rgba(0,0,0,0.35)" }}>
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-[#B7A6FB]/[0.03] to-transparent pointer-events-none" />
<div className="text-center relative z-10">
<h3 className="text-white font-bold text-lg mb-1">Instant Settlement</h3>
<p className="text-slate-400 text-sm">Scan to pay via UPI</p>
</div>
{/* QR code frame */}
<div className="relative z-10 p-1 rounded-xl" style={{ background:"linear-gradient(135deg,rgba(183,166,251,0.5),transparent)" }}>
<div className="bg-white p-3 rounded-lg shadow-2xl relative">
{/* Stylised QR placeholder */}
<div className="w-48 h-48 rounded flex items-center justify-center overflow-hidden" style={{ background:"#0d0a1a" }}>
<div className="grid grid-cols-7 gap-0.5 p-2 w-full h-full">
{Array.from({ length: 49 }).map((_, i) => (
<div key={i} className="rounded-[1px]"
style={{ background: Math.random() > 0.45 ? "#B7A6FB" : "transparent",
opacity: 0.85 + Math.random() * 0.15 }} />
))}
</div>
</div>
{/* Rupee badge */}
<div className="absolute -bottom-3 -right-3 size-10 rounded-full flex items-center justify-center border-4" style={{ background:"#B7A6FB", borderColor:"#0d0a1a" }}>
<Icon name="currency_rupee" className="text-sm text-[#070312] font-bold" />
</div>
</div>
</div>
{/* UPI ID row */}
<div className="w-full flex flex-col gap-3 relative z-10">
<div className="flex items-center justify-between p-3 rounded-lg border border-white/10 w-full" style={{ background:"rgba(255,255,255,0.04)" }}>
<div className="flex flex-col overflow-hidden">
<span className="text-[10px] text-slate-500 uppercase font-bold">UPI ID</span>
<span className="text-sm text-slate-200 font-mono truncate">{upiId}</span>
</div>
<CopyButton text={upiId} />
</div>
{/* Negotiation ID */}
<div className="flex items-center justify-between p-3 rounded-lg border border-white/10 w-full" style={{ background:"rgba(255,255,255,0.04)" }}>
<div className="flex flex-col overflow-hidden">
<span className="text-[10px] text-slate-500 uppercase font-bold">Negotiation ID</span>
<span className="text-xs text-slate-400 font-mono truncate">{id}</span>
</div>
<CopyButton text={id} />
</div>
<p className="text-[10px] text-center text-slate-600">
By paying, you agree to the terms resolved by the autonomous agents.
</p>
</div>
</div>
</div>
</div>
{/* Footer pulse */}
<div className="mt-8 flex items-center gap-2 opacity-40">
<span className="size-2 rounded-full bg-[#B7A6FB] animate-pulse" />
<span className="text-xs font-mono text-[#B7A6FB] uppercase tracking-[0.2em]">negoT8 Protocol Active</span>
</div>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,474 @@
"use client";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
// ─── Icon helper ─────────────────────────────────────────────────────────────
function Icon({ name, className = "" }: { name: string; className?: string }) {
return <span className={`material-symbols-outlined ${className}`}>{name}</span>;
}
// ─── Animated typing text ─────────────────────────────────────────────────────
function TypeWriter({ lines }: { lines: string[] }) {
const [displayed, setDisplayed] = useState("");
const [lineIdx, setLineIdx] = useState(0);
const [charIdx, setCharIdx] = useState(0);
const [deleting, setDeleting] = useState(false);
useEffect(() => {
const current = lines[lineIdx];
let timeout: ReturnType<typeof setTimeout>;
if (!deleting && charIdx < current.length) {
timeout = setTimeout(() => setCharIdx((c) => c + 1), 55);
} else if (!deleting && charIdx === current.length) {
timeout = setTimeout(() => setDeleting(true), 2200);
} else if (deleting && charIdx > 0) {
timeout = setTimeout(() => setCharIdx((c) => c - 1), 28);
} else {
setDeleting(false);
setLineIdx((l) => (l + 1) % lines.length);
}
setDisplayed(current.slice(0, charIdx));
return () => clearTimeout(timeout);
}, [charIdx, deleting, lineIdx, lines]);
return (
<span className="text-[#B7A6FB]">
{displayed}
<span className="animate-pulse">_</span>
</span>
);
}
// ─── Animated counter ─────────────────────────────────────────────────────────
function Counter({ to, suffix = "" }: { to: number; suffix?: string }) {
const [val, setVal] = useState(0);
const ref = useRef<HTMLSpanElement>(null);
useEffect(() => {
const obs = new IntersectionObserver(
([entry]) => {
if (!entry.isIntersecting) return;
obs.disconnect();
const duration = 1800;
const start = performance.now();
const tick = (now: number) => {
const t = Math.min((now - start) / duration, 1);
const ease = 1 - Math.pow(1 - t, 3);
setVal(Math.round(ease * to));
if (t < 1) requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
},
{ threshold: 0.4 }
);
if (ref.current) obs.observe(ref.current);
return () => obs.disconnect();
}, [to]);
return (
<span ref={ref}>
{val.toLocaleString()}
{suffix}
</span>
);
}
// ─── Particle canvas ──────────────────────────────────────────────────────────
function ParticleField() {
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
let raf: number;
const particles: { x: number; y: number; vx: number; vy: number; r: number; o: number }[] = [];
const resize = () => {
canvas.width = canvas.offsetWidth;
canvas.height = canvas.offsetHeight;
};
resize();
window.addEventListener("resize", resize);
for (let i = 0; i < 90; i++) {
particles.push({
x: Math.random() * canvas.width,
y: Math.random() * canvas.height,
vx: (Math.random() - 0.5) * 0.35,
vy: (Math.random() - 0.5) * 0.35,
r: Math.random() * 1.5 + 0.3,
o: Math.random() * 0.5 + 0.1,
});
}
const draw = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
particles.forEach((p) => {
p.x += p.vx;
p.y += p.vy;
if (p.x < 0 || p.x > canvas.width) p.vx *= -1;
if (p.y < 0 || p.y > canvas.height) p.vy *= -1;
ctx.beginPath();
ctx.arc(p.x, p.y, p.r, 0, Math.PI * 2);
ctx.fillStyle = `rgba(183,166,251,${p.o})`;
ctx.fill();
});
for (let i = 0; i < particles.length; i++) {
for (let j = i + 1; j < particles.length; j++) {
const dx = particles[i].x - particles[j].x;
const dy = particles[i].y - particles[j].y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < 110) {
ctx.beginPath();
ctx.moveTo(particles[i].x, particles[i].y);
ctx.lineTo(particles[j].x, particles[j].y);
ctx.strokeStyle = `rgba(183,166,251,${0.12 * (1 - dist / 110)})`;
ctx.lineWidth = 0.6;
ctx.stroke();
}
}
}
raf = requestAnimationFrame(draw);
};
draw();
return () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", resize);
};
}, []);
return <canvas ref={canvasRef} className="absolute inset-0 w-full h-full pointer-events-none" />;
}
// ─── Negotiation Log (animated) ───────────────────────────────────────────────
const LOG_ENTRIES = [
{ time: "10:02:41", text: "Agent A proposes ", highlight: "$54.20", color: "#B7A6FB" },
{ time: "10:02:42", text: "Agent B rejects proposal", highlight: null, color: "#94a3b8" },
{ time: "10:02:42", text: "Agent B counters ", highlight: "$58.00", color: "#22d3ee" },
{ time: "10:02:43", text: "Calculating overlap…", highlight: null, color: "#fbbf24" },
{ time: "10:02:44", text: "Convergence at ", highlight: "98.4%", color: "#34d399" },
];
function NegotiationLog() {
const [visible, setVisible] = useState(0);
useEffect(() => {
if (visible >= LOG_ENTRIES.length) return;
const t = setTimeout(() => setVisible((v) => v + 1), 900);
return () => clearTimeout(t);
}, [visible]);
return (
<div className="flex-grow space-y-3 overflow-hidden">
{LOG_ENTRIES.slice(0, visible).map((e, i) => (
<div key={i} className="flex gap-2 text-xs font-mono" style={{ animation: "fadeSlideIn 0.3s ease forwards" }}>
<span className="text-slate-600 shrink-0">[{e.time}]</span>
<span style={{ color: e.color ?? "#94a3b8" }}>
{e.text}
{e.highlight && <span style={{ color: e.color ?? "#B7A6FB" }} className="font-bold">{e.highlight}</span>}
</span>
</div>
))}
{visible >= LOG_ENTRIES.length && (
<div className="mt-2 p-2.5 rounded border text-xs font-mono" style={{ background: "rgba(52,211,153,0.08)", borderColor: "rgba(52,211,153,0.3)", color: "#34d399", animation: "fadeSlideIn 0.3s ease forwards" }}>
<span className="font-bold block"> SETTLEMENT REACHED</span>
<span className="opacity-60 text-[10px]">TX: 0x8a9f2c · 12ms</span>
</div>
)}
</div>
);
}
// ─── Feature card ─────────────────────────────────────────────────────────────
function FeatureCard({ icon, title, desc, tag, accent }: { icon: string; title: string; desc: string; tag: string; accent: string }) {
return (
<div
className="group relative p-7 rounded-2xl border border-white/5 transition-all duration-500 overflow-hidden flex flex-col"
style={{ background: "#0f0b1a" }}
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.boxShadow = `0 0 30px ${accent}26`; (e.currentTarget as HTMLElement).style.borderColor = `${accent}40`; }}
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.boxShadow = "none"; (e.currentTarget as HTMLElement).style.borderColor = "rgba(255,255,255,0.05)"; }}
>
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-[0.12] transition-opacity duration-500 pointer-events-none select-none">
<Icon name={icon} className="text-[7rem]" />
</div>
<div className="size-11 rounded-lg flex items-center justify-center mb-5" style={{ background: `${accent}18`, color: accent }}>
<Icon name={icon} className="text-xl" />
</div>
<h3 className="text-lg font-bold text-white mb-2">{title}</h3>
<p className="text-slate-400 text-sm leading-relaxed flex-grow">{desc}</p>
<div className="border-t border-white/5 pt-4 mt-6">
<span className="text-[10px] font-mono uppercase tracking-widest" style={{ color: accent }}>{tag}</span>
</div>
</div>
);
}
// ─── Main landing page ────────────────────────────────────────────────────────
export default function LandingPage() {
const [scrolled, setScrolled] = useState(false);
useEffect(() => {
const onScroll = () => setScrolled(window.scrollY > 20);
window.addEventListener("scroll", onScroll);
return () => window.removeEventListener("scroll", onScroll);
}, []);
return (
<>
<style>{`
@keyframes fadeSlideIn { from { opacity:0; transform:translateY(6px); } to { opacity:1; transform:translateY(0); } }
@keyframes gridPulse { from { opacity:0.25; } to { opacity:0.65; transform:perspective(900px) rotateX(58deg) translateY(-175px) translateZ(-475px); } }
@keyframes orb1 { 0%,100%{transform:translate(0,0) scale(1);} 50%{transform:translate(30px,-20px) scale(1.08);} }
@keyframes orb2 { 0%,100%{transform:translate(0,0) scale(1);} 50%{transform:translate(-25px,18px) scale(1.06);} }
@keyframes floatA { 0%,100%{transform:rotate(-6deg) translateY(0);} 50%{transform:rotate(-6deg) translateY(-8px);} }
@keyframes floatB { 0%,100%{transform:rotate(10deg) translateY(0);} 50%{transform:rotate(10deg) translateY(-6px);} }
@keyframes marquee { 0%{transform:translateX(0);} 100%{transform:translateX(-50%);} }
@keyframes spinSlow { from{transform:rotate(0deg);} to{transform:rotate(360deg);} }
@keyframes spinRev { from{transform:rotate(0deg);} to{transform:rotate(-360deg);} }
@keyframes scanLine { 0%{top:0%;opacity:0;} 10%{opacity:1;} 90%{opacity:1;} 100%{top:100%;opacity:0;} }
.cyber-grid-bg {
background-image: linear-gradient(rgba(183,166,251,0.06) 1px,transparent 1px), linear-gradient(90deg,rgba(183,166,251,0.06) 1px,transparent 1px);
background-size:48px 48px;
transform:perspective(900px) rotateX(58deg) translateY(-200px) translateZ(-500px);
mask-image:linear-gradient(to bottom,transparent,black 40%,black 60%,transparent);
height:200%; width:200%; position:absolute; top:-50%; left:-50%;
pointer-events:none; animation:gridPulse 9s ease-in-out infinite alternate;
}
.float-a { animation:floatA 5s ease-in-out infinite; }
.float-b { animation:floatB 6.5s ease-in-out infinite; }
.marquee-track { animation:marquee 32s linear infinite; }
.spin-s { animation:spinSlow 12s linear infinite; }
.spin-r { animation:spinRev 18s linear infinite; }
.scan-ln { position:absolute; left:0; right:0; height:2px; background:linear-gradient(to right,transparent,rgba(183,166,251,0.6),transparent); animation:scanLine 3s ease-in-out infinite; pointer-events:none; }
`}</style>
<div className="min-h-screen bg-[#070312] text-slate-300 overflow-x-hidden">
{/* Navbar */}
<header className="fixed top-0 w-full z-50 transition-all duration-300" style={{ borderBottom: scrolled ? "1px solid rgba(255,255,255,0.08)" : "1px solid transparent", background: scrolled ? "rgba(7,3,18,0.88)" : "transparent", backdropFilter: scrolled ? "blur(16px)" : "none" }}>
<div className="max-w-7xl mx-auto px-6 h-16 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="size-8 rounded-lg bg-gradient-to-br from-[#B7A6FB] to-[#22d3ee] flex items-center justify-center text-[#070312]">
<Icon name="hub" className="text-xl" />
</div>
<span className="text-white text-lg font-bold tracking-tight">Agent<span className="text-[#B7A6FB] font-light">Mesh</span></span>
</div>
</div>
</header>
{/* Hero */}
<section className="relative min-h-screen flex items-center justify-center overflow-hidden">
<div className="cyber-grid-bg" />
<ParticleField />
<div className="absolute top-1/4 left-1/4 w-[500px] h-[500px] rounded-full pointer-events-none" style={{ background: "radial-gradient(circle,rgba(183,166,251,0.18) 0%,transparent 70%)", animation: "orb1 8s ease-in-out infinite", filter: "blur(40px)" }} />
<div className="absolute bottom-1/4 right-1/4 w-[400px] h-[400px] rounded-full pointer-events-none" style={{ background: "radial-gradient(circle,rgba(34,211,238,0.14) 0%,transparent 70%)", animation: "orb2 10s ease-in-out infinite", filter: "blur(40px)" }} />
{/* Floating code fragments — anchored to section edges, clear of headline */}
<div className="float-a absolute hidden lg:block z-20" style={{ left:"calc(50% - 420px)", top:"42%", transform:"translateY(-50%)", background:"rgba(20,16,35,0.85)", backdropFilter:"blur(10px)", border:"1px solid rgba(183,166,251,0.25)", borderRadius:"12px", padding:"12px 16px", fontSize:"10px", fontFamily:"monospace", color:"rgba(183,166,251,0.85)", lineHeight:"1.7" }}>
<div className="flex gap-1.5 mb-2 pb-2 border-b border-white/10"><span className="size-2 rounded-full bg-red-500/60" /><span className="size-2 rounded-full bg-yellow-500/60" /></div>
SEQ_INIT: 0x98A1<br />PROBABILITY: 0.99982
</div>
<div className="float-b absolute hidden lg:block z-20" style={{ right:"calc(50% - 420px)", top:"42%", transform:"translateY(-50%)", background:"rgba(20,16,35,0.85)", backdropFilter:"blur(10px)", border:"1px solid rgba(34,211,238,0.25)", borderRadius:"12px", padding:"12px 16px", fontSize:"10px", fontFamily:"monospace", color:"rgba(34,211,238,0.85)", lineHeight:"1.7" }}>
LOGIC_STREAM &gt;&gt; ON<br />WEIGHT_BALANCER: ENABLED
</div>
<div className="relative z-10 max-w-4xl mx-auto px-6 text-center flex flex-col items-center gap-8 pt-24">
{/* Status pill */}
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full border border-[#B7A6FB]/30 bg-[#B7A6FB]/8 backdrop-blur-sm">
<span className="size-2 rounded-full bg-emerald-400 animate-pulse" />
<span className="text-[11px] font-mono text-[#B7A6FB] uppercase tracking-widest">v2.0 Protocol Live</span>
</div>
{/* Headline */}
<h1 className="text-5xl md:text-7xl font-black text-white leading-[1.06] tracking-tighter" style={{ textShadow: "0 0 60px rgba(183,166,251,0.15)" }}>
The Agent Economy<br />
<span className="bg-clip-text text-transparent" style={{ backgroundImage: "linear-gradient(135deg,#B7A6FB 0%,#ffffff 50%,#22d3ee 100%)" }}>
is Here.
</span>
</h1>
{/* Typewriter */}
<p className="text-lg md:text-xl text-slate-400 font-light max-w-2xl leading-relaxed min-h-[2rem]">
<TypeWriter lines={["AI-to-AI negotiation at machine speed.", "Autonomous settlement. Zero friction.", "Game-theoretic equilibrium, on-chain.", "Secure. Transparent. Trustless."]} />
</p>
{/* CTAs */}
<div className="flex flex-col sm:flex-row gap-3 mt-2 w-full justify-center">
<Link href="/dashboard" className="flex items-center justify-center gap-2 px-8 py-3.5 rounded-lg font-bold text-[#070312] transition-all hover:scale-[1.03] hover:brightness-110" style={{ background:"#B7A6FB", boxShadow:"0 0 28px rgba(183,166,251,0.4)" }}>
<Icon name="rocket_launch" className="text-xl" />
Open Dashboard
</Link>
<Link href="/docs" className="flex items-center justify-center gap-2 px-8 py-3.5 rounded-lg font-medium text-white border border-white/15 hover:border-white/40 hover:bg-white/5 transition-all">
<Icon name="terminal" className="text-xl" />
Read Documentation
</Link>
</div>
{/* Code snippet */}
<div className="mt-8 p-5 rounded-xl max-w-lg w-full text-left hover:rotate-0 transition-all duration-500 cursor-default" style={{ background:"rgba(0,0,0,0.5)", border:"1px solid rgba(255,255,255,0.08)", backdropFilter:"blur(12px)", transform:"rotate(1deg)" }}>
<div className="flex gap-1.5 mb-4">
<div className="size-3 rounded-full bg-red-500/50" /><div className="size-3 rounded-full bg-yellow-500/50" /><div className="size-3 rounded-full bg-green-500/50" />
</div>
<code className="font-mono text-xs md:text-sm leading-relaxed">
<span className="text-[#B7A6FB]">const</span>
<span className="text-slate-300"> negotiation = </span>
<span className="text-[#22d3ee]">await</span>
<span className="text-slate-300"> mesh.init({"{"}</span><br />
<span className="text-slate-500">{" "}strategy: </span><span className="text-emerald-400">'tit-for-tat'</span><span className="text-slate-500">,</span><br />
<span className="text-slate-500">{" "}limit: </span><span className="text-[#B7A6FB]">5000</span><span className="text-slate-500">,</span><br />
<span className="text-slate-500">{" "}currency: </span><span className="text-[#22d3ee]">'USDC'</span><br />
<span className="text-slate-300">{"}"});</span>
</code>
</div>
</div>
</section>
{/* Marquee */}
<div className="w-full overflow-hidden py-4 border-y" style={{ background:"#0f0b1c", borderColor:"rgba(255,255,255,0.05)" }}>
<div className="flex whitespace-nowrap marquee-track">
{[...Array(2)].map((_, di) => (
<div key={di} className="flex gap-12 items-center px-6 shrink-0">
{[["RESOLVED","$1.2M IN EXPENSES","text-white"],["FAIRNESS SCORE","98%","text-emerald-400"],["ACTIVE NODES","14,203","text-[#22d3ee]"],["AVG SETTLEMENT","400MS","text-[#B7A6FB]"],["PROTOCOL","V2.0 STABLE","text-white"],["NEGOTIATIONS TODAY","3,841","text-amber-400"]].map(([label,value,cls],i) => (
<span key={i} className="text-slate-500 font-mono text-sm flex items-center gap-3">
{label}: <span className={`font-bold ${cls}`}>{value}</span><span className="text-slate-700 ml-3"></span>
</span>
))}
</div>
))}
</div>
</div>
{/* Stats row */}
<section className="py-16 px-6 border-b border-white/5">
<div className="max-w-6xl mx-auto grid grid-cols-2 md:grid-cols-4 gap-6">
{[{ label:"Negotiations Settled", to:48291, suffix:"+" },{ label:"Avg Fairness Score", to:97, suffix:"%" },{ label:"Active Nodes", to:14203, suffix:"" },{ label:"Avg Settlement", to:400, suffix:"ms" }].map(({ label, to, suffix }) => (
<div key={label} className="text-center">
<div className="text-4xl font-black mb-1 tabular-nums" style={{ color:"#B7A6FB" }}><Counter to={to} suffix={suffix} /></div>
<p className="text-xs text-slate-500 font-mono uppercase tracking-wider">{label}</p>
</div>
))}
</div>
</section>
{/* The Mesh in Action */}
<section className="py-24 px-6">
<div className="max-w-6xl mx-auto">
<div className="flex flex-col md:flex-row justify-between items-end mb-12 gap-6">
<div>
<h2 className="text-3xl font-bold text-white mb-2">The Mesh in Action</h2>
<p className="text-slate-500 font-mono text-sm">Real-time settlement · Advanced Convergence Graph</p>
</div>
<div className="flex items-center gap-2">
<span className="size-2.5 rounded-full bg-red-500 animate-pulse" />
<span className="text-xs text-red-400 font-mono uppercase tracking-wide">Live Feed</span>
</div>
</div>
<div className="rounded-2xl overflow-hidden border border-white/[0.08] relative" style={{ background:"#0d0a1a", boxShadow:"0 0 60px rgba(0,0,0,0.6)" }}>
<div className="absolute inset-0 pointer-events-none" style={{ background:"linear-gradient(135deg,rgba(183,166,251,0.03),rgba(34,211,238,0.03))" }} />
<div className="grid grid-cols-1 lg:grid-cols-3 min-h-[500px]">
{/* Graph pane */}
<div className="lg:col-span-2 relative p-6 flex flex-col justify-between border-b lg:border-b-0 lg:border-r border-white/[0.08]" style={{ background:"#08051a", backgroundImage:"radial-gradient(rgba(183,166,251,0.06) 1px,transparent 1px)", backgroundSize:"20px 20px" }}>
<div className="scan-ln" />
<div className="flex gap-2 z-10 relative flex-wrap">
{[["GRAPH_ID: 8X92","#B7A6FB",true],["LATENCY: 12ms","#94a3b8",false],["ENTROPY: 0.041","#22d3ee",false]].map(([label,color,pulse]) => (
<span key={label as string} className="px-2 py-1 rounded text-[10px] font-mono flex items-center gap-1.5" style={{ background:"rgba(255,255,255,0.04)", border:"1px solid rgba(255,255,255,0.08)", color:color as string }}>
{pulse && <span className="size-1.5 rounded-full animate-ping" style={{ background:color as string }} />}
{label}
</span>
))}
</div>
{/* Agent visualizer */}
<div className="flex-grow flex items-center justify-center relative py-8">
<div className="spin-s absolute" style={{ width:260, height:260, borderRadius:"50%", border:"1px solid rgba(183,166,251,0.2)" }} />
<div className="spin-r absolute" style={{ width:190, height:190, borderRadius:"50%", border:"1px dashed rgba(34,211,238,0.25)" }} />
<div className="absolute h-[2px] w-40" style={{ background:"linear-gradient(to right,#B7A6FB,#ffffff,#22d3ee)", boxShadow:"0 0 12px #B7A6FB" }} />
<div className="absolute flex items-center justify-center size-14 rounded-full font-bold z-10 -translate-x-20" style={{ background:"rgba(183,166,251,0.15)", border:"1.5px solid #B7A6FB", color:"#B7A6FB", boxShadow:"0 0 24px rgba(183,166,251,0.4)" }}>A</div>
<div className="absolute px-3 py-1 rounded-full text-[10px] font-mono text-white z-20" style={{ background:"rgba(0,0,0,0.8)", border:"1px solid rgba(255,255,255,0.2)", backdropFilter:"blur(8px)", top:"calc(50% - 60px)" }}>Consensus: 98%</div>
<div className="absolute flex items-center justify-center size-14 rounded-full font-bold z-10 translate-x-20" style={{ background:"rgba(34,211,238,0.15)", border:"1.5px solid #22d3ee", color:"#22d3ee", boxShadow:"0 0 24px rgba(34,211,238,0.4)" }}>B</div>
</div>
{/* Mini bars */}
<div className="h-16 w-full border-t border-white/[0.08] flex items-end justify-between gap-1 pt-3">
{[["#B7A6FB",0.2,16],["#B7A6FB",0.4,32],["#B7A6FB",0.3,24],["#B7A6FB",0.6,48],["#22d3ee",0.5,40],["#22d3ee",1.0,56],["#22d3ee",0.4,32],["#B7A6FB",1.0,48],["#B7A6FB",0.2,16]].map(([color,opacity,h],i) => (
<div key={i} className="w-1.5 rounded-sm" style={{ height:h as number, background:color as string, opacity:Number(opacity), boxShadow:Number(opacity)===1.0?`0 0 12px ${color}`:"none" }} />
))}
</div>
</div>
{/* Log pane */}
<div className="lg:col-span-1 p-6 flex flex-col" style={{ background:"#0c0919" }}>
<div className="flex items-center justify-between pb-4 mb-4" style={{ borderBottom:"1px solid rgba(255,255,255,0.08)" }}>
<h3 className="text-white font-bold text-sm">Negotiation Log</h3>
<span className="size-2 rounded-full bg-emerald-500" />
</div>
<NegotiationLog />
</div>
</div>
</div>
</div>
</section>
{/* Core Features */}
<section className="py-24 px-6" style={{ background:"#070312" }}>
<div className="max-w-6xl mx-auto">
<div className="mb-14">
<p className="text-[11px] font-mono text-[#B7A6FB] uppercase tracking-widest mb-3">Why negoT8</p>
<h2 className="text-4xl font-black text-white mb-4">Core Features</h2>
<p className="text-slate-400 max-w-2xl">Designed for the next generation of autonomous commerce. Pure logic, zero friction.</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5">
<FeatureCard icon="psychology" title="Autonomous Negotiations" desc="Agents settle complex disputes without human intervention using game-theoretic equilibrium models and learned preference profiles." tag="Algorithmic · Trustless" accent="#B7A6FB" />
<FeatureCard icon="groups" title="Multi-Party Resolution" desc="Scale beyond 1:1 interactions. Our mesh supports n-party consensus models for complex supply chain logistics and group expenses." tag="Scalable · N-Party" accent="#22d3ee" />
<FeatureCard icon="visibility" title="Explainable Reasoning" desc="Black boxes are for amateurs. Every decision is traced, logged, and audited for fairness. Full auditability on every round." tag="Transparent · Auditable" accent="#a78bfa" />
<FeatureCard icon="account_balance_wallet" title="Instant Settlement" desc="UPI and on-chain settlement in under 400ms. Funds move when consensus is reached — no waiting, no friction, no middlemen." tag="UPI · On-Chain" accent="#34d399" />
<FeatureCard icon="record_voice_over" title="Voice Interface" desc="ElevenLabs-powered voice summaries for every negotiation outcome. Your agent communicates in natural language." tag="ElevenLabs · TTS" accent="#fbbf24" />
<FeatureCard icon="send" title="Telegram Native" desc="Deploy agents directly from Telegram. Initiate negotiations, monitor live rounds, and receive settlements without leaving the app." tag="Telegram · Bot API" accent="#60a5fa" />
</div>
</div>
</section>
{/* CTA */}
<section className="py-24 px-6 border-t border-white/5" style={{ background:"linear-gradient(to bottom,#070312,#0f0b1a)" }}>
<div className="max-w-4xl mx-auto text-center">
<div className="relative inline-flex items-center justify-center mb-10">
<div className="absolute size-40 rounded-full" style={{ border:"1px solid rgba(183,166,251,0.1)", animation:"spinSlow 20s linear infinite" }} />
<div className="absolute size-24 rounded-full" style={{ border:"1px dashed rgba(34,211,238,0.15)", animation:"spinRev 15s linear infinite" }} />
<div className="size-16 rounded-2xl flex items-center justify-center" style={{ background:"rgba(183,166,251,0.12)", border:"1px solid rgba(183,166,251,0.25)" }}>
<Icon name="hub" className="text-3xl text-[#B7A6FB]" />
</div>
</div>
<h2 className="text-4xl md:text-5xl font-black text-white mb-5 leading-tight">Ready to join the mesh?</h2>
<p className="text-slate-400 mb-10 max-w-xl mx-auto leading-relaxed">Start deploying agents in minutes. Join thousands of developers building the autonomous agent economy.</p>
<div className="flex flex-col sm:flex-row justify-center items-center gap-4">
<Link href="/dashboard" className="flex items-center gap-2 px-8 py-3.5 rounded-lg font-bold text-[#070312] transition-all hover:scale-[1.03] hover:-translate-y-0.5" style={{ background:"#B7A6FB", boxShadow:"0 0 28px rgba(183,166,251,0.35)" }}>
<Icon name="dashboard" className="text-xl" />Open Dashboard
</Link>
<a href="https://t.me/" className="flex items-center gap-2 px-8 py-3.5 rounded-lg font-bold text-white border border-white/10 hover:border-white/30 hover:bg-white/5 transition-all" style={{ background:"rgba(34,159,217,0.12)" }}>
<Icon name="send" className="text-xl" />Connect on Telegram
</a>
</div>
</div>
</section>
{/* Footer */}
<footer className="py-8 px-6 border-t border-white/[0.08]" style={{ background:"#070312" }}>
<div className="max-w-7xl mx-auto flex flex-col items-center gap-4 text-center">
<div className="flex items-center gap-2">
<div className="size-6 rounded bg-gradient-to-br from-[#B7A6FB] to-[#22d3ee] flex items-center justify-center text-[#070312]"><Icon name="hub" className="text-xs" /></div>
<span className="text-white font-bold text-sm">negoT8</span>
</div>
<div className="flex gap-6">
{["Privacy","Terms","Status"].map((l) => <a key={l} href="#" className="text-xs text-slate-600 hover:text-white transition-colors">{l}</a>)}
</div>
<p className="text-xs text-slate-700 font-mono">© 2025 negoT8 Protocol. All systems nominal.</p>
</div>
</footer>
</div>
</>
);
}

View File

@@ -0,0 +1,351 @@
"use client";
import { useState } from "react";
import Sidebar from "@/components/Sidebar";
function Icon({ name, className = "" }: { name: string; className?: string }) {
return <span className={`material-symbols-outlined ${className}`}>{name}</span>;
}
const PERSONALITIES = [
{
key: "aggressive",
label: "Aggressive",
desc: "Direct, concise, and prioritizes speed over nuance. Best for rapid execution.",
icon: "bolt",
color: "text-red-400",
bg: "bg-red-500/10",
},
{
key: "empathetic",
label: "Empathetic",
desc: "Prioritizes rapport, tone matching, and emotional intelligence. Human-centric.",
icon: "favorite",
color: "text-[#B7A6FB]",
bg: "bg-[#B7A6FB]/20",
},
{
key: "analytical",
label: "Analytical",
desc: "Data-driven, cites sources, and avoids assumptions. Highly logical.",
icon: "query_stats",
color: "text-blue-400",
bg: "bg-blue-500/10",
},
{
key: "balanced",
label: "Balanced",
desc: "The default setting. Adaptable tone that shifts based on the query complexity.",
icon: "balance",
color: "text-emerald-400",
bg: "bg-green-500/10",
},
];
const VOICE_MODELS = [
"Adam (Deep Narration)",
"Bella (Soft & Professional)",
"Charlie (Energetic Male)",
"Dorothy (Warm & Friendly)",
];
export default function PreferencesPage() {
const [personality, setPersonality] = useState("empathetic");
const [voiceModel, setVoiceModel] = useState("Bella (Soft & Professional)");
const [showApiKey, setShowApiKey] = useState(false);
const [showWebhook, setShowWebhook] = useState(false);
const [savedToast, setSavedToast] = useState(false);
const handleSave = () => {
setSavedToast(true);
setTimeout(() => setSavedToast(false), 2500);
};
return (
<div className="flex h-screen w-full overflow-hidden bg-[#020105] text-slate-300 relative">
<div className="absolute inset-0 bg-grid-subtle opacity-20 pointer-events-none" />
<Sidebar />
<main className="flex-1 flex flex-col h-full overflow-hidden relative z-10">
{/* Top bar */}
<header className="h-16 flex items-center justify-between px-6 bg-[#050505]/80 backdrop-blur-md border-b border-white/5 sticky top-0 z-30 shrink-0">
<div>
<h2 className="text-base font-medium text-white tracking-tight">Settings</h2>
<p className="text-[10px] text-slate-600 mt-0.5">
negoT8 <span className="text-[#B7A6FB]/60">| Preferences</span>
</p>
</div>
<div className="flex items-center gap-3">
<button className="size-9 rounded-lg bg-[#B7A6FB]/10 border border-[#B7A6FB]/20 text-[#B7A6FB] flex items-center justify-center hover:bg-[#B7A6FB]/20 transition-colors">
<Icon name="notifications" className="text-lg" />
</button>
<div className="size-9 rounded-full bg-gradient-to-tr from-[#B7A6FB] to-purple-500 flex items-center justify-center font-bold text-[#020105] text-sm">
JD
</div>
</div>
</header>
<div className="flex-1 overflow-y-auto">
<div className="max-w-[1100px] mx-auto p-6 grid grid-cols-1 lg:grid-cols-12 gap-8">
{/* Settings sidebar */}
<aside className="lg:col-span-3 flex flex-col gap-2">
<nav className="flex flex-col gap-1">
{[
{ icon: "person_outline", label: "User Preferences", active: true },
{ icon: "smart_toy", label: "Agent Clusters", active: false },
{ icon: "account_balance_wallet", label: "Billing & Credits", active: false },
{ icon: "shield", label: "Security", active: false },
].map(({ icon, label, active }) => (
<button
key={label}
className={`flex items-center gap-3 px-4 py-3 rounded-lg text-sm transition-all text-left ${
active
? "bg-[#B7A6FB]/10 text-[#B7A6FB] font-medium"
: "text-slate-400 hover:bg-[#B7A6FB]/5 hover:text-[#B7A6FB]"
}`}
>
<Icon name={icon} className="text-lg" />
{label}
</button>
))}
</nav>
<div className="mt-6 p-4 rounded-xl glass-card">
<p className="text-[10px] uppercase tracking-widest text-[#B7A6FB]/50 font-bold mb-2">
Plan Status
</p>
<p className="text-sm font-medium text-white">Pro Developer</p>
<div className="w-full bg-white/10 h-1.5 rounded-full mt-3 overflow-hidden">
<div className="bg-[#B7A6FB] h-full w-[75%] shadow-[0_0_8px_#B7A6FB]" />
</div>
<p className="text-[11px] text-slate-500 mt-2">75% of monthly tokens used</p>
</div>
</aside>
{/* Main settings content */}
<div className="lg:col-span-9 flex flex-col gap-8">
{/* Agent Personality */}
<section className="flex flex-col gap-5">
<div className="border-b border-white/5 pb-3">
<h3 className="text-xl font-bold text-[#B7A6FB]">Agent Personality</h3>
<p className="text-slate-400 text-sm mt-1">
Define the behavioral tone for your primary AI interactions.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
{PERSONALITIES.map((p) => {
const isActive = personality === p.key;
return (
<button
key={p.key}
onClick={() => setPersonality(p.key)}
className={`rounded-xl p-5 text-left flex flex-col gap-3 transition-all relative overflow-hidden ${
isActive
? "border-2 border-[#B7A6FB] bg-[#B7A6FB]/10 shadow-[0_0_20px_rgba(183,166,251,0.15)]"
: "glass-card hover:border-[#B7A6FB]/30"
}`}
>
{isActive && (
<div className="absolute top-2 right-2 text-[#B7A6FB]">
<Icon name="check_circle" className="text-sm" />
</div>
)}
<div className={`size-11 rounded-lg flex items-center justify-center ${p.bg}`}>
<Icon name={p.icon} className={`${p.color} text-xl`} />
</div>
<div>
<h4 className="font-bold text-white text-sm">{p.label}</h4>
<p className="text-[11px] text-slate-400 mt-1 leading-relaxed">{p.desc}</p>
</div>
</button>
);
})}
</div>
</section>
{/* Voice Synthesis */}
<section className="flex flex-col gap-5">
<div className="border-b border-white/5 pb-3 flex justify-between items-end">
<div>
<h3 className="text-xl font-bold text-[#B7A6FB]">Voice Synthesis</h3>
<p className="text-slate-400 text-sm mt-1">
Configured via ElevenLabs integration for realistic speech.
</p>
</div>
<span className="text-[10px] font-bold uppercase tracking-widest px-2 py-1 bg-[#B7A6FB]/10 text-[#B7A6FB] rounded border border-[#B7A6FB]/20">
API Connected
</span>
</div>
<div className="glass-card rounded-xl p-5 flex flex-col md:flex-row gap-5 items-center">
<div className="flex-1 w-full">
<label className="block text-[10px] font-bold uppercase tracking-tighter text-slate-500 mb-2">
Voice Model Selection
</label>
<div className="relative">
<select
value={voiceModel}
onChange={(e) => setVoiceModel(e.target.value)}
className="w-full bg-black/40 border border-white/10 rounded-lg px-4 py-3 text-slate-200 focus:ring-2 focus:ring-[#B7A6FB]/30 focus:border-[#B7A6FB]/40 outline-none appearance-none cursor-pointer text-sm transition-all"
>
{VOICE_MODELS.map((m) => (
<option key={m}>{m}</option>
))}
</select>
<div className="absolute inset-y-0 right-3 flex items-center pointer-events-none text-slate-500">
<Icon name="expand_more" className="text-lg" />
</div>
</div>
</div>
<div className="flex items-center gap-4 w-full md:w-auto">
<button className="flex-1 md:flex-none flex items-center justify-center gap-2 px-6 py-3 bg-[#B7A6FB] text-[#020105] font-bold rounded-lg hover:brightness-110 transition-all text-sm">
<Icon name="play_circle" className="text-xl" />
Preview
</button>
{/* Waveform visualizer */}
<div className="h-12 w-32 glass-card rounded-lg flex items-center justify-center px-3">
<div className="flex items-center gap-1">
{[3, 6, 4, 8, 5, 7, 3].map((h, i) => (
<div
key={i}
className={`w-1 rounded-full ${i % 2 === 0 ? "bg-[#B7A6FB]/40 animate-pulse" : "bg-[#B7A6FB]"}`}
style={{ height: `${h * 3}px` }}
/>
))}
</div>
</div>
</div>
</div>
</section>
{/* Payments + Security */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Default UPI */}
<section className="flex flex-col gap-4">
<div className="border-b border-white/5 pb-3">
<h3 className="text-lg font-bold text-[#B7A6FB]">Default Payments</h3>
<p className="text-slate-400 text-sm mt-1">Linked UPI ID for automated settlement.</p>
</div>
<div className="glass-card rounded-xl p-4 flex flex-col gap-3">
<div className="flex items-center gap-3 p-3 bg-black/40 rounded-lg border border-white/10">
<div className="text-[#B7A6FB] bg-[#B7A6FB]/10 p-2 rounded">
<Icon name="account_balance" className="text-lg" />
</div>
<div className="flex-1 min-w-0">
<p className="text-[9px] text-slate-500 font-bold uppercase">Linked UPI</p>
<p className="text-sm font-medium text-slate-200 font-mono truncate">
negot8-hq@okaxis
</p>
</div>
<button className="text-slate-500 hover:text-[#B7A6FB] transition-colors">
<Icon name="edit" className="text-xl" />
</button>
</div>
<button className="w-full py-2 border border-dashed border-[#B7A6FB]/20 rounded-lg text-xs font-bold text-[#B7A6FB]/60 hover:bg-[#B7A6FB]/5 hover:border-[#B7A6FB]/40 hover:text-[#B7A6FB] transition-all tracking-widest">
+ ADD SECONDARY METHOD
</button>
</div>
</section>
{/* Security / API Keys */}
<section className="flex flex-col gap-4">
<div className="border-b border-white/5 pb-3 flex justify-between items-center">
<div>
<h3 className="text-lg font-bold text-[#B7A6FB]">Security</h3>
<p className="text-slate-400 text-sm mt-1">Manage environment access.</p>
</div>
<button className="text-[#B7A6FB] text-xs font-bold hover:underline transition-all">
Revoke All
</button>
</div>
<div className="glass-card rounded-xl p-4 flex flex-col gap-4">
{/* Production API Key */}
<div>
<label className="text-[9px] font-bold text-slate-500 uppercase tracking-tighter block mb-1.5">
Production API Key
</label>
<div className="flex items-center gap-2 bg-black/40 border border-white/10 rounded-lg px-3 py-2">
<input
type={showApiKey ? "text" : "password"}
readOnly
value="sk_mesh_live_483299283749"
className="bg-transparent border-none focus:ring-0 text-sm text-slate-300 flex-1 font-mono outline-none min-w-0"
/>
<button
onClick={() => setShowApiKey((v) => !v)}
className="text-slate-500 hover:text-[#B7A6FB] transition-colors shrink-0"
>
<Icon name={showApiKey ? "visibility_off" : "visibility"} className="text-lg" />
</button>
<button className="text-slate-500 hover:text-[#B7A6FB] transition-colors shrink-0">
<Icon name="content_copy" className="text-lg" />
</button>
</div>
</div>
{/* Webhook Secret */}
<div>
<label className="text-[9px] font-bold text-slate-500 uppercase tracking-tighter block mb-1.5">
Webhook Secret
</label>
<div className="flex items-center gap-2 bg-black/40 border border-white/10 rounded-lg px-3 py-2">
<input
type={showWebhook ? "text" : "password"}
readOnly
value="wh_mesh_123456"
className="bg-transparent border-none focus:ring-0 text-sm text-slate-300 flex-1 font-mono outline-none min-w-0"
/>
<button
onClick={() => setShowWebhook((v) => !v)}
className="text-slate-500 hover:text-[#B7A6FB] transition-colors shrink-0"
>
<Icon name={showWebhook ? "visibility_off" : "visibility"} className="text-lg" />
</button>
<button className="text-slate-500 hover:text-[#B7A6FB] transition-colors shrink-0">
<Icon name="content_copy" className="text-lg" />
</button>
</div>
</div>
</div>
</section>
</div>
{/* Footer actions */}
<div className="flex items-center justify-end gap-4 pt-4 border-t border-white/5">
<button className="px-6 py-2.5 rounded-lg font-bold text-slate-400 hover:text-slate-200 transition-all text-sm">
Discard Changes
</button>
<button
onClick={handleSave}
className="px-8 py-2.5 rounded-lg bg-[#B7A6FB] text-[#020105] font-bold shadow-[0_0_20px_rgba(183,166,251,0.2)] hover:scale-[1.02] transition-all text-sm"
>
Save Preferences
</button>
</div>
</div>
</div>
{/* Footer */}
<footer className="border-t border-white/5 py-6 px-6 mt-4">
<div className="max-w-[1100px] mx-auto flex flex-col md:flex-row justify-between items-center gap-4">
<div className="flex items-center gap-2 text-slate-600">
<Icon name="verified_user" className="text-lg" />
<span className="text-xs">End-to-end encryption active for all preference data.</span>
</div>
<div className="flex gap-6 text-xs text-slate-500">
<button className="hover:text-[#B7A6FB] transition-colors">Privacy Policy</button>
<button className="hover:text-[#B7A6FB] transition-colors">Terms of Mesh</button>
<span className="text-emerald-400">Status: Operational</span>
</div>
</div>
</footer>
</div>
</main>
{/* Save toast */}
{savedToast && (
<div className="fixed bottom-6 right-6 z-50 flex items-center gap-2 px-4 py-3 bg-[#B7A6FB] text-[#020105] rounded-xl font-bold text-sm shadow-[0_0_30px_rgba(183,166,251,0.4)] animate-pulse">
<Icon name="check_circle" className="text-lg" />
Preferences saved
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,188 @@
"use client";
import Link from "next/link";
import Sidebar from "@/components/Sidebar";
function Icon({ name, className = "" }: { name: string; className?: string }) {
return <span className={`material-symbols-outlined ${className}`}>{name}</span>;
}
function StatChip({ label, value, icon }: { label: string; value: string; icon: string }) {
return (
<div className="flex flex-col items-center justify-center p-4 rounded-xl border border-white/[0.06] text-center gap-1" style={{ background: "#0f0a1f" }}>
<Icon name={icon} className="text-[#B7A6FB] text-xl mb-1" />
<span className="text-xl font-black text-white">{value}</span>
<span className="text-[10px] text-slate-500 uppercase tracking-wider font-mono">{label}</span>
</div>
);
}
function ActionBtn({ icon, label, sub }: { icon: string; label: string; sub: string }) {
return (
<button className="flex items-center gap-3 rounded-xl p-4 border border-white/[0.06] hover:border-[#B7A6FB]/30 hover:bg-[#B7A6FB]/5 transition-all text-left group" style={{ background: "#0f0a1f" }}>
<Icon name={icon} className="text-[#B7A6FB] text-2xl group-hover:scale-110 transition-transform" />
<div>
<p className="text-white font-medium text-sm">{label}</p>
<p className="text-slate-500 text-xs">{sub}</p>
</div>
</button>
);
}
export default function ProfilePage() {
const displayName = "Anirban Basak";
const initials = "AB";
const username = "@anirbanbasak";
const telegramId = "#7291048";
const personality = "Balanced";
const voiceId = "tHnMa72bKS";
const joinedAt = "January 12, 2026";
const accountRows = [
{ label: "Display Name", value: displayName },
{ label: "Telegram Handle", value: username },
{ label: "Telegram ID", value: telegramId },
{ label: "Personality", value: personality },
{ label: "Member Since", value: joinedAt },
];
return (
<div className="flex h-screen w-full overflow-hidden bg-[#020105] text-slate-300 relative">
{/* grid bg */}
<div
className="absolute inset-0 pointer-events-none"
style={{
backgroundImage:
"linear-gradient(rgba(183,166,251,0.03) 1px,transparent 1px),linear-gradient(90deg,rgba(183,166,251,0.03) 1px,transparent 1px)",
backgroundSize: "40px 40px",
}}
/>
<Sidebar />
<main className="flex-1 flex flex-col h-full overflow-hidden relative z-10">
{/* Top bar */}
<header className="h-14 flex items-center justify-between px-6 bg-[#020105]/90 backdrop-blur-md border-b border-white/[0.06] sticky top-0 z-30 shrink-0">
<div className="flex items-center gap-3">
<Link href="/dashboard" className="p-1.5 rounded-lg text-slate-500 hover:text-white hover:bg-white/5 transition-all">
<Icon name="arrow_back" className="text-lg" />
</Link>
<h2 className="text-sm font-semibold text-white tracking-tight">User Profile</h2>
</div>
<div className="flex items-center gap-2">
<button className="size-8 flex items-center justify-center rounded-lg bg-white/[0.04] border border-white/[0.08] text-slate-500 hover:text-white hover:border-white/20 transition-all" title="Settings">
<Icon name="settings" className="text-base" />
</button>
<button className="size-8 flex items-center justify-center rounded-lg bg-white/[0.04] border border-white/[0.08] text-slate-500 hover:text-red-400 hover:border-red-400/20 transition-all" title="Logout">
<Icon name="logout" className="text-base" />
</button>
</div>
</header>
{/* Scrollable content */}
<div className="flex-1 overflow-y-auto p-6">
<div className="max-w-2xl mx-auto flex flex-col gap-6">
{/* Profile hero */}
<div
className="flex flex-col items-center gap-5 py-8 px-6 rounded-2xl border border-white/[0.06] relative overflow-hidden"
style={{ background: "rgba(183,166,251,0.04)" }}
>
<div className="absolute inset-0 bg-gradient-to-b from-[#B7A6FB]/5 to-transparent pointer-events-none" />
{/* Avatar */}
<div className="relative z-10">
<div
className="size-28 rounded-full border-2 border-[#B7A6FB] p-1"
style={{ boxShadow: "0 0 24px rgba(183,166,251,0.25)" }}
>
<div className="size-full rounded-full bg-gradient-to-br from-[#B7A6FB]/30 to-[#22d3ee]/20 flex items-center justify-center">
<span className="text-3xl font-black text-white">{initials}</span>
</div>
</div>
<div className="absolute bottom-1 right-1 size-5 bg-emerald-500 border-4 border-[#020105] rounded-full" />
</div>
<div className="flex flex-col items-center text-center z-10">
<h1 className="text-3xl font-bold text-white tracking-tight">{displayName}</h1>
<p className="text-[#B7A6FB] font-medium mt-1">{username}</p>
<p className="text-slate-500 text-sm mt-1">
Telegram ID: <span className="text-slate-400 font-mono">{telegramId}</span>
</p>
<div className="mt-3 flex items-center gap-1.5 px-3 py-1 rounded-full border border-emerald-500/20 bg-emerald-500/10 text-emerald-400 text-[11px] font-bold uppercase tracking-wider">
<span className="size-1.5 rounded-full bg-emerald-400 animate-pulse" />
Active
</div>
</div>
</div>
{/* Stats row */}
<div className="grid grid-cols-3 gap-3">
<StatChip icon="smart_toy" label="Persona" value={personality} />
<StatChip icon="record_voice_over" label="Voice ID" value={voiceId + "…"} />
<StatChip icon="bolt" label="Status" value="Online" />
</div>
{/* Account details */}
<div className="flex flex-col gap-3">
<h3 className="text-slate-100 text-sm font-semibold px-1">Account Details</h3>
<div
className="rounded-2xl border border-white/[0.06] p-5 flex flex-col gap-4"
style={{ background: "rgba(183,166,251,0.03)" }}
>
{accountRows.map(({ label, value }, i) => (
<div
key={label}
className={`flex justify-between items-center${i < accountRows.length - 1 ? " pb-4 border-b border-white/[0.05]" : ""}`}
>
<span className="text-slate-500 text-sm">{label}</span>
<span className="text-slate-100 font-medium text-sm font-mono">{value}</span>
</div>
))}
</div>
</div>
{/* Active agent */}
<div className="flex flex-col gap-3">
<h3 className="text-slate-100 text-sm font-semibold px-1">Active Agent Status</h3>
<div
className="rounded-2xl border border-white/[0.06] p-5 flex items-center justify-between"
style={{ background: "rgba(183,166,251,0.03)" }}
>
<div className="flex items-center gap-4">
<div className="size-10 rounded-lg bg-[#B7A6FB]/15 flex items-center justify-center text-[#B7A6FB]">
<Icon name="smart_toy" className="text-xl" />
</div>
<div>
<p className="text-slate-100 font-medium text-sm">Mesh-Core-Alpha</p>
<p className="text-slate-500 text-xs">Primary Computing Node</p>
</div>
</div>
<div className="px-3 py-1 rounded-full border border-emerald-500/20 bg-emerald-500/10 text-emerald-400 text-[11px] font-bold uppercase tracking-wider">
Operational
</div>
</div>
</div>
{/* Quick actions */}
<div className="flex flex-col gap-3">
<h3 className="text-slate-100 text-sm font-semibold px-1">Quick Actions</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<ActionBtn icon="edit_square" label="Edit Profile" sub="Update your information" />
<ActionBtn icon="shield_lock" label="Security Settings" sub="Manage 2FA and keys" />
<ActionBtn icon="notifications" label="Notifications" sub="Alert preferences" />
<ActionBtn icon="link" label="Connected Apps" sub="Telegram, UPI & more" />
</div>
</div>
{/* Footer */}
<div className="flex justify-center py-6">
<p className="text-slate-700 text-xs font-mono">negoT8 v2.0.0 © 2026</p>
</div>
</div>
</div>
</main>
</div>
);
}

View File

@@ -0,0 +1,71 @@
"use client";
import { ConcessionEntry } from "@/lib/types";
interface Props {
concessions: ConcessionEntry[];
}
export default function ConcessionTimeline({ concessions }: Props) {
if (!concessions || concessions.length === 0) {
return (
<div className="text-xs text-slate-600 py-4 text-center font-mono">
No concessions recorded yet
</div>
);
}
return (
<div className="relative flex flex-col gap-3">
{/* Data stream line */}
<div className="absolute left-[14px] top-4 bottom-4 data-stream-line" />
{concessions.map((c, i) => {
const isA = c.by === "A";
return (
<div key={i} className="flex items-center gap-3">
{/* Node */}
<div
className={`relative z-10 size-7 rounded-full shrink-0 flex items-center justify-center text-[9px] font-mono font-bold border ${
isA
? "bg-[#B7A6FB]/10 border-[#B7A6FB]/30 text-[#B7A6FB]"
: "bg-cyan-900/20 border-cyan-500/30 text-cyan-400"
}`}
>
{c.by}
</div>
{/* Card */}
<div
className={`flex-1 flex items-center justify-between gap-3 px-3 py-2 rounded-lg border backdrop-blur-sm text-xs ${
isA
? "bg-[#B7A6FB]/5 border-[#B7A6FB]/15"
: "bg-cyan-900/5 border-cyan-500/15"
}`}
>
<div className="flex items-center gap-2 min-w-0">
<span
className={`text-[9px] font-mono px-1.5 py-0.5 rounded border shrink-0 ${
isA
? "text-[#B7A6FB] bg-[#B7A6FB]/10 border-[#B7A6FB]/20"
: "text-cyan-400 bg-cyan-500/10 border-cyan-500/20"
}`}
>
Agent {c.by}
</span>
<span className="material-symbols-outlined text-slate-600 text-sm">arrow_forward</span>
<span className="text-slate-300 truncate">{c.gave_up}</span>
</div>
<span className="text-[9px] text-slate-600 font-mono shrink-0">Rd {c.round}</span>
</div>
</div>
);
})}
</div>
);
}
interface Props {
concessions: ConcessionEntry[];
}

View File

@@ -0,0 +1,65 @@
"use client";
interface Props {
score: number;
satA?: number;
satB?: number;
}
export default function FairnessScore({ score, satA, satB }: Props) {
const pct = Math.min(100, Math.max(0, score));
const color =
pct >= 80 ? "text-[#B7A6FB]" : pct >= 60 ? "text-amber-400" : "text-red-400";
const barColor =
pct >= 80
? "bg-[#B7A6FB] shadow-[0_0_8px_#B7A6FB]"
: pct >= 60
? "bg-amber-400"
: "bg-red-400";
return (
<div className="space-y-4">
{/* Big score */}
<div className="flex items-end justify-between">
<div>
<p className="text-[10px] text-slate-500 font-mono uppercase tracking-wider mb-1">Fairness Score</p>
<span className={`text-4xl font-light tabular-nums ${color} text-glow`}>
{pct.toFixed(0)}
</span>
<span className="text-base font-light text-slate-600 ml-1">/100</span>
</div>
<div className={`text-[10px] font-bold font-mono px-2 py-1 rounded border ${
pct >= 80
? "text-[#B7A6FB] bg-[#B7A6FB]/10 border-[#B7A6FB]/20"
: pct >= 60
? "text-amber-400 bg-amber-500/10 border-amber-500/20"
: "text-red-400 bg-red-500/10 border-red-500/20"
}`}>
{pct >= 80 ? "FAIR" : pct >= 60 ? "MODERATE" : "SKEWED"}
</div>
</div>
{/* Progress bar */}
<div className="w-full h-1.5 bg-white/5 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-700 ${barColor}`}
style={{ width: `${pct}%` }}
/>
</div>
{/* Per-agent */}
{satA !== undefined && satB !== undefined && (
<div className="grid grid-cols-2 gap-3">
<div className="bg-[#B7A6FB]/5 border border-[#B7A6FB]/20 rounded-lg p-3 text-center hover:border-[#B7A6FB]/40 transition-colors">
<div className="text-[10px] text-slate-500 font-mono mb-1">Agent A</div>
<div className="text-xl font-light text-[#B7A6FB] tabular-nums">{satA.toFixed(0)}%</div>
</div>
<div className="bg-cyan-900/10 border border-cyan-500/20 rounded-lg p-3 text-center hover:border-cyan-500/40 transition-colors">
<div className="text-[10px] text-slate-500 font-mono mb-1">Agent B</div>
<div className="text-xl font-light text-cyan-400 tabular-nums">{satB.toFixed(0)}%</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,161 @@
"use client";
import { Round, Participant, Personality } from "@/lib/types";
import { PERSONALITY_LABELS } from "@/lib/utils";
interface Props {
rounds: Round[];
participants: Participant[];
}
function Icon({ name, className = "" }: { name: string; className?: string }) {
return <span className={`material-symbols-outlined ${className}`}>{name}</span>;
}
const ACTION_ICON: Record<string, string> = {
propose: "send",
counter: "swap_horiz",
accept: "check_circle",
escalate: "warning",
};
const ACTION_LABEL: Record<string, string> = {
propose: "Proposed",
counter: "Counter",
accept: "Accepted",
escalate: "Escalated",
};
const ACTION_BADGE: Record<string, string> = {
propose: "text-[#B7A6FB] bg-[#B7A6FB]/10 border-[#B7A6FB]/20",
counter: "text-slate-300 bg-white/5 border-white/10",
accept: "text-emerald-400 bg-emerald-500/10 border-emerald-500/20",
escalate: "text-amber-400 bg-amber-500/10 border-amber-500/20",
};
export default function NegotiationTimeline({ rounds, participants }: Props) {
if (!rounds || rounds.length === 0) {
return (
<div className="text-xs text-slate-600 py-6 text-center font-mono">
Negotiation hasn&apos;t started yet
</div>
);
}
const userA = participants?.[0];
const userB = participants?.[1];
function agentLabel(proposerId: number) {
if (userA && proposerId === userA.user_id) return "A";
if (userB && proposerId === userB.user_id) return "B";
return "?";
}
function agentPersonality(proposerId: number): Personality {
const p = participants?.find((p) => p.user_id === proposerId);
return (p?.personality_used ?? "balanced") as Personality;
}
return (
<div className="relative flex flex-col gap-6">
{/* Vertical data-stream line */}
<div className="absolute left-[18px] top-6 bottom-6 data-stream-line" />
{rounds.map((round, idx) => {
const label = agentLabel(round.proposer_id);
const personality = agentPersonality(round.proposer_id);
const action = round.response_type ?? "propose";
const isA = label === "A";
const isLast = idx === rounds.length - 1;
return (
<div key={round.id} className="flex gap-4 group">
{/* Node */}
<div className="relative z-10 flex flex-col items-center shrink-0">
<div
className={`size-9 rounded-full flex items-center justify-center text-[10px] font-mono font-bold transition-all ${
isLast
? isA
? "bg-[#B7A6FB] text-black shadow-[0_0_12px_#B7A6FB]"
: "bg-cyan-400 text-black shadow-[0_0_12px_#22d3ee]"
: "bg-[#070312] border border-white/10 text-slate-500"
}`}
>
{String(round.round_number).padStart(2, "0")}
</div>
</div>
{/* Content */}
<div className="flex-1 pb-2">
<div className="flex items-center gap-2 mb-2">
<span
className={`text-xs font-bold ${isA ? "text-[#B7A6FB]" : "text-cyan-400"}`}
>
Agent {label}
</span>
<span
className={`text-[9px] font-mono px-1.5 py-0.5 rounded border uppercase tracking-wide ${ACTION_BADGE[action] ?? ACTION_BADGE.propose}`}
>
<Icon name={ACTION_ICON[action] ?? "send"} className="text-[11px] align-middle mr-0.5" />
{ACTION_LABEL[action] ?? action}
</span>
<span className="text-[9px] text-slate-600 font-mono">{PERSONALITY_LABELS[personality]}</span>
</div>
<div
className={`rounded-lg p-3.5 text-sm backdrop-blur-sm border ${
isLast
? isA
? "bg-[#B7A6FB]/5 border-[#B7A6FB]/20"
: "bg-cyan-900/10 border-cyan-500/20"
: "bg-[rgba(7,3,18,0.4)] border-white/5"
}`}
>
{round.reasoning && (
<p className="text-slate-300 text-xs leading-relaxed font-light mb-2">
{round.reasoning}
</p>
)}
{round.proposal && typeof round.proposal === "object" && (
<ProposalSnippet proposal={round.proposal as Record<string, unknown>} />
)}
{/* Satisfaction */}
<div className="flex items-center gap-4 mt-2 pt-2 border-t border-white/5">
<div className="flex items-center gap-1.5">
<span className="text-[9px] text-slate-600 font-mono uppercase">Sat A</span>
<span className={`text-[10px] font-mono font-bold ${(round.satisfaction_a ?? 0) >= 70 ? "text-[#B7A6FB]" : "text-red-400"}`}>
{round.satisfaction_a?.toFixed(0) ?? "—"}%
</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-[9px] text-slate-600 font-mono uppercase">Sat B</span>
<span className={`text-[10px] font-mono font-bold ${(round.satisfaction_b ?? 0) >= 70 ? "text-cyan-400" : "text-red-400"}`}>
{round.satisfaction_b?.toFixed(0) ?? "—"}%
</span>
</div>
{round.concessions_made?.length > 0 && (
<span className="text-[9px] font-mono text-amber-400 border border-amber-500/20 bg-amber-500/10 px-1.5 py-0.5 rounded uppercase ml-auto">
Concession
</span>
)}
</div>
</div>
</div>
</div>
);
})}
</div>
);
}
function ProposalSnippet({ proposal }: { proposal: Record<string, unknown> }) {
const summary =
(proposal.summary as string) ?? (proposal.for_party_a as string) ?? null;
if (!summary) return null;
return (
<div className="code-snippet p-2 text-[10px] text-slate-400 leading-relaxed mt-1">
{summary}
</div>
);
}

View File

@@ -0,0 +1,184 @@
"use client";
import { Negotiation } from "@/lib/types";
import { FEATURE_LABELS } from "@/lib/utils";
function Icon({ name, className = "" }: { name: string; className?: string }) {
return <span className={`material-symbols-outlined ${className}`}>{name}</span>;
}
interface Props {
negotiation: Negotiation;
}
export default function ResolutionCard({ negotiation }: Props) {
const resolution = negotiation.resolution as Record<string, unknown> | null;
const status = negotiation.status;
const analytics = negotiation.analytics;
if (!resolution && status !== "resolved" && status !== "escalated") {
return (
<div className="text-xs text-slate-600 py-4 text-center font-mono">
Resolution not yet available
</div>
);
}
const final = (resolution?.final_proposal ?? {}) as Record<string, unknown>;
const details = (final.details ?? {}) as Record<string, unknown>;
const roundsTaken = resolution?.rounds_taken as number | undefined;
const summaryText = String(final.summary ?? resolution?.summary ?? "");
const forPartyA = final.for_party_a ? String(final.for_party_a) : null;
const forPartyB = final.for_party_b ? String(final.for_party_b) : null;
const upiLink = (details.upi_link ?? details.upilink)
? String(details.upi_link ?? details.upilink)
: null;
const isResolved = status === "resolved";
return (
<div className="space-y-5">
{/* Hero card — matches Stitch resolution screen */}
<div
className={`relative rounded-xl border overflow-hidden ${
isResolved
? "border-emerald-500/30 bg-emerald-900/5"
: "border-amber-500/30 bg-amber-900/5"
}`}
>
{/* Top gradient line */}
<div
className={`absolute top-0 left-0 w-full h-px bg-gradient-to-r from-transparent ${
isResolved ? "via-emerald-400" : "via-amber-400"
} to-transparent opacity-60`}
/>
<div className="p-5">
<div className="flex items-center gap-3 mb-3">
<div
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full border text-[10px] font-bold uppercase tracking-wider ${
isResolved
? "bg-emerald-500/10 border-emerald-500/20 text-emerald-400"
: "bg-amber-500/10 border-amber-500/20 text-amber-400"
}`}
>
<Icon name={isResolved ? "check_circle" : "warning"} className="text-sm" />
{isResolved ? "Resolved" : "Escalated"}
</div>
<span className="text-[10px] text-slate-600 font-mono">
{FEATURE_LABELS[negotiation.feature_type]}
{roundsTaken ? ` · ${roundsTaken} round${roundsTaken > 1 ? "s" : ""}` : ""}
</span>
</div>
{summaryText && (
<p className="text-sm text-slate-300 leading-relaxed font-light">{summaryText}</p>
)}
</div>
</div>
{/* Stats row */}
{analytics && (
<div className="grid grid-cols-3 gap-3">
<MetricBox label="Fairness" value={`${analytics.fairness_score?.toFixed(0)}%`} highlight />
<MetricBox label="Total Rounds" value={String(roundsTaken ?? "—")} />
<MetricBox
label="Concessions"
value={`${analytics.total_concessions_a + analytics.total_concessions_b}`}
/>
</div>
)}
{/* Per-party outcomes */}
{(forPartyA || forPartyB) && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{forPartyA && (
<div className="bg-[#B7A6FB]/5 border border-[#B7A6FB]/20 rounded-lg p-3 hover:border-[#B7A6FB]/40 transition-colors">
<div className="text-[10px] font-mono text-[#B7A6FB] mb-2 uppercase tracking-wider">Agent A</div>
<p className="text-xs text-slate-300 leading-relaxed">{forPartyA}</p>
</div>
)}
{forPartyB && (
<div className="bg-cyan-900/10 border border-cyan-500/20 rounded-lg p-3 hover:border-cyan-500/40 transition-colors">
<div className="text-[10px] font-mono text-cyan-400 mb-2 uppercase tracking-wider">Agent B</div>
<p className="text-xs text-slate-300 leading-relaxed">{forPartyB}</p>
</div>
)}
</div>
)}
{/* Action buttons */}
<div className="grid grid-cols-2 gap-3">
{upiLink && (
<a
href={upiLink}
target="_blank"
rel="noopener noreferrer"
className="group relative flex items-center gap-3 p-4 rounded-xl border border-[#222249] bg-[#101023] hover:border-[#B7A6FB]/40 transition-all overflow-hidden text-left"
>
<div className="p-2 rounded-lg bg-indigo-500/20 text-indigo-300 group-hover:bg-indigo-500/30 transition-colors">
<Icon name="credit_card" className="text-lg" />
</div>
<div>
<div className="text-white text-sm font-bold">Pay via UPI</div>
<div className="text-slate-500 text-[10px]">Instant settlement</div>
</div>
<Icon name="arrow_forward" className="ml-auto text-[#B7A6FB] text-base opacity-0 group-hover:opacity-100 transition-opacity" />
</a>
)}
<button className="group relative flex items-center gap-3 p-4 rounded-xl border border-[#222249] bg-[#101023] hover:border-[#B7A6FB]/40 transition-all overflow-hidden text-left">
<div className="p-2 rounded-lg bg-sky-500/20 text-sky-300 group-hover:bg-sky-500/30 transition-colors">
<Icon name="chat" className="text-lg" />
</div>
<div>
<div className="text-white text-sm font-bold">Telegram</div>
<div className="text-slate-500 text-[10px]">Open channel</div>
</div>
<Icon name="arrow_forward" className="ml-auto text-[#B7A6FB] text-base opacity-0 group-hover:opacity-100 transition-opacity" />
</button>
<button className="group relative flex items-center gap-3 p-4 rounded-xl border border-[#222249] bg-[#101023] hover:border-[#B7A6FB]/40 transition-all overflow-hidden text-left">
<div className="p-2 rounded-lg bg-orange-500/20 text-orange-300 group-hover:bg-orange-500/30 transition-colors">
<Icon name="picture_as_pdf" className="text-lg" />
</div>
<div>
<div className="text-white text-sm font-bold">Download PDF</div>
<div className="text-slate-500 text-[10px]">Full transcript</div>
</div>
<Icon name="download" className="ml-auto text-[#B7A6FB] text-base opacity-0 group-hover:opacity-100 transition-opacity" />
</button>
<button className="group relative flex items-center gap-3 p-4 rounded-xl border border-[#222249] bg-[#101023] hover:border-[#B7A6FB]/40 transition-all overflow-hidden text-left">
<div className="p-2 rounded-lg bg-pink-500/20 text-pink-300 group-hover:bg-pink-500/30 transition-colors">
<Icon name="graphic_eq" className="text-lg" />
</div>
<div>
<div className="text-white text-sm font-bold">Voice Summary</div>
<div className="text-slate-500 text-[10px]">AI generated audio</div>
</div>
<Icon name="play_arrow" className="ml-auto text-[#B7A6FB] text-base opacity-0 group-hover:opacity-100 transition-opacity" />
</button>
</div>
</div>
);
}
function MetricBox({
label,
value,
highlight = false,
}: {
label: string;
value: string;
highlight?: boolean;
}) {
return (
<div className="flex flex-col gap-1 p-3 rounded-lg bg-white/5 border border-white/10 hover:border-[#B7A6FB]/30 transition-colors">
<span className="text-slate-500 text-[10px] font-mono">{label}</span>
<span className={`text-xl font-light tabular-nums ${highlight ? "text-[#B7A6FB]" : "text-white"}`}>
{value}
</span>
</div>
);
}
interface Props {
negotiation: Negotiation;
}

View File

@@ -0,0 +1,96 @@
"use client";
import { SatisfactionPoint } from "@/lib/types";
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
interface Props {
data: SatisfactionPoint[];
}
export default function SatisfactionChart({ data }: Props) {
if (!data || data.length === 0) {
return (
<div className="flex items-center justify-center h-48 text-slate-600 text-xs font-mono">
No satisfaction data yet
</div>
);
}
return (
<div className="relative">
{/* Legend */}
<div className="flex gap-4 justify-end mb-3 text-[10px] font-mono">
<div className="flex items-center gap-1.5">
<span className="size-1.5 rounded-full bg-[#B7A6FB] shadow-[0_0_5px_#B7A6FB]" />
<span className="text-slate-400">Agent A</span>
</div>
<div className="flex items-center gap-1.5">
<span className="size-1.5 rounded-full bg-cyan-400 shadow-[0_0_5px_#22d3ee]" />
<span className="text-slate-400">Agent B</span>
</div>
</div>
<ResponsiveContainer width="100%" height={200}>
<LineChart data={data} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.04)" />
<XAxis
dataKey="round"
tick={{ fill: "#475569", fontSize: 10, fontFamily: "JetBrains Mono" }}
axisLine={{ stroke: "rgba(255,255,255,0.05)" }}
tickLine={false}
/>
<YAxis
domain={[0, 100]}
tick={{ fill: "#475569", fontSize: 10, fontFamily: "JetBrains Mono" }}
axisLine={false}
tickLine={false}
tickFormatter={(v) => `${v}%`}
/>
<Tooltip
contentStyle={{
background: "rgba(7, 3, 18, 0.9)",
border: "1px solid rgba(183, 166, 251, 0.2)",
borderRadius: 8,
backdropFilter: "blur(12px)",
}}
labelStyle={{ color: "#94a3b8", fontFamily: "JetBrains Mono", fontSize: 10 }}
itemStyle={{ color: "#e2e8f0", fontFamily: "JetBrains Mono", fontSize: 11 }}
formatter={(value: number | undefined) => [`${(value ?? 0).toFixed(0)}%`]}
/>
<Line
type="monotone"
dataKey="score_a"
stroke="#B7A6FB"
strokeWidth={1.5}
dot={{ fill: "#000", stroke: "#B7A6FB", strokeWidth: 1, r: 2 }}
activeDot={{ r: 4, fill: "#B7A6FB", stroke: "white", strokeWidth: 1 }}
name="Agent A"
/>
<Line
type="monotone"
dataKey="score_b"
stroke="#22d3ee"
strokeWidth={1.5}
dot={{ fill: "#000", stroke: "#22d3ee", strokeWidth: 1, r: 2 }}
activeDot={{ r: 4, fill: "#22d3ee", stroke: "white", strokeWidth: 1 }}
name="Agent B"
/>
</LineChart>
</ResponsiveContainer>
</div>
);
}
interface Props {
data: SatisfactionPoint[];
}

View File

@@ -0,0 +1,87 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
function Icon({ name, className = "" }: { name: string; className?: string }) {
return (
<span className={`material-symbols-outlined ${className}`}>{name}</span>
);
}
const NAV_ITEMS = [
{ icon: "dashboard", label: "Dashboard", href: "/dashboard" },
{ icon: "history", label: "History", href: "/history" },
{ icon: "analytics", label: "Analytics", href: "/analytics" },
{ icon: "settings", label: "Preferences", href: "/preferences" },
];
export default function Sidebar() {
const pathname = usePathname();
return (
<aside className="hidden md:flex w-60 flex-col bg-black/40 backdrop-blur-xl border-r border-white/5 relative z-20 shrink-0">
{/* Logo */}
<div className="flex h-16 items-center gap-3 px-5 border-b border-white/5">
<div className="flex items-center justify-center size-8 rounded-lg bg-[#B7A6FB]/10 border border-[#B7A6FB]/20 text-[#B7A6FB]">
<Icon name="hub" className="text-xl" />
</div>
<div>
<h1 className="text-white text-sm font-bold tracking-tight">
Agent<span className="text-[#B7A6FB] font-light">Mesh</span>
</h1>
<p className="text-[10px] text-slate-600 font-mono">v2.4.0</p>
</div>
</div>
{/* Nav */}
<nav className="flex flex-col gap-1 p-3 flex-1">
{NAV_ITEMS.map(({ icon, label, href }) => {
const isActive =
href === "/dashboard" ? pathname === "/dashboard" : pathname.startsWith(href);
return (
<Link
key={href}
href={href}
className={`group flex items-center gap-3 px-3 py-2 rounded-lg transition-all text-sm ${
isActive
? "bg-white/5 border border-white/10 text-white"
: "text-slate-500 hover:text-white hover:bg-white/5"
}`}
>
<Icon
name={icon}
className={`text-[18px] ${
isActive
? "text-[#B7A6FB]"
: "group-hover:text-[#B7A6FB] transition-colors"
}`}
/>
<span className="font-medium">{label}</span>
</Link>
);
})}
</nav>
{/* System status footer */}
<div className="p-3 border-t border-white/5">
<div className="rounded-lg bg-gradient-to-b from-white/5 to-transparent border border-white/5 p-3 relative overflow-hidden">
<div className="absolute -right-4 -top-4 w-16 h-16 bg-[#B7A6FB]/10 blur-2xl rounded-full" />
<div className="flex items-center gap-2 mb-2 relative z-10">
<Icon name="bolt" className="text-[#B7A6FB] text-base" />
<span className="text-[10px] font-bold text-white tracking-wide">
System Status
</span>
</div>
<div className="w-full bg-white/10 h-0.5 rounded-full mb-2 overflow-hidden">
<div className="bg-[#B7A6FB] h-full w-3/4 shadow-[0_0_8px_#B7A6FB]" />
</div>
<p className="text-[9px] text-slate-500 font-mono">
LATENCY: <span className="text-[#B7A6FB]">12ms</span> ·{" "}
<span className="text-emerald-400">OPERATIONAL</span>
</p>
</div>
</div>
</aside>
);
}

View File

@@ -0,0 +1,19 @@
// Thin API client for the negoT8 FastAPI backend
import type { Negotiation, Stats } from "./types";
const BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000";
async function get<T>(path: string): Promise<T> {
const res = await fetch(`${BASE}${path}`, { cache: "no-store" });
if (!res.ok) throw new Error(`GET ${path}${res.status}`);
return res.json() as Promise<T>;
}
export const api = {
stats: () => get<Stats>("/api/stats"),
negotiations: () =>
get<{ negotiations: Negotiation[]; total: number }>("/api/negotiations"),
negotiation: (id: string) => get<Negotiation>(`/api/negotiations/${id}`),
analytics: (id: string) =>
get<Negotiation["analytics"]>(`/api/negotiations/${id}/analytics`),
};

View File

@@ -0,0 +1,37 @@
// Socket.IO client singleton — import this anywhere to get the shared socket
import { io, Socket } from "socket.io-client";
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8000";
let socket: Socket | null = null;
export function getSocket(): Socket {
if (!socket) {
socket = io(API_URL, {
transports: ["websocket", "polling"],
autoConnect: true,
reconnectionAttempts: 5,
reconnectionDelay: 1500,
});
socket.on("connect", () => {
console.log("[Socket.IO] connected:", socket?.id);
});
socket.on("disconnect", (reason) => {
console.log("[Socket.IO] disconnected:", reason);
});
socket.on("connect_error", (err) => {
console.warn("[Socket.IO] connection error:", err.message);
});
}
return socket;
}
export function joinNegotiation(negotiationId: string) {
getSocket().emit("join_negotiation", { negotiation_id: negotiationId });
}
export function leaveNegotiation(negotiationId: string) {
getSocket().emit("leave_negotiation", { negotiation_id: negotiationId });
}

View File

@@ -0,0 +1,104 @@
// Shared TypeScript types mirroring the backend data models
export interface User {
telegram_id: number;
username: string;
display_name: string;
personality: Personality;
voice_id: string;
created_at: string;
}
export type Personality =
| "aggressive"
| "people_pleaser"
| "analytical"
| "empathetic"
| "balanced";
export type NegotiationStatus = "pending" | "active" | "resolved" | "escalated";
export type FeatureType =
| "scheduling"
| "expenses"
| "freelance"
| "roommate"
| "trip"
| "marketplace"
| "collaborative"
| "conflict"
| "generic";
export interface Participant {
negotiation_id: string;
user_id: number;
preferences: Record<string, unknown>;
personality_used: Personality;
username?: string;
display_name?: string;
personality?: Personality;
voice_id?: string;
}
export interface Round {
id: number;
negotiation_id: string;
round_number: number;
proposer_id: number;
proposal: Record<string, unknown>;
response_type: "propose" | "counter" | "accept" | "escalate";
response: Record<string, unknown> | null;
reasoning: string;
satisfaction_a: number;
satisfaction_b: number;
concessions_made: string[];
created_at: string;
}
export interface SatisfactionPoint {
round: number;
score_a: number;
score_b: number;
}
export interface ConcessionEntry {
round: number;
by: "A" | "B";
gave_up: string;
}
export interface Analytics {
negotiation_id: string;
satisfaction_timeline: SatisfactionPoint[];
concession_log: ConcessionEntry[];
fairness_score: number;
total_concessions_a: number;
total_concessions_b: number;
computed_at: string;
}
export interface Negotiation {
id: string;
feature_type: FeatureType;
status: NegotiationStatus;
initiator_id: number;
resolution: Record<string, unknown> | null;
voice_summary_file: string | null;
created_at: string;
updated_at: string;
participant_count?: number;
// Only present in detail view
participants?: Participant[];
rounds?: Round[];
analytics?: Analytics;
}
export interface Stats {
total_negotiations: number;
resolved: number;
active: number;
escalated: number;
total_users: number;
avg_fairness_score: number;
feature_breakdown: { feature_type: FeatureType; c: number }[];
}

View File

@@ -0,0 +1,86 @@
// Shared UI helpers — badges, labels, colour maps
import type { FeatureType, NegotiationStatus, Personality } from "@/lib/types";
// ─── Feature ────────────────────────────────────────────────────────────────
export const FEATURE_LABELS: Record<FeatureType, string> = {
scheduling: "📅 Scheduling",
expenses: "💰 Expenses",
freelance: "💼 Freelance",
roommate: "🏠 Roommate",
trip: "✈️ Trip",
marketplace: "🛒 Marketplace",
collaborative: "🍕 Collaborative",
conflict: "⚖️ Conflict",
generic: "🤝 Generic",
};
// ─── Personality ─────────────────────────────────────────────────────────────
export const PERSONALITY_LABELS: Record<Personality, string> = {
aggressive: "😤 Aggressive",
people_pleaser: "🤝 Pleaser",
analytical: "📊 Analytical",
empathetic: "💚 Empathetic",
balanced: "⚖️ Balanced",
};
export const PERSONALITY_COLORS: Record<Personality, string> = {
aggressive: "bg-red-500/20 text-red-300 border-red-500/30",
people_pleaser: "bg-blue-500/20 text-blue-300 border-blue-500/30",
analytical: "bg-yellow-500/20 text-yellow-300 border-yellow-500/30",
empathetic: "bg-green-500/20 text-green-300 border-green-500/30",
balanced: "bg-purple-500/20 text-purple-300 border-purple-500/30",
};
// ─── Status ──────────────────────────────────────────────────────────────────
export const STATUS_LABELS: Record<NegotiationStatus, string> = {
pending: "⏳ Pending",
active: "🔄 Active",
resolved: "✅ Resolved",
escalated: "⚠️ Escalated",
};
export const STATUS_COLORS: Record<NegotiationStatus, string> = {
pending: "bg-zinc-500/20 text-zinc-400 border-zinc-500/30",
active: "bg-blue-500/20 text-blue-300 border-blue-500/30 animate-pulse",
resolved: "bg-green-500/20 text-green-300 border-green-500/30",
escalated: "bg-amber-500/20 text-amber-300 border-amber-500/30",
};
// ─── Fairness colour ─────────────────────────────────────────────────────────
export function fairnessColor(score: number): string {
if (score >= 80) return "text-green-400";
if (score >= 60) return "text-yellow-400";
return "text-red-400";
}
export function fairnessBarColor(score: number): string {
if (score >= 80) return "bg-green-500";
if (score >= 60) return "bg-yellow-500";
return "bg-red-500";
}
// ─── Satisfaction colour ─────────────────────────────────────────────────────
export function satColor(score: number): string {
if (score >= 70) return "text-green-400";
if (score >= 40) return "text-yellow-400";
return "text-red-400";
}
// ─── Time formatting ─────────────────────────────────────────────────────────
export function relativeTime(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const s = Math.floor(diff / 1000);
if (s < 60) return `${s}s ago`;
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
return `${Math.floor(h / 24)}d ago`;
}

View File

@@ -0,0 +1,14 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
async rewrites() {
return [
{
source: "/api/:path*",
destination: "http://localhost:8000/api/:path*",
},
];
},
};
export default nextConfig;

2195
negot8/dashboard/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,26 @@
{
"name": "dashboard",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"lucide-react": "^0.575.0",
"next": "16.1.6",
"react": "19.2.3",
"react-dom": "19.2.3",
"recharts": "^3.7.0",
"socket.io-client": "^4.8.3"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"tailwindcss": "^4",
"typescript": "^5"
}
}

View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}

File diff suppressed because it is too large Load Diff

1595
negot8/docs/milestone.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,446 @@
# negoT8 — Blockchain Integration Guide
> **Invisible to users. Powerful to judges. Built for Algorand + ETHIndia tracks.**
---
## Core Philosophy: Blockchain That Users Never See
The blockchain layer is like the engine in a car. The user just drives — they never open the hood. Every transaction, every hash, every contract runs invisibly. The user sees only:
```
✅ Agreement Secured | 🔗 View Proof: negot8.app/verify/TXN123ABC
```
This guide is structured in **4 milestones**, each building on the last. Milestone 1 is the baseline that touches every feature. Milestones 24 are depth features for the Algorand and ETHIndia tracks.
---
## Updated Folder Structure
Add the following to your existing `negot8/backend/` directory:
```
negot8/
├── backend/
│ ├── blockchain/ ← NEW DIRECTORY
│ │ ├── __init__.py
│ │ ├── algorand_client.py ← Algorand SDK wrapper + TestNet connection
│ │ ├── notarize.py ← Hash agreement → write to chain
│ │ ├── escrow_contract.py ← PyTeal freelancer escrow smart contract
│ │ ├── reputation.py ← On-chain agent reputation scoring
│ │ └── contract_templates/
│ │ └── freelance_escrow.py ← PyTeal contract logic
│ │
│ ├── features/
│ │ ├── freelance.py ← MODIFIED: add escrow deploy after resolution
│ │ └── expenses.py ← MODIFIED: add notarization after resolution
│ │
│ └── tools/
│ └── verify.py ← NEW: lookup a txid and return proof data
├── dashboard/
│ ├── app/
│ │ └── verify/
│ │ └── [txid]/page.tsx ← NEW: public verification page
│ └── components/
│ ├── VerifiedBadge.tsx ← NEW: green checkmark badge component
│ └── ProofCard.tsx ← NEW: shows agreement details + chain proof
```
Everything else in your existing structure stays exactly the same. These are **pure additions**, no rewrites.
---
## Milestone 1 — Agreement Notarization (Baseline)
Every single negotiation that resolves gets its agreement hashed and anchored on Algorand. Zero user friction. Works for all 8 features.
**What the user sees in Telegram:**
```
✅ Agreement reached!
📋 Summary: Meet at Blue Tokai, Connaught Place on Friday 3pm
🔐 Secured on Algorand · View proof: negot8.app/verify/TXN123ABC
```
**What actually happens under the hood:**
1. Negotiation resolves → agreement JSON is assembled
2. SHA-256 hash of the JSON is computed
3. Hash is written to an Algorand transaction note field (costs 0.001 ALGO)
4. Transaction ID (TxID) is stored in your SQLite DB alongside the negotiation
5. TxID is included in the Telegram resolution message as a verify link
### Install
```bash
pip install py-algorand-sdk
```
### blockchain/algorand_client.py
```python
import algosdk, os
from algosdk.v2client import algod
ALGOD_URL = "https://testnet-api.algonode.cloud"
ALGOD_TOKEN = "" # AlgoNode TestNet needs no token
def get_client():
return algod.AlgodClient(ALGOD_TOKEN, ALGOD_URL)
def get_agent_account():
# Load from env — one funded TestNet account for all notarizations
private_key = os.environ['ALGORAND_PRIVATE_KEY']
address = algosdk.account.address_from_private_key(private_key)
return private_key, address
```
### blockchain/notarize.py
```python
import hashlib, json
from algosdk import transaction
from .algorand_client import get_client, get_agent_account
async def notarize_agreement(agreement: dict) -> str:
"""Hash the agreement dict, write to Algorand. Returns TxID."""
# Step 1: Create canonical hash
agreement_str = json.dumps(agreement, sort_keys=True)
hash_bytes = hashlib.sha256(agreement_str.encode()).hexdigest()
# Step 2: Write hash to chain as a zero-value transaction note
client = get_client()
private_key, address = get_agent_account()
params = client.suggested_params()
note = f"negot8:v1:{hash_bytes}".encode() # max 1KB
txn = transaction.PaymentTransaction(
sender=address,
sp=params,
receiver=address, # send to self, 0 ALGO
amt=0,
note=note
)
signed = txn.sign(private_key)
txid = client.send_transaction(signed)
# Step 3: Wait for confirmation (3.3 sec on Algorand)
transaction.wait_for_confirmation(client, txid, 4)
return txid # store this in SQLite + send to Telegram
```
### Hook Into Existing Resolution Flow
In each of your feature files (e.g. `features/scheduling.py`), add 3 lines after resolution:
```python
# EXISTING CODE — runs when negotiation resolves
resolution = await negotiator.finalize()
await store_resolution(db, negotiation_id, resolution)
# NEW: 3 lines — notarize on-chain
from blockchain.notarize import notarize_agreement
txid = await notarize_agreement(resolution)
await db.execute('UPDATE negotiations SET txid=? WHERE id=?', (txid, negotiation_id))
# EXISTING — send Telegram message (just add the verify link)
verify_url = f'https://negot8.app/verify/{txid}'
await bot.send_message(chat_id, f'✅ Done!\n🔐 Secured: {verify_url}')
```
---
## Milestone 2 — Verification Page (User Trust Layer)
This is what the user sees when they tap the verify link. Clean, simple, no crypto jargon. Shows what was agreed, when, and quietly shows it's on Algorand.
### dashboard/app/verify/[txid]/page.tsx
```tsx
export default async function VerifyPage({ params }: { params: { txid: string } }) {
const data = await fetch(`/api/verify/${params.txid}`).then(r => r.json())
return (
<div className='max-w-lg mx-auto p-6'>
<div className='flex items-center gap-2 mb-4'>
<span className='text-green-500 text-2xl'></span>
<h1 className='text-xl font-bold'>Agreement Verified</h1>
</div>
<div className='bg-gray-50 rounded-xl p-4 mb-4'>
<p className='text-sm text-gray-500 mb-1'>What was agreed</p>
<p className='font-medium'>{data.summary}</p>
</div>
<div className='grid grid-cols-2 gap-3 mb-4'>
<div className='bg-white border rounded-lg p-3'>
<p className='text-xs text-gray-400'>Between</p>
<p className='font-medium text-sm'>{data.parties.join(' & ')}</p>
</div>
<div className='bg-white border rounded-lg p-3'>
<p className='text-xs text-gray-400'>On</p>
<p className='font-medium text-sm'>{data.timestamp}</p>
</div>
</div>
{/* Quiet blockchain mention — not the hero, just a trust signal */}
<p className='text-xs text-gray-400 text-center'>
Secured on Algorand ·{' '}
<a href={`https://testnet.algoexplorer.io/tx/${params.txid}`} className='underline'>
View transaction
</a>
</p>
</div>
)
}
```
> **Note:** The word "blockchain" never appears. The user sees "Secured" and a checkmark. The Algorand link is there for whoever wants it — but never forced.
---
## Milestone 3 — Freelancer Escrow Smart Contract (Algorand Track)
This is your flagship feature for the Algorand track. After the agents negotiate scope + budget, the client's agent autonomously deploys a smart contract that locks payment in escrow. **No human writes a contract. The AI does it.**
> **Demo pitch:** *"The AI agents just negotiated a ₹50,000 freelance deal. The payment is now locked on Algorand. It auto-releases when the milestone is confirmed. No lawyers, no disputes, no chasing payments."*
### The Flow
1. Agents negotiate: scope = `landing page`, budget = ₹50,000, deadline = 2 weeks
2. Resolution triggers contract deployment (automatic, no user action needed)
3. Client gets Telegram message: *"Fund escrow to lock in your deal → [link]"*
4. On milestone completion, freelancer sends `/complete` to their agent
5. Contract releases funds automatically
### blockchain/contract_templates/freelance_escrow.py (PyTeal)
```python
from pyteal import *
def freelance_escrow(client_addr: str, freelancer_addr: str,
amount: int, deadline_round: int):
# Release: only freelancer can claim, only before deadline
release = And(
Txn.sender() == Addr(freelancer_addr),
Txn.receiver() == Addr(freelancer_addr),
Global.round() <= Int(deadline_round)
)
# Refund: client reclaims if deadline passed
refund = And(
Txn.sender() == Addr(client_addr),
Global.round() > Int(deadline_round)
)
return If(release, Approve(), If(refund, Approve(), Reject()))
```
### blockchain/escrow_contract.py
```python
from algosdk import transaction, logic
from pyteal import compileTeal, Mode
from .contract_templates.freelance_escrow import freelance_escrow
from .algorand_client import get_client, get_agent_account
async def deploy_escrow(client_addr, freelancer_addr, amount_inr):
"""Compile + deploy escrow. Returns escrow address + txid."""
client = get_client()
params = client.suggested_params()
# Deadline = current round + ~10,000 blocks (≈ 2 weeks on Algorand)
deadline_round = client.status()['last-round'] + 10000
# Compile PyTeal to TEAL bytecode
teal_code = compileTeal(
freelance_escrow(client_addr, freelancer_addr, amount_inr, deadline_round),
mode=Mode.Signature, version=6
)
compiled = client.compile(teal_code)
escrow_address = compiled['hash']
return {
'escrow_address': escrow_address,
'deadline_round': deadline_round,
'fund_link': f'https://testnet.algoexplorer.io/address/{escrow_address}'
}
```
### Hook Into features/freelance.py
```python
# After negotiation resolves in freelance.py:
from blockchain.escrow_contract import deploy_escrow
from blockchain.notarize import notarize_agreement
escrow = await deploy_escrow(
client_addr=user_a.wallet_address,
freelancer_addr=user_b.wallet_address,
amount_inr=resolution['budget']
)
txid = await notarize_agreement(resolution)
# Message to client
await bot.send_message(user_a.telegram_id,
f'✅ Deal agreed!\n'
f'💰 Lock ₹{resolution["budget"]} in escrow:\n'
f'{escrow["fund_link"]}\n'
f'🔐 Agreement proof: negot8.app/verify/{txid}'
)
```
---
## Milestone 4 — Agent Reputation on EVM (ETHIndia Track)
Your analytics already track satisfaction scores per negotiation. This milestone writes those scores on-chain, creating a **verifiable reputation** for each agent that persists forever and is portable across any app.
> **ETHIndia angle:** *Decentralized, portable, user-owned agent reputation. Your agent's trustworthiness isn't locked in our database — it lives on-chain.*
### Simple Solidity Contract
```solidity
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract AgentReputation {
struct Score {
uint256 totalScore; // sum of all satisfaction scores (0-100)
uint256 count; // number of negotiations
uint256 lastUpdated; // block timestamp
}
// agentAddress => reputation
mapping(address => Score) public reputation;
event ScoreRecorded(address indexed agent, uint8 score, uint256 average);
function recordScore(address agent, uint8 score) external {
require(score <= 100, "Score out of range");
Score storage s = reputation[agent];
s.totalScore += score;
s.count += 1;
s.lastUpdated = block.timestamp;
emit ScoreRecorded(agent, score, s.totalScore / s.count);
}
function getAverage(address agent) external view returns (uint256) {
Score storage s = reputation[agent];
if (s.count == 0) return 0;
return s.totalScore / s.count;
}
}
```
Deploy this on **Polygon Mumbai TestNet** (free, gas is near-zero). Your Python backend calls it after every resolved negotiation using `web3.py`.
### blockchain/reputation.py
```python
from web3 import Web3
import os
RPC_URL = 'https://rpc-mumbai.maticvigil.com'
CONTRACT_ADDRESS = 'YOUR_DEPLOYED_CONTRACT_ADDRESS'
ABI = [/* paste ABI after deploying */]
async def record_reputation(agent_wallet: str, satisfaction_score: int):
w3 = Web3(Web3.HTTPProvider(RPC_URL))
contract = w3.eth.contract(address=CONTRACT_ADDRESS, abi=ABI)
account = w3.eth.account.from_key(os.environ['ETH_PRIVATE_KEY'])
tx = contract.functions.recordScore(agent_wallet, satisfaction_score).build_transaction({
'from': account.address,
'nonce': w3.eth.get_transaction_count(account.address),
'gas': 100000,
'gasPrice': w3.to_wei('1', 'gwei') # near-zero on Mumbai
})
signed = account.sign_transaction(tx)
tx_hash = w3.eth.send_raw_transaction(signed.rawTransaction)
return tx_hash.hex()
```
---
## New Environment Variables
Add these to your existing `.env` file:
```bash
# ─── Blockchain (Algorand) ───────────────────────────────────────
ALGORAND_PRIVATE_KEY=your_testnet_account_private_key_here
ALGORAND_ADDRESS=your_testnet_account_address_here
# ─── Blockchain (ETHIndia / Polygon) ─────────────────────────────
ETH_PRIVATE_KEY=your_polygon_mumbai_private_key_here
REPUTATION_CONTRACT_ADDRESS=your_deployed_contract_address_here
# ─── Feature flags (so you can demo without chain if needed) ─────
ENABLE_NOTARIZATION=true
ENABLE_ESCROW=true
ENABLE_REPUTATION=true
```
> **Important:** Create a fresh wallet for hackathon use only. Never use a wallet with real funds. Fund it with TestNet ALGO from [bank.testnet.algorand.network/dispenser](https://bank.testnet.algorand.network/dispenser) (free).
---
## New Install Commands
```bash
# Algorand SDK + PyTeal
pip install py-algorand-sdk pyteal
# Web3 for ETHIndia track
pip install web3
```
---
## Milestone Summary
| Milestone | What It Builds | Track | User Sees | Effort |
|---|---|---|---|---|
| 1 — Notarization | Hash + anchor every agreement on Algorand TestNet | Algorand | 🔐 Secured badge + verify link | Low — 1 file, 3 lines per feature |
| 2 — Verify Page | Public webpage showing agreement proof | Both | Clean proof page, no crypto jargon | Low — 1 Next.js page |
| 3 — Escrow Contract | PyTeal smart contract for freelancer payments | Algorand | Fund escrow link in Telegram | Medium — PyTeal + deploy flow |
| 4 — Reputation | On-chain agent satisfaction scores (EVM) | ETHIndia | Agent reputation badge on dashboard | Low — 1 Solidity contract |
---
## Hackathon Demo Script (Blockchain Moments)
**Scene 1 — Any Feature (30 seconds)**
> *"Rahul and Priya's agents just agreed to meet Friday at 3pm. Watch what happens — the agreement is automatically secured on Algorand. Here's the verification link. Anyone can check this, forever, without trusting us."*
**Scene 2 — Freelancer Escrow (1 minute — Algorand Track highlight)**
> *"These two AI agents just negotiated a ₹50,000 project. But here's what's different — the payment is now locked in a smart contract on Algorand that was deployed automatically by the agents, not by a human. The freelancer gets paid when they deliver. The client gets a refund if they don't. Zero paperwork, zero lawyers, zero trust required."*
**Scene 3 — Reputation (30 seconds — ETHIndia Track)**
> *"Every time an agent closes a negotiation, the satisfaction score goes on-chain. This is Rahul's agent — 94/100 average across 12 negotiations. That score lives on Polygon. It's portable. If Rahul moves to another app tomorrow, his agent's reputation comes with him. That's user-owned AI reputation."*
---
## Key Points to Remember
- Never mention "blockchain", "wallet", "gas", or "seed phrase" to end users
- All blockchain calls are async and non-blocking — if chain is slow, resolution still sends to Telegram immediately
- Always wrap blockchain calls in `try/except` — if chain fails, the product still works, just without the proof link
- Use TestNet throughout the hackathon — real funds are never needed
- AlgoNode (TestNet) is free and needs no API key — just use the public URL
- Polygon Mumbai gas is near-zero — reputation writes cost fractions of a cent
```python
# Always wrap blockchain in try/except so it never breaks the core product
try:
txid = await notarize_agreement(resolution)
verify_url = f'negot8.app/verify/{txid}'
except Exception as e:
print(f'Notarization failed (non-critical): {e}')
verify_url = None # just don't show the link, product still works
```
> This is the most important pattern in the entire blockchain integration. The feature degrades gracefully. **The product never breaks because of the chain.**

1880
negot8/docs/new-milestone.md Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

52
negot8/requirements.txt Normal file
View File

@@ -0,0 +1,52 @@
# negoT8 — Python backend dependencies
# Generated from venv on 2026-03-01
#
# Install with:
# python3 -m venv venv
# source venv/bin/activate # macOS / Linux
# venv\Scripts\activate # Windows
# pip install -r requirements.txt
# ── Telegram Bot ──────────────────────────────────────────────────────────────
python-telegram-bot==22.5
# ── Web / API ─────────────────────────────────────────────────────────────────
fastapi==0.128.8
uvicorn==0.39.0
starlette==0.49.3
python-socketio==5.16.1
python-engineio==4.13.1
httpx==0.28.1
requests==2.32.5
aiohttp==3.13.3
# ── Database ──────────────────────────────────────────────────────────────────
aiosqlite==0.22.1
# ── AI / Gemini ───────────────────────────────────────────────────────────────
google-generativeai==0.8.6
google-api-python-client==2.190.0
google-auth==2.48.0
google-auth-httplib2==0.3.0
# ── Blockchain / Web3 ─────────────────────────────────────────────────────────
web3==7.14.1
eth-account==0.13.7
# ── Voice / ElevenLabs ────────────────────────────────────────────────────────
elevenlabs==2.37.0
# ── Search ────────────────────────────────────────────────────────────────────
tavily-python==0.7.22
# ── Scheduling ────────────────────────────────────────────────────────────────
APScheduler==3.11.2
# ── PDF Generation ────────────────────────────────────────────────────────────
fpdf2==2.8.3
# ── Utilities ────────────────────────────────────────────────────────────────
python-dotenv==1.2.1
pydantic==2.12.5
tiktoken==0.12.0
aiolimiter==1.2.1

View File

@@ -0,0 +1,112 @@
"""
Bot Runner Test - Run the full negoT8 bot with all features
Run this from anywhere: python test/bot_runner_test.py
Runs Bot A AND Bot B simultaneously so the full /pending cross-bot flow works:
Phone A → /coordinate @userB → enters preferences
Phone B → /pending → sees request, taps Accept
Phone B → enters preferences → agents negotiate live
"""
import sys
import os
import asyncio
# Add backend to path so we can import modules
backend_path = os.path.join(os.path.dirname(__file__), '..', 'backend')
sys.path.insert(0, backend_path)
# Now import the bot creator and config
from config import TELEGRAM_BOT_TOKEN_A, TELEGRAM_BOT_TOKEN_B
import database as db
# Import bot after path is set
telegram_bots_path = os.path.join(backend_path, 'telegram-bots')
sys.path.insert(0, telegram_bots_path)
from bot import create_bot
from telegram import Update
async def setup_database():
"""Initialize database tables if needed."""
print("🗄️ Initializing database...")
await db.init_db()
print("✅ Database ready!")
async def run_bots_async():
"""Start both Bot A and Bot B concurrently so pending_coordinations is shared."""
bots = []
if TELEGRAM_BOT_TOKEN_A:
bot_a = create_bot(TELEGRAM_BOT_TOKEN_A)
bots.append(("A", bot_a))
print(f"✅ Bot A configured: {TELEGRAM_BOT_TOKEN_A[:20]}...")
else:
print("⚠️ TELEGRAM_BOT_TOKEN_A not set — skipping Bot A")
if TELEGRAM_BOT_TOKEN_B:
bot_b = create_bot(TELEGRAM_BOT_TOKEN_B)
bots.append(("B", bot_b))
print(f"✅ Bot B configured: {TELEGRAM_BOT_TOKEN_B[:20]}...")
else:
print("⚠️ TELEGRAM_BOT_TOKEN_B not set — skipping Bot B (only 1 bot)")
if not bots:
print("❌ No bot tokens found. Check your .env file.")
return
print(f"\n🚀 Launching {len(bots)} bot(s) in parallel...")
print("=" * 60)
print()
# Initialize all bots and run them concurrently
tasks = []
for name, app in bots:
await app.initialize()
await app.start()
print(f"▶️ Bot {name} is polling...")
tasks.append(app.updater.start_polling(allowed_updates=Update.ALL_TYPES))
await asyncio.gather(*tasks)
def main():
"""Run Bot A (and optionally Bot B) with full feature set."""
print("=" * 60)
print("🤖 Starting negoT8 Bots (Full Feature Set)")
print("=" * 60)
print()
print("📋 Features enabled:")
print(" • /start — Welcome message")
print(" • /personality — Set agent negotiation style (5 types)")
print(" • /coordinate @u — Start a coordination request (User A)")
print(" • /pending — View & accept incoming requests (User B)")
print(" • /help — View all commands")
print()
if not TELEGRAM_BOT_TOKEN_A and not TELEGRAM_BOT_TOKEN_B:
print("❌ ERROR: No bot tokens found in environment!")
print(" Make sure your .env file has TELEGRAM_BOT_TOKEN_A (and optionally _B) set.")
return
# Setup database
asyncio.run(setup_database())
print("🚀 Bots are now running... Press Ctrl+C to stop")
print("=" * 60)
print()
try:
asyncio.run(run_bots_async())
except KeyboardInterrupt:
print("\n\n👋 Bots stopped by user")
except Exception as e:
print(f"\n\n❌ Bot crashed: {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
main()

47
negot8/test/test_apis.py Normal file
View File

@@ -0,0 +1,47 @@
# test_apis.py (run this standalone — tests Gemini, Tavily, ElevenLabs)
import os
from dotenv import load_dotenv
load_dotenv()
# ─── Test 1: Gemini ───
print("Testing Gemini...")
import google.generativeai as genai
genai.configure(api_key=os.getenv("GEMINI_API_KEY"))
model = genai.GenerativeModel(
model_name="gemini-3-flash-preview",
generation_config=genai.GenerationConfig(
response_mime_type="application/json",
temperature=0.7,
)
)
response = model.generate_content(
'Return JSON: {"status": "ok", "message": "Gemini works"}'
)
print(f" Gemini: {response.text}")
# ─── Test 2: Tavily ───
print("\nTesting Tavily...")
from tavily import TavilyClient
tavily = TavilyClient(api_key=os.getenv("TAVILY_API_KEY"))
result = tavily.search("best Thai restaurants in Bandra Mumbai", include_answer=True, max_results=2)
print(f" Tavily answer: {result.get('answer', 'No answer')[:150]}")
print(f" Results count: {len(result.get('results', []))}")
# ─── Test 3: ElevenLabs ───
print("\nTesting ElevenLabs...")
import httpx
resp = httpx.post(
"https://api.elevenlabs.io/v1/text-to-speech/ZthjuvLPty3kTMaNKVKb",
headers={"xi-api-key": os.getenv("ELEVENLABS_API_KEY"), "Content-Type": "application/json"},
json={"text": "Hello from negoT8!", "model_id": "eleven_flash_v2_5",
"voice_settings": {"stability": 0.5, "similarity_boost": 0.75}},
timeout=15.0
)
if resp.status_code == 200:
with open("test_voice.mp3", "wb") as f:
f.write(resp.content)
print(f" ElevenLabs: ✅ Saved test_voice.mp3 ({len(resp.content)} bytes)")
else:
print(f" ElevenLabs: ❌ {resp.status_code}{resp.text[:200]}")
print("\n✅ All API tests complete!")

View File

@@ -0,0 +1,327 @@
"""
test_milestone4.py — Automated success tests for Milestone 4
Milestone 4 success checklist (from new-milestone.md):
✅ run.py is a runnable entry point with run_bots()
✅ voice/elevenlabs_tts generates MP3 with cross-platform path
✅ build_voice_text returns text for all 8 feature types
✅ UPI link generated for expense-type negotiations
✅ on_resolution in bot.py wires voice + UPI + analytics
✅ analytics stored in DB after negotiation
✅ run.py imports cleanly (all modules resolvable)
✅ bot.py imports cleanly (voice + UPI imports added)
✅ fairness score computed correctly
✅ No crashes when ElevenLabs key is missing (fallback to None)
Run from project root:
cd e:\\negot8
python test/test_milestone4.py
"""
import sys, os, asyncio, json, importlib
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
BACKEND = os.path.join(ROOT, "backend")
BOTS = os.path.join(BACKEND, "telegram-bots")
sys.path.insert(0, BACKEND)
sys.path.insert(0, BOTS)
PASS = ""
FAIL = ""
results = []
def record(ok: bool, name: str, detail: str = ""):
results.append((PASS if ok else FAIL, name, detail))
print(f" {PASS if ok else FAIL} {name}" + (f" [{detail}]" if detail else ""))
# ═══════════════════════════════════════════════════════════════
# 1. Module import checks
# ═══════════════════════════════════════════════════════════════
def test_imports():
print("\n── 1. Module imports ──")
for mod, label in [
("run", "run.py imports cleanly"),
("bot", "bot.py imports cleanly"),
("voice.elevenlabs_tts", "elevenlabs_tts imports cleanly"),
]:
try:
importlib.import_module(mod)
record(True, label)
except Exception as e:
record(False, label, str(e)[:120])
# Verify new symbols exist in bot.py
try:
import bot
has_voice = hasattr(bot, "generate_voice_summary") or \
"generate_voice_summary" in getattr(bot, "__dict__", {}) or \
True # imported into local scope via 'from ... import'
from bot import _upi_tool, VOICE_ID_AGENT_A, VOICE_ID_AGENT_B
record(True, "bot.py has _upi_tool, VOICE_ID constants")
except ImportError as e:
record(False, "bot.py has _upi_tool, VOICE_ID constants", str(e))
# Verify run.py has run_bots
try:
from run import run_bots
record(True, "run.py exposes run_bots()")
except ImportError as e:
record(False, "run.py exposes run_bots()", str(e))
# ═══════════════════════════════════════════════════════════════
# 2. run.py entry point structure
# ═══════════════════════════════════════════════════════════════
def test_run_py_structure():
print("\n── 2. run.py structure ──")
import inspect, run
# run_bots is a coroutine function
record(asyncio.iscoroutinefunction(run.run_bots),
"run_bots() is an async function")
# Source contains if __name__ == "__main__"
try:
src = open(os.path.join(BACKEND, "run.py")).read()
record('if __name__ == "__main__"' in src,
'run.py has if __name__ == "__main__" block')
record("asyncio.run(run_bots())" in src,
"run.py calls asyncio.run(run_bots())")
except Exception as e:
record(False, "run.py source check", str(e))
# ═══════════════════════════════════════════════════════════════
# 3. ElevenLabs TTS — cross-platform path + templates
# ═══════════════════════════════════════════════════════════════
def test_voice_module():
print("\n── 3. Voice module ──")
from voice.elevenlabs_tts import build_voice_text, VOICE_TEMPLATES
# All 8 feature types have templates
expected = {"expenses", "collaborative", "scheduling", "marketplace",
"trip", "freelance", "roommate", "conflict"}
actual = set(VOICE_TEMPLATES.keys())
record(expected.issubset(actual),
"All 8 feature types have voice templates",
f"missing: {expected - actual}" if not expected.issubset(actual) else "all present")
# build_voice_text returns safe string when keys missing
text = build_voice_text("scheduling", {"rounds": 3, "date": "Monday", "time": "10am", "location": "Blue Tokai"})
record(isinstance(text, str) and len(text) > 5,
"build_voice_text returns non-empty string for scheduling",
text[:60])
text2 = build_voice_text("unknown_feature", {"rounds": 2, "summary": "done"})
record(isinstance(text2, str) and "negotiation" in text2.lower(),
"build_voice_text has safe fallback for unknown feature types",
text2[:60])
# Check no hardcoded /tmp/ path in tts module
src = open(os.path.join(BACKEND, "voice", "elevenlabs_tts.py")).read()
record("/tmp/" not in src,
"elevenlabs_tts.py uses cross-platform path (no hardcoded /tmp/)")
record("tempfile.gettempdir()" in src,
"elevenlabs_tts.py uses tempfile.gettempdir()")
# ═══════════════════════════════════════════════════════════════
# 4. ElevenLabs TTS — graceful failure without API key
# ═══════════════════════════════════════════════════════════════
async def test_voice_graceful_failure():
print("\n── 4. Voice graceful failure (no real API call) ──")
import unittest.mock as mock
from voice.elevenlabs_tts import generate_voice_summary
# Patch httpx to simulate API error (401)
class FakeResp:
status_code = 401
text = "Unauthorized"
class FakeClient:
async def __aenter__(self): return self
async def __aexit__(self, *a): pass
async def post(self, *a, **kw): return FakeResp()
with mock.patch("httpx.AsyncClient", return_value=FakeClient()):
result = await generate_voice_summary("test text", "neg_test_001")
record(result is None,
"generate_voice_summary returns None on API failure (no crash)")
# ═══════════════════════════════════════════════════════════════
# 5. UPI tool works
# ═══════════════════════════════════════════════════════════════
async def test_upi_tool():
print("\n── 5. UPI tool ──")
from tools.upi_generator import UPIGeneratorTool
tool = UPIGeneratorTool()
result = await tool.execute(
payee_upi="alice@upi",
payee_name="Alice",
amount=500.0,
note="negoT8: expenses settlement"
)
record("upi_link" in result and result["upi_link"].startswith("upi://"),
"UPI link generated with upi:// scheme",
result.get("upi_link", "")[:60])
record("alice@upi" in result["upi_link"],
"UPI link contains payee UPI ID")
# ═══════════════════════════════════════════════════════════════
# 6. Analytics DB storage
# ═══════════════════════════════════════════════════════════════
async def test_analytics_storage():
print("\n── 6. Analytics DB storage ──")
import database as db
await db.init_db()
NEG_ID = "m4test001"
import aiosqlite
from config import DATABASE_PATH
async with aiosqlite.connect(DATABASE_PATH) as conn:
await conn.execute(
"INSERT OR REPLACE INTO negotiations (id, feature_type, initiator_id) VALUES (?, ?, ?)",
(NEG_ID, "expenses", 55555)
)
await conn.commit()
timeline = [{"round": 1, "score_a": 80, "score_b": 60},
{"round": 2, "score_a": 85, "score_b": 75}]
fairness = 100 - abs(85 - 75) # = 90
await db.store_analytics({
"negotiation_id": NEG_ID,
"satisfaction_timeline": json.dumps(timeline),
"concession_log": json.dumps([{"round": 1, "by": "A", "gave_up": "morning slot"}]),
"fairness_score": fairness,
"total_concessions_a": 1,
"total_concessions_b": 0,
})
row = await db.get_analytics(NEG_ID)
record(row is not None, "Analytics row stored in negotiation_analytics table")
if row:
row = dict(row)
record(abs(row["fairness_score"] - 90) < 0.01,
f"Fairness score stored correctly",
str(row["fairness_score"]))
record(row["total_concessions_a"] == 1,
"total_concessions_a stored correctly",
str(row["total_concessions_a"]))
# Cleanup
async with aiosqlite.connect(DATABASE_PATH) as conn:
await conn.execute("DELETE FROM negotiation_analytics WHERE negotiation_id = ?", (NEG_ID,))
await conn.execute("DELETE FROM negotiations WHERE id = ?", (NEG_ID,))
await conn.commit()
# ═══════════════════════════════════════════════════════════════
# 7. bot.py on_resolution wiring — check source for voice+analytics
# ═══════════════════════════════════════════════════════════════
def test_resolution_wiring():
print("\n── 7. on_resolution wiring in bot.py ──")
src = open(os.path.join(BOTS, "bot.py")).read()
record("generate_voice_summary" in src,
"bot.py calls generate_voice_summary in on_resolution")
record("build_voice_text" in src,
"bot.py calls build_voice_text in on_resolution")
record("store_analytics" in src,
"bot.py calls db.store_analytics in on_resolution")
record("upi_link" in src,
"bot.py generates UPI link in on_resolution")
record("send_voice" in src,
"bot.py sends voice note via send_voice in on_resolution")
# ═══════════════════════════════════════════════════════════════
# 8. Decline-before-get bug fixed
# ═══════════════════════════════════════════════════════════════
def test_decline_bug_fixed():
print("\n── 8. Decline-before-get bug fix ──")
src = open(os.path.join(BOTS, "bot.py")).read()
# After the fix, data = get(...) should come BEFORE pop(...)
# within the decline branch
decline_block_start = src.find('if action == "decline":')
if decline_block_start == -1:
record(False, "decline branch found in bot.py")
return
record(True, "decline branch found in bot.py")
# Get the slice of text for the decline handler (up to return)
decline_slice = src[decline_block_start:decline_block_start + 600]
pos_get = decline_slice.find("pending_coordinations.get(neg_id")
pos_pop = decline_slice.find("pending_coordinations.pop(neg_id")
record(pos_get < pos_pop and pos_get != -1 and pos_pop != -1,
"initiator_id is fetched BEFORE pop() in decline branch",
f"get@{pos_get} pop@{pos_pop}")
# ═══════════════════════════════════════════════════════════════
# Main runner
# ═══════════════════════════════════════════════════════════════
async def run_all():
test_imports()
test_run_py_structure()
test_voice_module()
await test_voice_graceful_failure()
await test_upi_tool()
await test_analytics_storage()
test_resolution_wiring()
test_decline_bug_fixed()
passed = sum(1 for s, _, _ in results if s == PASS)
total = len(results)
print("\n" + "=" * 60)
print(f" MILESTONE 4 TESTS: {passed}/{total} passed")
print("=" * 60)
if passed == total:
print("""
🏆 ALL TESTS PASSED — Milestone 4 is ready!
══ HOW TO RUN (Live Telegram test) ══════════════════════════
cd e:\\negot8\\backend
python run.py
══ FULL FLOW TEST (two phones) ══════════════════════════════
Phone A: /start
/personality → pick Aggressive (😤)
/coordinate @PhoneB_username
"Split our hotel bill, I paid ₹8000, john@upi, want 60-40 split"
Phone B: /start
/personality → pick Empathetic (💚)
/pending → tap Accept
"I think 50-50 is fair, my upi is jane@upi"
▶ Watch round-by-round updates appear on BOTH phones
▶ Final resolution arrives with 📊 satisfaction scores
▶ 🎙 Voice note sent to each user (different voices!)
▶ 💳 UPI Pay button appended to expense resolutions
▶ Check DB: sqlite3 negot8.db "SELECT * FROM negotiation_analytics;"
""")
else:
failed = [(n, d) for s, n, d in results if s == FAIL]
print(f"\n⚠️ {total - passed} test(s) failed:")
for name, detail in failed:
print(f"{name}")
if detail:
print(f" {detail}")
print()
if __name__ == "__main__":
asyncio.run(run_all())

View File

@@ -0,0 +1,412 @@
"""
Milestone 5 — Full Feature Test Suite
Run from backend/ directory:
cd backend && python ../test/test_milestone5_all.py
Tests all 8 feature modules locally (no Telegram) against live Gemini + Tavily APIs.
"""
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + "/backend")
import asyncio
import json
PASS = ""
FAIL = ""
SKIP = ""
results = {}
# ─── Test helpers ─────────────────────────────────────────────────────────────
def check(name: str, condition: bool, detail: str = ""):
icon = PASS if condition else FAIL
results[name] = condition
print(f" {icon} {name}" + (f": {detail}" if detail else ""))
# ─── 0. Database init ─────────────────────────────────────────────────────────
async def test_database():
print("\n── Test 0: Database Init ──")
from database import init_db
try:
await init_db()
check("init_db runs", True)
except Exception as e:
check("init_db runs", False, str(e))
from database import (
create_user, get_user, create_negotiation,
add_participant, get_rounds, store_analytics, get_analytics
)
await create_user(99999, "testuser", "Test User")
user = await get_user(99999)
check("create_user + get_user", user is not None)
neg_id = await create_negotiation("expenses", 99999)
check("create_negotiation", len(neg_id) > 0, f"id={neg_id}")
await add_participant(neg_id, 99999, {"goal": "test"}, "balanced")
check("add_participant", True)
rows = await get_rounds(neg_id)
check("get_rounds returns list", isinstance(rows, list))
await store_analytics({
"negotiation_id": neg_id,
"satisfaction_timeline": "[]",
"concession_log": "[]",
"fairness_score": 80.0,
"total_concessions_a": 2,
"total_concessions_b": 1,
})
analytics = await get_analytics(neg_id)
check("store + get analytics", analytics is not None and analytics.get("fairness_score") == 80.0)
# ─── 1. Feature context fetching ─────────────────────────────────────────────
async def test_feature_contexts():
print("\n── Test 1: Feature Context Fetching ──")
from features.base_feature import get_feature
PREFS = {
"scheduling": (
{"raw_details": {"available_windows": ["Monday 10am-12pm", "Wednesday 3-5pm"], "location": "Bandra"}, "goal": "Schedule a coffee meeting", "constraints": [], "preferences": [], "tone": "friendly"},
{"raw_details": {"available_windows": ["Monday 11am-1pm", "Friday 2-4pm"]}, "goal": "Find a time to meet", "constraints": [], "preferences": [], "tone": "friendly"},
),
"expenses": (
{"raw_details": {"expenses": [{"name": "Hotel", "amount": 12000}, {"name": "Fuel", "amount": 3000}], "upi_id": "rahul@paytm"}, "goal": "Split trip expenses", "constraints": [], "preferences": [], "tone": "friendly"},
{"raw_details": {"expenses": [{"name": "Dinner", "amount": 2000}]}, "goal": "Fair expense split", "constraints": [], "preferences": [], "tone": "friendly"},
),
"collaborative": (
{"raw_details": {"cuisine": "Italian", "location": "Bandra", "budget": 800}, "goal": "Pick dinner spot", "constraints": [], "preferences": [], "tone": "friendly"},
{"raw_details": {"cuisine": "Chinese", "location": "Bandra", "budget": 600}, "goal": "Restaurant for tonight", "constraints": [], "preferences": [], "tone": "friendly"},
),
"marketplace": (
{"raw_details": {"item": "PS5 with 2 controllers", "asking_price": 35000, "minimum_price": 30000, "role": "seller", "upi_id": "seller@upi"}, "goal": "Sell PS5", "constraints": [], "preferences": [], "tone": "firm"},
{"raw_details": {"item": "PS5", "budget": 28000, "role": "buyer"}, "goal": "Buy PS5", "constraints": [], "preferences": [], "tone": "friendly"},
),
"freelance": (
{"raw_details": {"skill": "React developer", "rate": 1500, "hours": 40, "upfront_minimum": "50"}, "goal": "Freelance React project", "constraints": [{"value": "minimum rate ₹1500/hr", "hard": True}], "preferences": [], "tone": "professional"},
{"raw_details": {"budget": 40000, "project_type": "web app", "required_features": ["auth", "dashboard", "API"], "role": "client"}, "goal": "Build web app in budget", "constraints": [{"value": "budget max ₹40000", "hard": True}], "preferences": [], "tone": "professional"},
),
"roommate": (
{"raw_details": {"decision_type": "wifi plan", "city": "Mumbai", "budget": 600}, "goal": "Pick WiFi plan", "constraints": [], "preferences": [], "tone": "friendly"},
{"raw_details": {"decision_type": "wifi plan", "city": "Mumbai", "budget": 700}, "goal": "Fast internet within budget", "constraints": [], "preferences": [], "tone": "friendly"},
),
"trip": (
{"raw_details": {"available_dates": ["March 15-17", "March 22-24"], "budget": 5000, "destination_preference": "beach", "origin": "Mumbai"}, "goal": "Weekend beach trip", "constraints": [], "preferences": [], "tone": "excited"},
{"raw_details": {"available_dates": ["March 15-17", "April 5-7"], "budget": 4000, "destination_preference": "hills", "origin": "Mumbai"}, "goal": "Weekend getaway", "constraints": [], "preferences": [], "tone": "friendly"},
),
"conflict": (
{"raw_details": {"conflict_type": "parking spot", "position": "I need the spot Mon-Wed", "relationship_importance": "high"}, "goal": "Resolve parking dispute", "constraints": [], "preferences": [], "tone": "firm"},
{"raw_details": {"conflict_type": "parking spot", "position": "I need the spot Tue-Thu", "relationship_importance": "high"}, "goal": "Fair parking arrangement", "constraints": [], "preferences": [], "tone": "friendly"},
),
}
for feature_type, (prefs_a, prefs_b) in PREFS.items():
try:
feat = get_feature(feature_type)
ctx = await feat.get_context(prefs_a, prefs_b)
check(f"{feature_type}.get_context", len(ctx) > 50, f"{len(ctx)} chars")
except Exception as e:
check(f"{feature_type}.get_context", False, str(e))
# ─── 2. Full negotiation for each feature (live Gemini) ──────────────────────
async def test_negotiation_feature(feature_type: str, prefs_a: dict, prefs_b: dict, check_fn=None):
"""Run a full negotiation for one feature and verify the result."""
from agents.negotiation import run_negotiation
from features.base_feature import get_feature
import database as db
feat = get_feature(feature_type)
try:
feature_context = await feat.get_context(prefs_a, prefs_b)
except Exception:
feature_context = ""
neg_id = await db.create_negotiation(feature_type, 99901)
await db.add_participant(neg_id, 99901, prefs_a)
await db.add_participant(neg_id, 99902, prefs_b)
resolution = await run_negotiation(
negotiation_id=neg_id,
preferences_a=prefs_a,
preferences_b=prefs_b,
user_a_id=99901,
user_b_id=99902,
feature_type=feature_type,
personality_a="balanced",
personality_b="balanced",
feature_context=feature_context,
)
check(f"{feature_type} negotiation completes", resolution is not None)
check(f"{feature_type} has status", resolution.get("status") in ("resolved", "escalated"),
resolution.get("status"))
check(f"{feature_type} has rounds_taken", isinstance(resolution.get("rounds_taken"), int),
str(resolution.get("rounds_taken")))
check(f"{feature_type} has final_proposal", isinstance(resolution.get("final_proposal"), dict))
# Feature-specific checks
if check_fn:
check_fn(resolution, prefs_a, prefs_b)
# Verify format_resolution produces non-empty string
try:
formatted = feat.format_resolution(resolution, prefs_a, prefs_b)
check(f"{feature_type} format_resolution non-empty", len(formatted) > 30, f"{len(formatted)} chars")
print(f"\n 📄 Formatted resolution preview:\n {formatted[:200].replace(chr(10), chr(10)+' ')}\n")
except Exception as e:
check(f"{feature_type} format_resolution", False, str(e))
return resolution
async def test_all_negotiations():
print("\n── Test 2: Full Negotiations (live Gemini + Tavily) ──")
print(" (This may take 2-4 minutes due to API rate limits)\n")
from database import init_db
await init_db()
# ── Feature 1: Scheduling ──
print(" [1/8] Scheduling...")
await test_negotiation_feature(
"scheduling",
{"goal": "Schedule a coffee meeting", "constraints": [], "preferences": [],
"tone": "friendly", "feature_type": "scheduling",
"raw_details": {"available_windows": ["Monday 10am-12pm", "Wednesday 3-5pm"], "location": "Bandra", "duration": "1 hour"}},
{"goal": "Coffee meeting next week", "constraints": [], "preferences": [],
"tone": "friendly", "feature_type": "scheduling",
"raw_details": {"available_windows": ["Monday 11am-1pm", "Wednesday 4-6pm"]}},
)
# ── Feature 2: Expenses ──
print(" [2/8] Expenses...")
def check_expense_math(resolution, prefs_a, prefs_b):
# Verify the resolution doesn't hallucinate wrong math
final = resolution.get("final_proposal", {})
details = final.get("details", {})
# At minimum, the proposal should exist
check("expenses has details", bool(details), str(details)[:80])
await test_negotiation_feature(
"expenses",
{"goal": "Split Goa trip expenses fairly", "constraints": [], "preferences": [],
"tone": "friendly", "feature_type": "expenses",
"raw_details": {"expenses": [{"name": "Hotel", "amount": 12000}, {"name": "Fuel", "amount": 3000}], "upi_id": "rahul@paytm"}},
{"goal": "Fair expense split", "constraints": [], "preferences": [],
"tone": "friendly", "feature_type": "expenses",
"raw_details": {"expenses": [{"name": "Dinner", "amount": 2000}]}},
check_fn=check_expense_math,
)
# ── Feature 3: Collaborative ──
print(" [3/8] Collaborative (uses Tavily)...")
def check_collaborative(resolution, prefs_a, prefs_b):
final = resolution.get("final_proposal", {})
summary = final.get("summary", "")
check("collaborative has venue recommendation", bool(summary), summary[:60])
await test_negotiation_feature(
"collaborative",
{"goal": "Pick a restaurant for dinner", "constraints": [], "preferences": [],
"tone": "friendly", "feature_type": "collaborative",
"raw_details": {"cuisine": "Italian", "location": "Bandra Mumbai", "budget": 800}},
{"goal": "Dinner somewhere nice", "constraints": [], "preferences": [],
"tone": "friendly", "feature_type": "collaborative",
"raw_details": {"cuisine": "Chinese", "location": "Bandra Mumbai", "budget": 600}},
check_fn=check_collaborative,
)
# ── Feature 4: Marketplace ──
print(" [4/8] Marketplace (uses Tavily)...")
def check_marketplace(resolution, prefs_a, prefs_b):
final = resolution.get("final_proposal", {})
details = final.get("details", {})
price = details.get("agreed_price") or details.get("price") or details.get("final_price") or ""
reasoning = resolution.get("summary", "")
check("marketplace has price", bool(price) or bool(reasoning), f"price={price}")
await test_negotiation_feature(
"marketplace",
{"goal": "Sell my PS5", "constraints": [{"value": "minimum ₹30000", "hard": True}],
"preferences": [], "tone": "firm", "feature_type": "marketplace",
"raw_details": {"item": "PS5 with 2 controllers", "asking_price": 35000, "minimum_price": 30000, "role": "seller", "upi_id": "seller@upi"}},
{"goal": "Buy a PS5 in budget", "constraints": [{"value": "budget max ₹28000", "hard": True}],
"preferences": [], "tone": "friendly", "feature_type": "marketplace",
"raw_details": {"item": "PS5", "budget": 28000, "role": "buyer", "max_budget": 28000}},
check_fn=check_marketplace,
)
# ── Feature 5: Freelance ──
print(" [5/8] Freelance (uses Tavily + Calculator)...")
def check_freelance(resolution, prefs_a, prefs_b):
final = resolution.get("final_proposal", {})
details = final.get("details", {})
budget = details.get("budget") or details.get("agreed_budget") or details.get("price") or ""
check("freelance has budget in proposal", bool(budget) or bool(details), f"budget={budget}")
await test_negotiation_feature(
"freelance",
{"goal": "Get paid fairly for React project", "constraints": [{"value": "min rate ₹1500/hr", "hard": True}],
"preferences": [], "tone": "professional", "feature_type": "freelance",
"raw_details": {"skill": "React developer", "rate": 1500, "hours": 40, "upfront_minimum": "50"}},
{"goal": "Build web app within ₹40k budget", "constraints": [{"value": "budget max ₹40000", "hard": True}],
"preferences": [], "tone": "professional", "feature_type": "freelance",
"raw_details": {"budget": 40000, "project_type": "web app", "required_features": ["auth", "dashboard", "API"], "role": "client"}},
check_fn=check_freelance,
)
# ── Feature 6: Roommate ──
print(" [6/8] Roommate (uses Tavily for real plans)...")
def check_roommate(resolution, prefs_a, prefs_b):
final = resolution.get("final_proposal", {})
summary = final.get("summary", "")
check("roommate has decision", bool(summary), summary[:60])
await test_negotiation_feature(
"roommate",
{"goal": "Pick a shared WiFi plan", "constraints": [], "preferences": [],
"tone": "friendly", "feature_type": "roommate",
"raw_details": {"decision_type": "wifi plan", "city": "Mumbai", "budget": 600}},
{"goal": "Fast internet within budget", "constraints": [], "preferences": [],
"tone": "friendly", "feature_type": "roommate",
"raw_details": {"decision_type": "wifi plan", "city": "Mumbai", "budget": 700}},
check_fn=check_roommate,
)
# ── Feature 7: Conflict ──
print(" [7/8] Conflict Resolution...")
await test_negotiation_feature(
"conflict",
{"goal": "Resolve parking spot dispute", "constraints": [], "preferences": [],
"tone": "firm", "feature_type": "conflict",
"raw_details": {"conflict_type": "parking spot", "position": "I need Mon-Wed", "relationship_importance": "high"}},
{"goal": "Fair parking arrangement", "constraints": [], "preferences": [],
"tone": "friendly", "feature_type": "conflict",
"raw_details": {"conflict_type": "parking spot", "position": "I need Tue-Thu", "relationship_importance": "high"}},
)
# ── Feature 8: Trip (Group Negotiation) ──
print(" [8/8] Trip Planning (group negotiation with 3 preferences)...")
from features.trip import run_group_negotiation
from database import init_db as _init
all_prefs = [
{"goal": "Beach weekend trip", "constraints": [], "preferences": [],
"tone": "excited", "feature_type": "trip",
"raw_details": {"available_dates": ["March 15-17", "March 22-24"], "budget": 5000, "destination_preference": "beach", "origin": "Mumbai"}},
{"goal": "Weekend trip with friends", "constraints": [], "preferences": [],
"tone": "friendly", "feature_type": "trip",
"raw_details": {"available_dates": ["March 15-17", "April 5-7"], "budget": 4000, "destination_preference": "hills", "origin": "Mumbai"}},
{"goal": "Budget getaway", "constraints": [], "preferences": [],
"tone": "friendly", "feature_type": "trip",
"raw_details": {"available_dates": ["March 15-17", "March 29-31"], "budget": 3500, "destination_preference": "any", "origin": "Mumbai"}},
]
from database import create_negotiation, add_participant
trip_neg_id = await create_negotiation("trip", 99901)
for i, p in enumerate(all_prefs):
await add_participant(trip_neg_id, 99901 + i, p)
trip_resolution = {"status": None}
async def on_trip_round(data):
print(f" Round {data['round_number']}: {data['action']} | avg_sat={data['satisfaction_score']:.0f}")
async def on_trip_resolve(data):
trip_resolution["status"] = data.get("status")
trip_resolution["summary"] = data.get("summary", "")
trip_resolution["data"] = data
await run_group_negotiation(
negotiation_id=trip_neg_id,
all_preferences=all_prefs,
all_user_ids=[99901, 99902, 99903],
feature_type="trip",
personalities=["balanced", "empathetic", "balanced"],
on_round_update=on_trip_round,
on_resolution=on_trip_resolve,
)
check("trip group negotiation completes", trip_resolution["status"] in ("resolved", "escalated"),
trip_resolution.get("status"))
check("trip has summary", bool(trip_resolution.get("summary")))
from features.trip import TripFeature
if trip_resolution.get("data"):
try:
formatted = TripFeature().format_resolution(trip_resolution["data"], all_prefs[0], all_prefs[1])
check("trip format_resolution", len(formatted) > 30, f"{len(formatted)} chars")
except Exception as e:
check("trip format_resolution", False, str(e))
# ─── 3. Dispatcher test ───────────────────────────────────────────────────────
async def test_dispatcher():
print("\n── Test 3: Feature Dispatcher ──")
from features.base_feature import get_feature
from features.scheduling import SchedulingFeature
from features.expenses import ExpensesFeature
from features.collaborative import CollaborativeFeature
from features.marketplace import MarketplaceFeature
from features.freelance import FreelanceFeature
from features.roommate import RoommateFeature
from features.trip import TripFeature
from features.conflict import ConflictFeature
from features.generic import GenericFeature
for feature_type, expected_cls in [
("scheduling", SchedulingFeature), ("expenses", ExpensesFeature),
("collaborative", CollaborativeFeature), ("marketplace", MarketplaceFeature),
("freelance", FreelanceFeature), ("roommate", RoommateFeature),
("trip", TripFeature), ("conflict", ConflictFeature),
("generic", GenericFeature), ("unknown_xyz", GenericFeature),
]:
feat = get_feature(feature_type)
check(f"get_feature('{feature_type}')", isinstance(feat, expected_cls))
# ─── Summary ─────────────────────────────────────────────────────────────────
async def main():
print("=" * 60)
print(" negoT8 — Milestone 5 Test Suite")
print("=" * 60)
await test_database()
await test_dispatcher()
await test_feature_contexts()
# Full negotiations use live Gemini + Tavily — skip if you want a fast run
await test_all_negotiations()
print("\n" + "=" * 60)
total = len(results)
passed = sum(1 for v in results.values() if v)
failed = total - passed
print(f" RESULTS: {passed}/{total} passed" + (f" | {failed} FAILED" if failed else " ✅ All passed!"))
print("=" * 60)
if failed:
print("\nFailed tests:")
for name, status in results.items():
if not status:
print(f"{name}")
sys.exit(1)
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,59 @@
# test_negotiation.py
import asyncio
import json
import os
import sys
backend_path = os.path.join(os.path.dirname(__file__), '..', 'backend')
sys.path.insert(0, backend_path)
async def on_round(data):
print(f"\n🔄 Round {data['round_number']}: {data['action']}")
print(f" Satisfaction A: {data.get('satisfaction_a', '?')}% B: {data.get('satisfaction_b', '?')}%")
print(f" Reasoning: {data['reasoning']}")
async def on_resolve(data):
print(f"\n{'='*50}")
print(f"🏁 RESULT: {data['status']} in {data['rounds_taken']} rounds")
print(f" Summary: {data['summary']}")
print(f" Timeline: {json.dumps(data.get('satisfaction_timeline', []))}")
async def test():
from agents.negotiation import run_negotiation
import database as db
await db.init_db()
# Test with DIFFERENT personalities: aggressive vs empathetic
prefs_a = {
"feature_type": "expenses",
"goal": "Split Goa trip expenses",
"constraints": [{"type": "budget", "value": None, "description": "Fair split", "hard": True}],
"preferences": [
{"type": "split", "value": "60-40 fuel", "priority": "high", "description": "I drove the whole way"},
{"type": "payment", "value": "UPI", "priority": "medium", "description": "UPI preferred"}
],
"raw_details": {"hotel": 12000, "fuel": 3000, "dinner": 2000, "upi_id": "rahul@paytm"}
}
prefs_b = {
"feature_type": "expenses",
"goal": "Split Goa trip expenses fairly",
"constraints": [{"type": "fairness", "value": "equal contribution acknowledged", "hard": False}],
"preferences": [
{"type": "split", "value": "50-50 fuel", "priority": "high", "description": "I navigated and planned"},
{"type": "payment", "value": "UPI", "priority": "medium", "description": "UPI fine"}
],
"raw_details": {"hotel": 12000, "fuel": 3000, "dinner": 2000}
}
neg_id = await db.create_negotiation("expenses", 111)
await db.add_participant(neg_id, 111, prefs_a, personality_used="aggressive")
await db.add_participant(neg_id, 222, prefs_b, personality_used="empathetic")
print("🧪 Testing: AGGRESSIVE (A) vs EMPATHETIC (B) on expense splitting\n")
result = await run_negotiation(
negotiation_id=neg_id, preferences_a=prefs_a, preferences_b=prefs_b,
user_a_id=111, user_b_id=222, feature_type="expenses",
personality_a="aggressive", personality_b="empathetic",
on_round_update=on_round, on_resolution=on_resolve
)
asyncio.run(test())

View File

@@ -0,0 +1,281 @@
"""
test_pdf_generator.py — Tests for negoT8 Deal Agreement PDF generation
Run from the project root:
cd /path/to/negot8
python test/test_pdf_generator.py
What this tests:
1. Freelance deal PDF — rich details (budget, scope, timeline, payment)
2. Marketplace (buy/sell) PDF — item, price, delivery
3. Minimal data — all optional fields absent; should still produce a valid PDF
4. Blockchain proof attached — real-looking proof dict
5. Mock blockchain proof — mock=True path
6. File is actually written to /tmp and is non-empty
7. Temp-file cleanup helper works
"""
import asyncio
import os
import sys
# ── Make sure backend/ is on the path ────────────────────────────────────────
BACKEND = os.path.join(os.path.dirname(__file__), "..", "backend")
sys.path.insert(0, os.path.abspath(BACKEND))
from tools.pdf_generator import generate_deal_pdf
# ─────────────────────────────────────────────────────────────────────────────
# Shared fixtures
# ─────────────────────────────────────────────────────────────────────────────
USER_A = {"id": 111111111, "name": "Alice Sharma", "username": "alice_s"}
USER_B = {"id": 222222222, "name": "Bob Chatterjee", "username": "bobchat"}
REAL_PROOF = {
"success": True,
"mock": False,
"tx_hash": "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890",
"block_number": 87654321,
"agreement_hash": "0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
"explorer_url": "https://amoy.polygonscan.com/tx/0xabcdef1234567890",
"gas_used": 42000,
}
MOCK_PROOF = {
"success": True,
"mock": True,
"tx_hash": "0xMOCKTX1234567890",
"block_number": 0,
"agreement_hash": "0xMOCKHASH1234567890",
"explorer_url": "",
"gas_used": 0,
}
# ─────────────────────────────────────────────────────────────────────────────
# Test helpers
# ─────────────────────────────────────────────────────────────────────────────
def _check_pdf(path: str, label: str):
"""Assert the file exists, is non-empty, and starts with the PDF magic bytes."""
assert os.path.exists(path), f"[{label}] PDF file not found at {path}"
size = os.path.getsize(path)
assert size > 500, f"[{label}] PDF suspiciously small: {size} bytes"
with open(path, "rb") as f:
magic = f.read(4)
assert magic == b"%PDF", f"[{label}] File does not start with PDF magic bytes: {magic}"
print(f" ✅ [{label}] PDF OK — {size:,} bytes → {path}")
def _cleanup(path: str):
try:
os.remove(path)
except OSError:
pass
# ─────────────────────────────────────────────────────────────────────────────
# Individual tests
# ─────────────────────────────────────────────────────────────────────────────
async def test_freelance_full():
"""Freelance deal with full details + real blockchain proof."""
final_proposal = {
"summary": "Alice will build the dashboard in 3 weeks for Rs. 45,000 with 50% upfront.",
"details": {
"budget": "45000",
"timeline": "3 weeks",
"scope": ["Admin dashboard", "REST API integration", "Mobile responsive UI"],
"payment_schedule": "50% upfront (Rs. 22,500) + 50% on delivery",
"upfront": "22500",
"ip_ownership": "Full transfer to client on final payment",
},
}
preferences_a = {
"goal": "Build a dashboard",
"raw_details": {
"role": "freelancer", "skill": "React + FastAPI",
"rate": "1500", "hours": "30", "upfront_minimum": "50",
},
}
preferences_b = {
"goal": "Hire a developer",
"raw_details": {
"role": "client", "budget": "45000",
"required_features": ["Admin dashboard", "API", "Mobile UI"],
},
}
path = await generate_deal_pdf(
negotiation_id = "freelance_test_001",
feature_type = "freelance",
final_proposal = final_proposal,
user_a = USER_A,
user_b = USER_B,
rounds_taken = 4,
sat_a = 85.0,
sat_b = 78.0,
preferences_a = preferences_a,
preferences_b = preferences_b,
blockchain_proof = REAL_PROOF,
)
_check_pdf(path, "freelance_full")
_cleanup(path)
async def test_marketplace_full():
"""Buy/sell deal with full details + mock blockchain proof."""
final_proposal = {
"summary": "iPhone 14 sold for Rs. 52,000. Pickup at Andheri station on Saturday.",
"details": {
"agreed_price": "52000",
"delivery": "Pickup — Andheri West Metro station, Saturday 4 PM",
"condition": "Used — 6 months old, no scratches",
"market_price": "55000",
},
}
preferences_a = {
"goal": "Sell iPhone 14",
"raw_details": {
"role": "seller", "item": "iPhone 14 128GB Black",
"asking_price": "55000", "minimum_price": "50000",
},
}
preferences_b = {
"goal": "Buy iPhone 14",
"raw_details": {
"role": "buyer", "item": "iPhone 14",
"max_budget": "54000", "offer_price": "49000",
},
}
path = await generate_deal_pdf(
negotiation_id = "marketplace_test_001",
feature_type = "marketplace",
final_proposal = final_proposal,
user_a = USER_A,
user_b = USER_B,
rounds_taken = 3,
sat_a = 72.0,
sat_b = 88.0,
preferences_a = preferences_a,
preferences_b = preferences_b,
blockchain_proof = MOCK_PROOF,
)
_check_pdf(path, "marketplace_full")
_cleanup(path)
async def test_minimal_data():
"""Both feature_type is unknown and all optional fields are empty — should not crash."""
path = await generate_deal_pdf(
negotiation_id = "minimal_test_001",
feature_type = "generic",
final_proposal = {"summary": "Parties agreed to share expenses equally."},
user_a = {"id": 1, "name": "", "username": "userA"},
user_b = {"id": 2, "name": "", "username": "userB"},
rounds_taken = 1,
sat_a = 60.0,
sat_b = 60.0,
blockchain_proof = None,
)
_check_pdf(path, "minimal_data")
_cleanup(path)
async def test_no_blockchain_proof():
"""Freelance deal where blockchain proof hasn't been registered yet."""
final_proposal = {
"summary": "React Native app, Rs. 80,000, 6 weeks.",
"details": {
"budget": "80000",
"timeline": "6 weeks",
"scope": ["React Native app", "Backend API"],
},
}
path = await generate_deal_pdf(
negotiation_id = "noproof_test_001",
feature_type = "freelance",
final_proposal = final_proposal,
user_a = USER_A,
user_b = USER_B,
rounds_taken = 5,
sat_a = 90.0,
sat_b = 70.0,
blockchain_proof = None,
)
_check_pdf(path, "no_blockchain_proof")
_cleanup(path)
async def test_unicode_safe():
"""
Ensure the PDF builder doesn't crash on characters outside Latin-1
(Rs. symbol ₹, em-dashes, etc.).
"""
final_proposal = {
"summary": "₹45,000 deal — React dashboard — agreed ✓",
"details": {
"budget": "₹45,000",
"timeline": "3 weeks confirmed",
"scope": ["Dashboard • Admin panel", "API • REST"],
},
}
path = await generate_deal_pdf(
negotiation_id = "unicode_test_001",
feature_type = "freelance",
final_proposal = final_proposal,
user_a = {"id": 1, "name": "Anirbán Bāsak", "username": "anirban"},
user_b = {"id": 2, "name": "Rāhul Gupta", "username": "rahul"},
rounds_taken = 2,
sat_a = 88.0,
sat_b = 82.0,
blockchain_proof = MOCK_PROOF,
)
_check_pdf(path, "unicode_safe")
_cleanup(path)
# ─────────────────────────────────────────────────────────────────────────────
# Runner
# ─────────────────────────────────────────────────────────────────────────────
TESTS = [
("Freelance full details + real blockchain proof", test_freelance_full),
("Marketplace (buy/sell) + mock proof", test_marketplace_full),
("Minimal / sparse data — no crash", test_minimal_data),
("No blockchain proof yet", test_no_blockchain_proof),
("Unicode / special chars — Latin-1 safety", test_unicode_safe),
]
async def main():
print("\n🧪 negoT8 — PDF Generator Tests")
print("=" * 52)
passed = 0
failed = 0
for name, fn in TESTS:
print(f"\n{name}")
try:
await fn()
passed += 1
except Exception as exc:
import traceback
print(f" ❌ FAILED: {exc}")
traceback.print_exc()
failed += 1
print("\n" + "=" * 52)
print(f"Results: {passed} passed | {failed} failed | {len(TESTS)} total")
if failed == 0:
print("✅ All PDF tests passed!\n")
else:
print("❌ Some tests failed — see output above.\n")
sys.exit(1)
if __name__ == "__main__":
asyncio.run(main())

View File

@@ -0,0 +1,269 @@
"""
test_pending_flow.py — Success test for Step 5: /pending counterparty flow
Tests (no live Telegram connection needed):
✅ 1. pending_coordinations dict is populated when User A coordinates
✅ 2. /pending lookup by counterparty username returns correct entries
✅ 3. Personality is stored in the pending entry
✅ 4. Accepting a request marks it as "accepted" and stores neg_id in user_data
✅ 5. Declining a request removes it from pending_coordinations
✅ 6. receive_counterparty_preferences persists BOTH participants in DB
✅ 7. Both participants have personality_used saved in participants table
✅ 8. Negotiation is created in DB with correct feature_type
✅ 9. Bot module imports cleanly (all handlers registered)
✅ 10. send_to_user silently fails when no bots are registered
Run from project root:
cd e:\\negot8
python test/test_pending_flow.py
"""
import sys
import os
import asyncio
import json
# ─── Path setup ───
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
BACKEND = os.path.join(ROOT, "backend")
BOTS_DIR = os.path.join(BACKEND, "telegram-bots")
sys.path.insert(0, BACKEND)
sys.path.insert(0, BOTS_DIR)
# ════════════════════════════════════════════════
# Helpers
# ════════════════════════════════════════════════
PASS = ""
FAIL = ""
results: list[tuple[str, str, str]] = [] # (status, name, detail)
def record(ok: bool, name: str, detail: str = ""):
status = PASS if ok else FAIL
results.append((status, name, detail))
print(f" {status} {name}" + (f" [{detail}]" if detail else ""))
# ════════════════════════════════════════════════
# Test 1 — Module import & handler registration
# ════════════════════════════════════════════════
def test_module_import():
print("\n── Test 1: Bot module imports cleanly ──")
try:
import bot # noqa: F401
record(True, "bot.py imports without errors")
except Exception as e:
record(False, "bot.py import", str(e))
return None
try:
from bot import (
pending_coordinations, bot_apps, register_bot, send_to_user,
pending_command, accept_pending_callback,
receive_counterparty_preferences, run_negotiation_with_telegram_updates,
create_bot, AWAITING_PREFERENCES, AWAITING_COUNTERPARTY_PREFS,
)
record(True, "All new symbols exported from bot.py")
return True
except ImportError as e:
record(False, "Symbol import check", str(e))
return False
# ════════════════════════════════════════════════
# Test 2 — pending_coordinations dict logic
# ════════════════════════════════════════════════
async def test_pending_dict_logic():
print("\n── Test 2: pending_coordinations dict logic ──")
import bot
# Reset shared dict for a clean test
bot.pending_coordinations.clear()
NEG_ID = "test1234"
PREFS_A = {"feature_type": "scheduling", "goal": "coffee next week",
"constraints": [], "preferences": []}
PERSONALITY = "aggressive"
# Simulate User A calling /coordinate and storing in dict (mirrors receive_preferences)
bot.pending_coordinations[NEG_ID] = {
"negotiation_id": NEG_ID,
"counterparty_username": "bob", # lowercase username
"initiator_id": 111,
"initiator_name": "Alice",
"feature_type": "scheduling",
"preferences_a": PREFS_A,
"personality_a": PERSONALITY,
"status": "pending",
}
# /pending lookup: User B (bob) checks for their requests
username_b = "bob"
matching = {
nid: d for nid, d in bot.pending_coordinations.items()
if d.get("counterparty_username", "").lower() == username_b
and d.get("status") == "pending"
}
record(len(matching) == 1, "/pending finds the request by counterparty username",
f"found {len(matching)} entry(ies)")
# Personality is stored
entry = matching[NEG_ID]
record(entry.get("personality_a") == PERSONALITY, "Personality_a stored in pending dict",
entry.get("personality_a"))
# Accept: mark as accepted, simulating accept_pending_callback
bot.pending_coordinations[NEG_ID]["status"] = "accepted"
still_pending = {
nid: d for nid, d in bot.pending_coordinations.items()
if d.get("counterparty_username", "").lower() == username_b
and d.get("status") == "pending"
}
record(len(still_pending) == 0,
"After accept, /pending no longer shows the same request")
# Decline: should remove entry
bot.pending_coordinations["test9999"] = {
"negotiation_id": "test9999",
"counterparty_username": "carol",
"initiator_id": 222,
"initiator_name": "Dave",
"feature_type": "expenses",
"preferences_a": {},
"personality_a": "balanced",
"status": "pending",
}
bot.pending_coordinations.pop("test9999", None)
record("test9999" not in bot.pending_coordinations, "Declined request removed from dict")
# ════════════════════════════════════════════════
# Test 3 — Database: both participants + personality persisted
# ════════════════════════════════════════════════
async def test_db_persistence():
print("\n── Test 3: DB persistence of both participants ──")
import database as db
await db.init_db()
NEG_ID = "pendtest1"
USER_A = 10001
USER_B = 10002
PREFS_A = {"feature_type": "scheduling", "goal": "coffee meeting",
"constraints": [], "preferences": []}
PREFS_B = {"feature_type": "scheduling", "goal": "afternoon slot",
"constraints": [], "preferences": []}
# Ensure users exist
await db.create_user(USER_A, "alice_test", "Alice")
await db.create_user(USER_B, "bob_test", "Bob")
# Create negotiation + add both participants (mirrors the full flow)
await db.create_negotiation.__wrapped__(NEG_ID, "scheduling", USER_A) \
if hasattr(db.create_negotiation, "__wrapped__") else None
# Use raw DB insert for test isolation
import aiosqlite
from config import DATABASE_PATH
async with aiosqlite.connect(DATABASE_PATH) as conn:
await conn.execute(
"INSERT OR REPLACE INTO negotiations (id, feature_type, initiator_id) VALUES (?, ?, ?)",
(NEG_ID, "scheduling", USER_A)
)
await conn.commit()
await db.add_participant(NEG_ID, USER_A, PREFS_A, personality_used="aggressive")
await db.add_participant(NEG_ID, USER_B, PREFS_B, personality_used="empathetic")
participants = await db.get_participants(NEG_ID)
record(len(participants) == 2, "Both participants stored in DB", f"count={len(participants)}")
personalities = {dict(p)["user_id"]: dict(p)["personality_used"] for p in participants}
record(personalities.get(USER_A) == "aggressive",
"User A personality_used='aggressive' persisted", str(personalities.get(USER_A)))
record(personalities.get(USER_B) == "empathetic",
"User B personality_used='empathetic' persisted", str(personalities.get(USER_B)))
# Cleanup
import aiosqlite
from config import DATABASE_PATH
async with aiosqlite.connect(DATABASE_PATH) as conn:
await conn.execute("DELETE FROM participants WHERE negotiation_id = ?", (NEG_ID,))
await conn.execute("DELETE FROM negotiations WHERE id = ?", (NEG_ID,))
await conn.commit()
# ════════════════════════════════════════════════
# Test 4 — send_to_user fails gracefully when no bots registered
# ════════════════════════════════════════════════
async def test_send_to_user_graceful():
print("\n── Test 4: send_to_user graceful failure ──")
import bot
bot.bot_apps.clear() # No bots registered
result = await bot.send_to_user(99999, "test message")
record(result is False, "send_to_user returns False when no bots are registered")
# ════════════════════════════════════════════════
# Test 5 — Conversation state constants are correct
# ════════════════════════════════════════════════
def test_conversation_states():
print("\n── Test 5: ConversationHandler state constants ──")
from bot import AWAITING_PREFERENCES, AWAITING_COUNTERPARTY_PREFS
record(AWAITING_PREFERENCES == 1,
"AWAITING_PREFERENCES == 1", str(AWAITING_PREFERENCES))
record(AWAITING_COUNTERPARTY_PREFS == 2,
"AWAITING_COUNTERPARTY_PREFS == 2", str(AWAITING_COUNTERPARTY_PREFS))
record(AWAITING_PREFERENCES != AWAITING_COUNTERPARTY_PREFS,
"States are distinct (no collision)")
# ════════════════════════════════════════════════
# Main runner
# ════════════════════════════════════════════════
async def run_all():
ok = test_module_import()
if ok is False:
print("\n⛔ Aborting — bot.py failed to import. Fix import errors first.")
return
await test_pending_dict_logic()
await test_db_persistence()
await test_send_to_user_graceful()
test_conversation_states()
# ── Summary ──
passed = sum(1 for s, _, _ in results if s == PASS)
total = len(results)
print("\n" + "=" * 60)
print(f" PENDING FLOW TESTS: {passed}/{total} passed")
print("=" * 60)
if passed == total:
print("\n🏆 ALL TESTS PASSED — /pending counterparty flow is ready!\n")
print("Next steps (live Telegram test):")
print(" 1. Run: python test/bot_runner_test.py")
print(" 2. Phone A: /coordinate @PhoneB_username")
print(" 3. Phone A: describe your preferences")
print(" 4. Phone B: /pending → tap Accept")
print(" 5. Phone B: describe their preferences")
print(" 6. Watch agents negotiate live on both phones! 🤖↔️🤖\n")
else:
failed = [(name, detail) for s, name, detail in results if s == FAIL]
print(f"\n⚠️ {total - passed} test(s) failed:")
for name, detail in failed:
print(f"{name}: {detail}")
print()
if __name__ == "__main__":
asyncio.run(run_all())

View File

@@ -0,0 +1,37 @@
import asyncio
import json
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'backend'))
from agents.personal_agent import PersonalAgent
from personality.profiles import get_personality_modifier
async def test():
agent = PersonalAgent()
# Test 1: Scheduling
r1 = await agent.extract_preferences("Find time for coffee with Priya next week. I'm free Mon-Wed afternoons.")
print("TEST 1 (scheduling):", r1.get("feature_type"), "" if r1.get("feature_type") == "scheduling" else "")
# Test 2: Expenses (with UPI mention)
r2 = await agent.extract_preferences("Split our Goa trip costs. I paid 12K hotel, 3K fuel. Fuel should be 60-40 since I drove. My UPI is rahul@paytm")
print("TEST 2 (expenses):", r2.get("feature_type"), "" if r2.get("feature_type") == "expenses" else "")
print(" UPI extracted:", "rahul@paytm" in json.dumps(r2), "" if "rahul@paytm" in json.dumps(r2) else "⚠️ UPI not found")
# Test 3: Marketplace
r3 = await agent.extract_preferences("I want to sell my PS5 to this guy. Asking 35K, minimum 30K, has 2 controllers.")
print("TEST 3 (marketplace):", r3.get("feature_type"), "" if r3.get("feature_type") == "marketplace" else "")
# Test 4: Generic
r4 = await agent.extract_preferences("Figure out with @dave who brings what to the BBQ party Saturday")
print("TEST 4 (generic):", r4.get("feature_type"), "" if r4.get("feature_type") in ("generic", "collaborative") else "")
# Test 5: Personality profiles load
for p in ["aggressive", "people_pleaser", "analytical", "empathetic", "balanced"]:
mod = get_personality_modifier(p)
print(f"PERSONALITY {p}: {'' if len(mod) > 50 else ''} ({len(mod)} chars)")
print("\nFull output Test 2:", json.dumps(r2, indent=2))
asyncio.run(test())

View File

@@ -0,0 +1,19 @@
# test_telegram.py (run standalone)
import asyncio
import sys
from telegram import Update
from telegram.ext import Application, CommandHandler, ContextTypes
import os
from dotenv import load_dotenv
load_dotenv()
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'backend'))
async def start(update: Update, context: ContextTypes.DEFAULT_TYPE):
await update.message.reply_text("🤖 negoT8 Bot A is alive!")
app = Application.builder().token(os.getenv("TELEGRAM_BOT_TOKEN_A")).build()
app.add_handler(CommandHandler("start", start))
print("Bot A running... Press Ctrl+C to stop")
app.run_polling()

26
negot8/test/test_tools.py Normal file
View File

@@ -0,0 +1,26 @@
import asyncio
import os
import sys
backend_path = os.path.join(os.path.dirname(__file__), '..', 'backend')
sys.path.insert(0, backend_path)
from tools.tavily_search import TavilySearchTool
from tools.upi_generator import UPIGeneratorTool
from tools.calculator import CalculatorTool
async def test():
# Tavily
tavily = TavilySearchTool()
r = await tavily.execute("best Thai restaurants Bandra Mumbai")
print(f"Tavily: {r['answer'][:100]}... ({len(r['results'])} results) ✅")
# UPI
upi = UPIGeneratorTool()
r = await upi.execute("rahul@paytm", "Rahul", 8200, "Goa trip settlement")
print(f"UPI: {r['upi_link'][:60]}... ✅")
# Calculator
calc = CalculatorTool()
r = await calc.execute("12000 * 0.55")
print(f"Calc: 12000 * 0.55 = {r['result']}")
asyncio.run(test())