An AI that interrogates along ESG-native axes, then competes with the market's view — disagreeing with evidence, not picking.
An ESG tool that does four things:
- Thinks — asks adaptive questions instead of dumping a dashboard, and shows why it asks each one (its chain of thought) (Stage 1)
- Challenges — pushes back on your framing when it's wrong (Stage 1)
- Competes — takes a position against the stale rating using the live signal + the historical trend the rating can't see, grounded in real sources it fetches live (RAG) (Stage 2) — the differentiator
- Shows its work — every verdict ships with a reasoning trail and its sources (Stage 3)
It is not a stock picker. It disagrees with ratings; it never says buy / sell / hold, and it never gives a score.
The home screen is a 3-column monitoring dashboard over an ASEAN base universe — 52 ASEAN
companies with consistent ESG improvement 2019–2023 (the ESG Momentum foundation basket;
MSCI ASEAN is the benchmark it beat, 55.1% vs 6.4% — not the source of names). Each name carries
its evidence (esg_basis + source_url + confidence).
- Left — Filters + Avg ESG. Pick an Industry / Country; the average ESG of the filtered set recomputes live (change to Banks → you get the Banks average). Plus your Monitored list.
- Center — the read. Four pillar cards (avg Environment / Social / Governance / Digital-AI momentum), a 90-day momentum chart, Hidden winners vs peer average, and a Classification verdict (e.g. HIDDEN WINNER — never buy/sell/hold).
- Right — the AI assistant. It controls the filters by chat ("show banks", "Singapore", "all ASEAN"), adds/focuses companies, and drives the Live signals + Why the rating may be wrong panels for the focused name.
- Click a card → deep dive — the full Stage 1→2→3 relay (interrogate, or "⚔️ Compete now").
The aggregation lives in metrics.py (pure, tested), with three auto-detected data modes:
- Numeric — full per-company numbers (
esg_score,momentum.{environment,social,governance, digital_ai},live_signals.…) drive every panel exactly like the design. - Evidence (the real 52 today) — no numbers yet, but each name carries an
esg_basis, so the radar derives a grounded ESG-leadership score (0–100) + credential rows from the ratings the evidence actually cites (MSCI / DJSI / CDP A-List / FTSE4Good / Sustainalytics / GRESB / national ESG indices). Avg ESG-leadership, the ESG-leaders ranking and the classification all work; pillar momentum + live signals honestly say "awaiting the alt-data feed." Nothing invented. - Empty — neither numbers nor evidence → "awaiting data."
A Demo data toggle swaps in data/demo_universe.json (fictional, fully numeric) to show the
full numeric vision. Pinned tickers persist in data/watchlist.json.
The slide's "what's missing" — AI adoption, alt-data (news/sentiment), real-time vs annual-report lag — is exactly our Layer B: the foundation says these 52 improved on paper; the radar tests that with the live signals the rating can't see.
The demo basket's company ESG scores are synthetic but principled — derived from real country-level indicators, because company-level ESG (MSCI/Sustainalytics) is paywalled.
- What's real: OECD / World Bank country indicators, live-fetched on startup with a cached
CSV fallback (
data/fallback_oecd_wgi_asean.csv): WGI governance (Rule of Law, Control of Corruption, Regulatory Quality), CO₂ per capita and renewable-energy share (E), labour-force participation and the female/male participation ratio (S). - What's synthetic: the company names and their scores. Each demo company takes its
country's normalized E/S/G baseline, applies a sector materiality tilt (banks weight
governance higher, power/utilities weight environment higher, …) and a small seeded variance
so same-country peers differ. Defined in
data/demo_config.json; generated bybuild_demo_universe.py. - Why: it lets us demonstrate a transparent, tunable ESG methodology grounded in real macro
data without claiming paywalled company disclosures. Every demo score carries a
data_provenanceline saying so. Weights live as named constants inesg_scoring.py(INDICATOR_WEIGHTS,PILLAR_WEIGHTS,SECTOR_MULTIPLIERS,VARIANCE_PCT) — tune and re-run. - Scores are 0–100, higher = better, and describe only — never buy/sell/hold.
Regenerate after tuning: PYTHONPATH=. ./venv/bin/python -m scripts.build_demo_universe.
Override the country data with your own OECD/WGI CSV via the app's upload, or by replacing
data/fallback_oecd_wgi_asean.csv.
Each stage is a fresh LLM agent that hands the next only its verified output (the "baton"). Stage 2 never sees Stage 1's chat — only the narrowed question.
vague question
→ Stage 1 Interrogate ......... emits a NarrowedQuestion (LLM only, no data; + visible CoT)
→ Stage 2 Compete over data ... emits a Stage2Answer (LLM + company data + history + live RAG)
→ Stage 3 Render .............. verdict · chain-of-thought · history · sources
The tool analyses one company at a time, from one of three data sources (datasource.py):
- 🌐 Live — type what you want to invest in (e.g. "I want to invest in Nvidia"). It
identifies the company, fetches real documents, and the LLM builds an ESG profile
grounded only in those sources — anything not found is
"unknown", never invented. - 📤 Upload — bring your own ESG data:
.jsonis used as-is (a template is in the sidebar);.csv/.txtare read by the AI, grounded only in your file. - 🧪 Sample — the offline
data/hero_company.json(DemoBank SG), a bulletproof demo / test fixture when there's no network or key.
The interrogation is anchored to whichever company is loaded.
Live retrieval (RAG). Before competing, Stage 2 fetches real external context for the
narrowed question from DuckDuckGo — its HTML/Lite search results plus the Instant-Answer
AI summary, no API key — and ranks the snippets with a pure-Python TF-IDF retriever
(rag.py). The agent interprets the AI summary; the top snippets ground the reasoning and
surface as clickable sources. All outbound HTTP is isolated in
core.http_get(); retrieval is best-effort — if the network is down it degrades to a
dataset-only run (toggle it off entirely with the sidebar 🌐 Live retrieval switch). The
company dataset stays local and placeholder; the web only adds grounding context, never
fabricated company facts.
The app is now a React frontend + FastAPI backend over the same Python brain
(core / contracts / metrics / universe / rag / datasource / stage1 / stage2
are unchanged; Stage 3's rendering moved into React). Requires Python 3.8+, Node 18+,
and a DeepSeek API key (DEEPSEEK_API_KEY) for the relay — the dashboard, sample demo and
all pure metrics run fine without one.
python -m venv venv && . venv/bin/activate # Windows: venv\Scripts\activate
pip install -r requirements.txt
export DEEPSEEK_API_KEY="sk-..." # Windows: $env:DEEPSEEK_API_KEY="sk-..."
python server.py # API + built UI on http://localhost:8000cd web
npm install
npm run build # production bundle -> web/dist (served by server.py)
npm run dev # OR dev server on http://localhost:5173 (proxies /api -> :8000)- The board opens on the fictional demo universe (fully numeric, labelled illustrative). Toggle 🎛 Demo data in the header for the real evidence-based ASEAN base DB.
- Chat with the assistant (right rail, “‹ Assistant”): "show banks", "Singapore", "analyze DemoBank" (compete) or "interrogate Maybank".
- In a deep dive: answer the 2–4 adaptive questions (or ⚔️ Compete now), then Reason over the data — Stage 2 streams its progress live (RAG status → phases → tokens) and lands on the verdict, chain of thought, market-vs-reality, and sources.
- 🎬 Sample loads the offline DemoBank SG fixture (no network or key needed).
💡 In the 🧪 Sample scenario, the hidden signal is an undisclosed AI-governance build-out (+340% hiring, zero disclosure) ahead of the MAS AI guidelines — the sharpest demo steers the interrogation toward that AI/digital blind-spot. For live companies the differentiator is whatever real gap the fetched sources reveal.
The React app in
web/is the primary frontend, served by the FastAPI backend inserver.py.
| Env var | Required | Default | Notes |
|---|---|---|---|
DEEPSEEK_API_KEY |
yes | — | Your DeepSeek key. Read from env only, never hard-coded. |
DEEPSEEK_MODEL |
no | deepseek-v4-flash |
Override the model, e.g. deepseek-chat. |
ESG_HTTP_TIMEOUT |
no | 10 |
Per-request fetch timeout (seconds) for live retrieval. |
ESG_LLM_TIMEOUT |
no | 30 |
Per-request LLM timeout (seconds); stops a stalled endpoint hanging the UI. |
ESG_RAG_TOP_K |
no | 5 |
How many retrieved snippets to ground the reasoning. |
ESG_RAG_MAX_DOCS |
no | 12 |
Max documents fetched per query. |
ESG_RAG_TTL |
no | 21600 |
Retrieval cache lifetime (seconds; default 6h). |
ESG_USER_AGENT |
no | (set) | User-Agent sent on fetches. |
All LLM calls go through core.call_llm() (DeepSeek endpoint https://api.deepseek.com,
OpenAI-compatible); all web fetches go through core.http_get(). Live retrieval needs no
API key and caches to .cache/ (git-ignored). No network? The app still runs — it just
reasons on the dataset alone.
core.py # shared: LLM client (call_llm), live-fetch (http_get), JSON parser, config, data loader
contracts.py # the handoff data shapes (A: NarrowedQuestion, B: CompanyData, C: Stage2Answer)
rag.py # live retrieval (RAG): DuckDuckGo search + AI summary + TF-IDF rank
datasource.py # build CompanyData LIVE (grounded) or load an UPLOAD (json/csv/txt)
server.py # FastAPI backend — REST + SSE over the Python brain (the React app's API)
web/ # React + TypeScript frontend (Vite) — the command center + deep-dive relay
stage1.py # interrogation agent (+ visible chain-of-thought rationale)
stage2.py # competing reasoner (+ RAG, history, CoT, sources)
stage3.py # dependency-free Contract C Markdown/text export helpers
data/hero_company.json # SAMPLE only — offline-demo + test fixture (PLACEHOLDER values)
fixtures/ # sample contract outputs (used by tests / as batons)
selftest.py # offline end-to-end check (no key, no network) — incl. RAG + datasource tests
debug_llm.py # one-shot probe of your DeepSeek endpoint
.cache/ # live-retrieval cache (auto-created, git-ignored)
requirements.txt
| Endpoint | Purpose |
|---|---|
GET /api/health |
liveness + whether DEEPSEEK_API_KEY is set |
GET /api/board |
the whole dashboard state for a filter set (pure metrics, no LLM) |
POST /api/chat |
the assistant intent parser → structured action (filter / focus / relay) |
POST /api/monitor |
build + pin a snapshot (universe ticker or free-text live build) |
DELETE /api/monitor/{ticker} |
unpin |
GET /api/entry/{ticker} |
deep-dive payload (Contract B + snapshot + cached batons) |
POST /api/sample · POST /api/upload |
offline sample / uploaded ESG file |
POST /api/stage1/ask · POST /api/stage1/narrow |
interrogation turns → Contract A |
POST /api/stage2/run |
SSE stream: RAG status → phase labels → token deltas → Contract C |
POST /api/compare |
side-by-side data for 2–4 built snapshots |
Offline self-test (no API key or network needed) — validates the contracts and the relay wiring with a mocked LLM:
python selftest.pyProbe the live endpoint — shows exactly what DeepSeek returns (useful if Stage 1/2 loop or come back empty):
python debug_llm.py # needs DEEPSEEK_API_KEY setCALL FAILED: NotFoundError→ wrong model name → set a working model and re-run: PowerShell$env:DEEPSEEK_MODEL="deepseek-chat"· cmdset DEEPSEEK_MODEL=deepseek-chatRAW REPR: ''→ empty contentPARSED: {...}→ JSON works; the app should too
In-app debug — tick "🐞 Show raw model output" in the sidebar to see each turn's raw model response inline.
- Live fetch is allowed but isolated (
core.http_get) and best-effort — a dead network degrades to a data-only run, never a crash. No DB. - Never fabricate — the model (and the live data builder) reason only over provided inputs/sources; a missing company fact is "unknown", never invented or estimated. Sources are the real retrieved URLs, never made up.
- Data is labelled by origin: the sample (
hero_company.json) is placeholder and never shown as real; live/upload data is real and labelled as such. Output is never advice. - Never give buy / sell / hold or a score.
- The API key is read from the environment only.
Built for a CGSI hackathon. Sample data is illustrative placeholder content.