Skip to content

Commit cb54d82

Browse files
committed
Add offline unit tests, CI workflow, MIT license, and metrics-honesty note
1 parent b473815 commit cb54d82

6 files changed

Lines changed: 220 additions & 0 deletions

File tree

.github/workflows/tests.yml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
name: tests
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
steps:
13+
- uses: actions/checkout@v4
14+
15+
- name: Set up Python
16+
uses: actions/setup-python@v5
17+
with:
18+
python-version: "3.12"
19+
20+
- name: Install dependencies
21+
run: |
22+
python -m pip install --upgrade pip
23+
pip install -r requirements.txt
24+
25+
- name: Run tests
26+
env:
27+
GROQ_API_KEY: test_dummy_key
28+
run: python -m unittest discover -s tests -v

LICENSE

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
MIT License
2+
3+
Copyright (c) 2026 K Jayarama Das
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.

README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
# EcoPrompt — Energy-Efficient AI Prompt Routing
22

3+
![tests](https://github.com/jayaram-07/ecoprompt/actions/workflows/tests.yml/badge.svg)
4+
![license](https://img.shields.io/badge/license-MIT-green)
5+
36
> A hierarchical router that answers each prompt with the **cheapest, lowest-energy engine that can do the job** — sending trivial queries to deterministic/local handlers and reserving large LLMs only for prompts that genuinely need them. The result: lower latency, lower cost, and less compute/carbon per query.
47
58
**🔗 [Live demo](https://frontend-two-indol-16.vercel.app/)**  ·  Frontend on Vercel · Backend on Google Cloud Run
@@ -49,6 +52,14 @@ Every request records latency, estimated energy (kWh), and estimated cost per ro
4952
- Groq Llama 3 70B: ~$0.70 / 1M tokens
5053
- Electricity: ₹8.00 / kWh (India avg)
5154

55+
> **A note on the numbers (honesty matters).** The energy and CO₂ figures are
56+
> **estimates, not hardware measurements.** Energy is modeled as
57+
> `latency × assumed power draw` and cost is derived from the published
58+
> per-token prices above. They're meant to illustrate the *relative* savings of
59+
> routing cheap-first — not to be billed against. The one thing measured
60+
> directly is **cloud-avoidance rate** (the share of prompts answered without a
61+
> paid LLM call), which is the metric that actually drives the savings.
62+
5263
## Tech stack
5364

5465
**Backend** — Python, FastAPI, Uvicorn · [Groq](https://groq.com) (Llama 3.1 8B / Llama 3 70B) · Google Gemini (grounded search) · custom deterministic + RAG engines
@@ -85,13 +96,26 @@ See [`.env.example`](.env.example). You'll need:
8596
- `GROQ_API_KEY` — from [console.groq.com](https://console.groq.com) (powers the local/large LLM tiers)
8697
- `GEMINI_API_KEY` — from [Google AI Studio](https://aistudio.google.com) (powers the grounded web-search tier)
8798

99+
## Tests
100+
Offline unit tests cover the routing-decision logic (complexity scoring, the
101+
simple-prompt fast path, token budgeting, source-host matching, energy estimate)
102+
and the KB tokenizer. They make no API calls.
103+
104+
```bash
105+
python -m unittest discover -s tests -v
106+
```
107+
108+
CI runs them on every push via GitHub Actions (see the badge above).
109+
88110
## Project layout
89111
```
90112
main.py FastAPI app — routing cascade, metrics, streaming
91113
deterministic.py Tier-1 rule/lookup engine
92114
kb/ Knowledge-base lookups + RAG engine (geography, math, science, …)
115+
tests/ Offline unit tests for routing + tokenizer logic
93116
diagrams/ Architecture diagrams (.png/.svg/.mmd)
94117
frontend/ Vite + React + Tailwind UI and metrics dashboard
118+
.github/workflows/ CI (runs the test suite)
95119
```
96120

97121
## Roadmap

tests/conftest.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"""Test setup: provide a dummy API key so `import main` succeeds without real
2+
credentials. The routing-helper functions under test are pure and never make
3+
network calls, so a placeholder key is sufficient.
4+
"""
5+
import os
6+
7+
os.environ.setdefault("GROQ_API_KEY", "test_dummy_key")

tests/test_kb_utils.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
"""Unit tests for the knowledge-base tokenizer used by the local KB routes.
2+
3+
The tokenizer normalizes prompts (lowercasing, stopword removal, synonym
4+
folding) so cheap local lookups can match curated knowledge without an LLM.
5+
Run with: python -m unittest discover -s tests
6+
"""
7+
import os
8+
import sys
9+
import unittest
10+
11+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
12+
13+
from kb.kb_utils import tokenize # noqa: E402
14+
15+
16+
class Tokenize(unittest.TestCase):
17+
def test_lowercases_and_removes_stopwords(self):
18+
self.assertEqual(tokenize("What is the capital of France"), ["capital", "france"])
19+
20+
def test_strips_punctuation(self):
21+
self.assertEqual(tokenize("Photosynthesis?"), ["photosynthesis"])
22+
23+
def test_synonym_money_to_currency(self):
24+
self.assertIn("currency", tokenize("what is the money"))
25+
self.assertNotIn("money", tokenize("what is the money"))
26+
27+
def test_synonym_leader_to_president(self):
28+
self.assertEqual(tokenize("who is the leader of India"), ["president", "india"])
29+
30+
def test_empty_string_returns_empty_list(self):
31+
self.assertEqual(tokenize(""), [])
32+
33+
34+
if __name__ == "__main__":
35+
unittest.main()

tests/test_routing_helpers.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
"""Unit tests for EcoPrompt's pure routing-decision helpers.
2+
3+
These cover the logic that decides *which tier* answers a prompt and how many
4+
tokens to budget — the core of the energy-efficiency story. They run offline
5+
(no API calls). Run with: python -m unittest discover -s tests
6+
"""
7+
import os
8+
import sys
9+
import unittest
10+
11+
# Dummy key so `import main` (which constructs a Groq client) succeeds offline.
12+
os.environ.setdefault("GROQ_API_KEY", "test_dummy_key")
13+
# Allow `import main` when tests are run from the repo root.
14+
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
15+
16+
import main # noqa: E402
17+
18+
19+
class PromptComplexityScore(unittest.TestCase):
20+
def test_trivial_prompt_scores_zero(self):
21+
self.assertEqual(main.prompt_complexity_score("hi"), 0)
22+
23+
def test_short_factual_prompt_is_low(self):
24+
self.assertLess(main.prompt_complexity_score("what is photosynthesis"), 4)
25+
26+
def test_long_multiclause_prompt_is_high(self):
27+
prompt = (
28+
"Compare and contrast monolithic versus microservices architecture, "
29+
"because I need a detailed analysis of the tradeoffs"
30+
)
31+
self.assertGreaterEqual(main.prompt_complexity_score(prompt), 4)
32+
33+
def test_realtime_keyword_raises_score(self):
34+
# "latest"/"today" should push a prompt toward the heavier routes.
35+
with_realtime = main.prompt_complexity_score("what is the latest news today")
36+
without = main.prompt_complexity_score("what is the news")
37+
self.assertGreater(with_realtime, without)
38+
39+
40+
class IsSimplePrompt(unittest.TestCase):
41+
def test_simple_definitional_prompt(self):
42+
self.assertTrue(main.is_simple_prompt("what is photosynthesis"))
43+
44+
def test_complex_prompt_is_not_simple(self):
45+
self.assertFalse(
46+
main.is_simple_prompt(
47+
"Compare and contrast microservices versus monolith in detail"
48+
)
49+
)
50+
51+
def test_long_prompt_is_not_simple(self):
52+
long_prompt = "explain " + "word " * 20
53+
self.assertFalse(main.is_simple_prompt(long_prompt))
54+
55+
56+
class SelectMaxTokens(unittest.TestCase):
57+
def test_code_request_gets_largest_budget(self):
58+
self.assertEqual(
59+
main.select_max_tokens("write a python function to sort a list"), 420
60+
)
61+
62+
def test_simple_prompt_gets_smallest_budget(self):
63+
self.assertEqual(main.select_max_tokens("what is photosynthesis"), 120)
64+
65+
def test_complex_prompt_gets_large_budget(self):
66+
prompt = (
67+
"Compare and contrast monolithic versus microservices architecture, "
68+
"because I need a detailed analysis of the tradeoffs"
69+
)
70+
self.assertEqual(main.select_max_tokens(prompt), 400)
71+
72+
def test_default_budget(self):
73+
self.assertEqual(main.select_max_tokens("tell me a fun fact about dogs"), 280)
74+
75+
76+
class HostnameHelpers(unittest.TestCase):
77+
def test_extract_hostname_strips_www_and_lowercases(self):
78+
self.assertEqual(main.extract_hostname("https://www.BBC.com/news/x"), "bbc.com")
79+
80+
def test_extract_hostname_handles_bad_url(self):
81+
self.assertEqual(main.extract_hostname("not a url"), "")
82+
83+
def test_hostname_matches_exact(self):
84+
self.assertTrue(main.hostname_matches("bbc.com", "bbc.com"))
85+
86+
def test_hostname_matches_subdomain(self):
87+
self.assertTrue(main.hostname_matches("news.bbc.com", "bbc.com"))
88+
89+
def test_hostname_does_not_match_unrelated(self):
90+
self.assertFalse(main.hostname_matches("example.com", "bbc.com"))
91+
92+
93+
class EnergyEstimate(unittest.TestCase):
94+
def test_energy_formula(self):
95+
# ((ms/1000) * watts) / 3600 / 1000
96+
self.assertAlmostEqual(
97+
main.estimate_energy_kwh(1000, 50), (1 * 50) / 3600 / 1000, places=12
98+
)
99+
100+
def test_zero_latency_is_zero_energy(self):
101+
self.assertEqual(main.estimate_energy_kwh(0, 50), 0)
102+
103+
104+
if __name__ == "__main__":
105+
unittest.main()

0 commit comments

Comments
 (0)