mirror of
https://github.com/arkorty/B.Tech-Project-III.git
synced 2026-04-19 12:41:48 +00:00
init
This commit is contained in:
54
negot8/.env.example
Normal file
54
negot8/.env.example
Normal 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
46
negot8/.gitignore
vendored
Normal 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
816
negot8/README.md
Normal 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
138
negot8/SETUP.md
Normal 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.
|
||||
0
negot8/backend/agents/__init__.py
Normal file
0
negot8/backend/agents/__init__.py
Normal file
57
negot8/backend/agents/base_agent.py
Normal file
57
negot8/backend/agents/base_agent.py
Normal 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)}
|
||||
111
negot8/backend/agents/matching_agent.py
Normal file
111
negot8/backend/agents/matching_agent.py
Normal 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
|
||||
)
|
||||
125
negot8/backend/agents/negotiation.py
Normal file
125
negot8/backend/agents/negotiation.py
Normal 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
|
||||
97
negot8/backend/agents/negotiator_agent.py
Normal file
97
negot8/backend/agents/negotiator_agent.py
Normal 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."""
|
||||
)
|
||||
51
negot8/backend/agents/personal_agent.py
Normal file
51
negot8/backend/agents/personal_agent.py
Normal 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
491
negot8/backend/api.py
Normal 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)
|
||||
1
negot8/backend/blockchain_web3/__init__.py
Normal file
1
negot8/backend/blockchain_web3/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
pass
|
||||
210
negot8/backend/blockchain_web3/blockchain.py
Normal file
210
negot8/backend/blockchain_web3/blockchain.py
Normal 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
|
||||
67
negot8/backend/blockchain_web3/contract_abi.py
Normal file
67
negot8/backend/blockchain_web3/contract_abi.py
Normal 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
25
negot8/backend/config.py
Normal 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
568
negot8/backend/database.py
Normal 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
|
||||
3
negot8/backend/features/__init__.py
Normal file
3
negot8/backend/features/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from features.base_feature import get_feature, BaseFeature
|
||||
|
||||
__all__ = ["get_feature", "BaseFeature"]
|
||||
65
negot8/backend/features/base_feature.py
Normal file
65
negot8/backend/features/base_feature.py
Normal 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()
|
||||
121
negot8/backend/features/collaborative.py
Normal file
121
negot8/backend/features/collaborative.py
Normal 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)
|
||||
104
negot8/backend/features/conflict.py
Normal file
104
negot8/backend/features/conflict.py
Normal 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)
|
||||
114
negot8/backend/features/expenses.py
Normal file
114
negot8/backend/features/expenses.py
Normal 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)
|
||||
142
negot8/backend/features/freelance.py
Normal file
142
negot8/backend/features/freelance.py
Normal 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)
|
||||
62
negot8/backend/features/generic.py
Normal file
62
negot8/backend/features/generic.py
Normal 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)
|
||||
119
negot8/backend/features/marketplace.py
Normal file
119
negot8/backend/features/marketplace.py
Normal 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)
|
||||
123
negot8/backend/features/roommate.py
Normal file
123
negot8/backend/features/roommate.py
Normal 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)
|
||||
148
negot8/backend/features/scheduling.py
Normal file
148
negot8/backend/features/scheduling.py
Normal 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)
|
||||
338
negot8/backend/features/trip.py
Normal file
338
negot8/backend/features/trip.py
Normal 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
|
||||
2
negot8/backend/mock/__init__.py
Normal file
2
negot8/backend/mock/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
# Mock mode permanently disabled — all calls go to real Gemini
|
||||
MOCK_MODE = False
|
||||
171
negot8/backend/mock/responses.py
Normal file
171
negot8/backend/mock/responses.py
Normal 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"])
|
||||
0
negot8/backend/personality/__init__.py
Normal file
0
negot8/backend/personality/__init__.py
Normal file
45
negot8/backend/personality/profiles.py
Normal file
45
negot8/backend/personality/profiles.py
Normal 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"])
|
||||
24
negot8/backend/protocol/messages.py
Normal file
24
negot8/backend/protocol/messages.py
Normal 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
323
negot8/backend/run.py
Normal 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
31
negot8/backend/serve.py
Normal 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
|
||||
0
negot8/backend/telegram-bots/__init__.py
Normal file
0
negot8/backend/telegram-bots/__init__.py
Normal file
1718
negot8/backend/telegram-bots/bot.py
Normal file
1718
negot8/backend/telegram-bots/bot.py
Normal file
File diff suppressed because it is too large
Load Diff
1
negot8/backend/test_eof
Normal file
1
negot8/backend/test_eof
Normal file
@@ -0,0 +1 @@
|
||||
EOF
|
||||
19
negot8/backend/tools/calculator.py
Normal file
19
negot8/backend/tools/calculator.py
Normal 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}
|
||||
279
negot8/backend/tools/google_calendar.py
Normal file
279
negot8/backend/tools/google_calendar.py
Normal 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
|
||||
513
negot8/backend/tools/pdf_generator.py
Normal file
513
negot8/backend/tools/pdf_generator.py
Normal 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 (0–100)
|
||||
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
|
||||
25
negot8/backend/tools/tavily_search.py
Normal file
25
negot8/backend/tools/tavily_search.py
Normal 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)}"}
|
||||
16
negot8/backend/tools/upi_generator.py
Normal file
16
negot8/backend/tools/upi_generator.py
Normal 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
|
||||
}
|
||||
52
negot8/backend/voice/elevenlabs_tts.py
Normal file
52
negot8/backend/voice/elevenlabs_tts.py
Normal 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]
|
||||
53
negot8/contracts/AgreementRegistry.sol
Normal file
53
negot8/contracts/AgreementRegistry.sol
Normal 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;
|
||||
}
|
||||
}
|
||||
81
negot8/contracts/deploy.py
Normal file
81
negot8/contracts/deploy.py
Normal 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
41
negot8/dashboard/.gitignore
vendored
Normal 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
|
||||
36
negot8/dashboard/README.md
Normal file
36
negot8/dashboard/README.md
Normal 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.
|
||||
376
negot8/dashboard/app/analytics/page.tsx
Normal file
376
negot8/dashboard/app/analytics/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
462
negot8/dashboard/app/dashboard/page.tsx
Normal file
462
negot8/dashboard/app/dashboard/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
413
negot8/dashboard/app/docs/page.tsx
Normal file
413
negot8/dashboard/app/docs/page.tsx
Normal 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: 0x8fb…442b</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>
|
||||
);
|
||||
}
|
||||
BIN
negot8/dashboard/app/favicon.ico
Normal file
BIN
negot8/dashboard/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
114
negot8/dashboard/app/globals.css
Normal file
114
negot8/dashboard/app/globals.css
Normal 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; }
|
||||
293
negot8/dashboard/app/history/page.tsx
Normal file
293
negot8/dashboard/app/history/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
negot8/dashboard/app/layout.tsx
Normal file
43
negot8/dashboard/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
335
negot8/dashboard/app/negotiation/[id]/page.tsx
Normal file
335
negot8/dashboard/app/negotiation/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
353
negot8/dashboard/app/negotiation/[id]/resolved/page.tsx
Normal file
353
negot8/dashboard/app/negotiation/[id]/resolved/page.tsx
Normal 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> & <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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
474
negot8/dashboard/app/page.tsx
Normal file
474
negot8/dashboard/app/page.tsx
Normal 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: 0x8a…9f2c · 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 >> 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
351
negot8/dashboard/app/preferences/page.tsx
Normal file
351
negot8/dashboard/app/preferences/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
188
negot8/dashboard/app/profile/page.tsx
Normal file
188
negot8/dashboard/app/profile/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
71
negot8/dashboard/components/ConcessionTimeline.tsx
Normal file
71
negot8/dashboard/components/ConcessionTimeline.tsx
Normal 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[];
|
||||
}
|
||||
65
negot8/dashboard/components/FairnessScore.tsx
Normal file
65
negot8/dashboard/components/FairnessScore.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
161
negot8/dashboard/components/NegotiationTimeline.tsx
Normal file
161
negot8/dashboard/components/NegotiationTimeline.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
|
||||
184
negot8/dashboard/components/ResolutionCard.tsx
Normal file
184
negot8/dashboard/components/ResolutionCard.tsx
Normal 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;
|
||||
}
|
||||
96
negot8/dashboard/components/SatisfactionChart.tsx
Normal file
96
negot8/dashboard/components/SatisfactionChart.tsx
Normal 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[];
|
||||
}
|
||||
|
||||
87
negot8/dashboard/components/Sidebar.tsx
Normal file
87
negot8/dashboard/components/Sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
negot8/dashboard/lib/api.ts
Normal file
19
negot8/dashboard/lib/api.ts
Normal 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`),
|
||||
};
|
||||
37
negot8/dashboard/lib/socket.ts
Normal file
37
negot8/dashboard/lib/socket.ts
Normal 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 });
|
||||
}
|
||||
104
negot8/dashboard/lib/types.ts
Normal file
104
negot8/dashboard/lib/types.ts
Normal 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 }[];
|
||||
}
|
||||
86
negot8/dashboard/lib/utils.ts
Normal file
86
negot8/dashboard/lib/utils.ts
Normal 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`;
|
||||
}
|
||||
14
negot8/dashboard/next.config.ts
Normal file
14
negot8/dashboard/next.config.ts
Normal 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
2195
negot8/dashboard/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
negot8/dashboard/package.json
Normal file
26
negot8/dashboard/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
7
negot8/dashboard/postcss.config.mjs
Normal file
7
negot8/dashboard/postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
negot8/dashboard/public/file.svg
Normal file
1
negot8/dashboard/public/file.svg
Normal 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 |
1
negot8/dashboard/public/globe.svg
Normal file
1
negot8/dashboard/public/globe.svg
Normal 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 |
1
negot8/dashboard/public/next.svg
Normal file
1
negot8/dashboard/public/next.svg
Normal 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 |
1
negot8/dashboard/public/vercel.svg
Normal file
1
negot8/dashboard/public/vercel.svg
Normal 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 |
1
negot8/dashboard/public/window.svg
Normal file
1
negot8/dashboard/public/window.svg
Normal 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 |
34
negot8/dashboard/tsconfig.json
Normal file
34
negot8/dashboard/tsconfig.json
Normal 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"]
|
||||
}
|
||||
3004
negot8/docs/build-guide-new.md
Normal file
3004
negot8/docs/build-guide-new.md
Normal file
File diff suppressed because it is too large
Load Diff
1595
negot8/docs/milestone.md
Normal file
1595
negot8/docs/milestone.md
Normal file
File diff suppressed because it is too large
Load Diff
446
negot8/docs/negoT8_Blockchain_Guide.md
Normal file
446
negot8/docs/negoT8_Blockchain_Guide.md
Normal 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 2–4 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
1880
negot8/docs/new-milestone.md
Normal file
File diff suppressed because it is too large
Load Diff
1330
negot8/docs/web3-milestone-revised (1).md
Normal file
1330
negot8/docs/web3-milestone-revised (1).md
Normal file
File diff suppressed because it is too large
Load Diff
52
negot8/requirements.txt
Normal file
52
negot8/requirements.txt
Normal 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
|
||||
112
negot8/test/bot_runner_test.py
Normal file
112
negot8/test/bot_runner_test.py
Normal 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
47
negot8/test/test_apis.py
Normal 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!")
|
||||
327
negot8/test/test_milestone4.py
Normal file
327
negot8/test/test_milestone4.py
Normal 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())
|
||||
412
negot8/test/test_milestone5_all.py
Normal file
412
negot8/test/test_milestone5_all.py
Normal 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())
|
||||
59
negot8/test/test_negotiation.py
Normal file
59
negot8/test/test_negotiation.py
Normal 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())
|
||||
281
negot8/test/test_pdf_generator.py
Normal file
281
negot8/test/test_pdf_generator.py
Normal 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())
|
||||
269
negot8/test/test_pending_flow.py
Normal file
269
negot8/test/test_pending_flow.py
Normal 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())
|
||||
37
negot8/test/test_personal_agent.py
Normal file
37
negot8/test/test_personal_agent.py
Normal 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())
|
||||
19
negot8/test/test_telegram.py
Normal file
19
negot8/test/test_telegram.py
Normal 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
26
negot8/test/test_tools.py
Normal 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())
|
||||
Reference in New Issue
Block a user