Spaces:
Sleeping
Sleeping
Add remaining agents to Hugging Face Space
Browse files- AIDual/app/main.py +177 -0
- AIDual/app/static/docs/expenses.txt +10 -0
- AIDual/app/static/docs/oncall.txt +10 -0
- AIDual/app/static/docs/policy.txt +6 -0
- AIDual/app/static/docs/travel.txt +10 -0
- AIDual/app/static/index.html +76 -0
- AIFit/app/main.py +144 -0
- AIFit/app/static/index.html +62 -0
- AIHealthcare/app/main.py +227 -0
- AIHealthcare/app/static/index.html +89 -0
- AIQuiz/app/main.py +220 -0
- AIQuiz/app/static/index.html +139 -0
- AIRoute/app/main.py +301 -0
- AIRoute/app/static/index.html +168 -0
- AISaas/app/main.py +190 -0
- AISaas/app/static/index.html +99 -0
- AIStrategy/app/main.py +148 -0
- AIStrategy/app/static/index.html +54 -0
AIDual/app/main.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI, APIRouter, HTTPException
|
| 2 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
+
from fastapi.responses import FileResponse
|
| 4 |
+
from pydantic import BaseModel, Field
|
| 5 |
+
from typing import List, Optional
|
| 6 |
+
import os
|
| 7 |
+
import sys
|
| 8 |
+
import json
|
| 9 |
+
from dotenv import load_dotenv, find_dotenv
|
| 10 |
+
|
| 11 |
+
ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
| 12 |
+
if ROOT_DIR not in sys.path:
|
| 13 |
+
sys.path.append(ROOT_DIR)
|
| 14 |
+
|
| 15 |
+
load_dotenv(find_dotenv(), override=True)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class Citation(BaseModel):
|
| 19 |
+
title: str
|
| 20 |
+
source: str
|
| 21 |
+
snippet: str
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class CompareRequest(BaseModel):
|
| 25 |
+
question: str
|
| 26 |
+
top_k: int = Field(default=3, ge=1, le=10)
|
| 27 |
+
context: Optional[str] = None
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class CompareResponse(BaseModel):
|
| 31 |
+
question: str
|
| 32 |
+
fine_tuned_answer: str
|
| 33 |
+
rag_answer: str
|
| 34 |
+
rag_citations: List[Citation] = []
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
from openai import OpenAI
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
FT_SYSTEM = (
|
| 41 |
+
"You are a simulated fine‑tuned enterprise assistant. Produce a polished, executive‑ready answer in 3–6 concise bullet points. "
|
| 42 |
+
"Tone: confident, specific, policy‑style. Avoid generic guidance like 'consult HR'. If information is unclear, state a reasonable enterprise default and mark it as typical practice. "
|
| 43 |
+
"Formatting: bullets only, no preamble or epilogue."
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
RAG_SYSTEM = (
|
| 47 |
+
"You are a RAG assistant. Answer STRICTLY from the provided context. If the context does not support an answer, say 'Not in context' and suggest where to look. "
|
| 48 |
+
"Output 3–6 concise bullets. After each bullet, include a citation tag like (Doc 1) or (Pasted) when derived from pasted context. "
|
| 49 |
+
"Do not invent facts. If documents conflict, note the discrepancy briefly and prefer the most specific statement."
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
class DualDemoService:
|
| 54 |
+
def __init__(self) -> None:
|
| 55 |
+
key = (os.getenv("OPENAI_API_KEY") or "").strip().strip('"').strip("'")
|
| 56 |
+
if not key:
|
| 57 |
+
raise RuntimeError("OPENAI_API_KEY is not set")
|
| 58 |
+
base_url = (os.getenv("OPENAI_BASE_URL") or "").strip().strip('"').strip("'") or None
|
| 59 |
+
kwargs = {"api_key": key}
|
| 60 |
+
if base_url:
|
| 61 |
+
kwargs["base_url"] = base_url
|
| 62 |
+
self.client = OpenAI(**kwargs)
|
| 63 |
+
self.model = os.getenv("OPENAI_MODEL", "gpt-4o-mini")
|
| 64 |
+
self.docs_dir = os.path.join(os.path.dirname(__file__), "static", "docs")
|
| 65 |
+
|
| 66 |
+
def _read_docs(self) -> List[dict]:
|
| 67 |
+
docs: List[dict] = []
|
| 68 |
+
if os.path.isdir(self.docs_dir):
|
| 69 |
+
for name in os.listdir(self.docs_dir):
|
| 70 |
+
path = os.path.join(self.docs_dir, name)
|
| 71 |
+
if os.path.isfile(path):
|
| 72 |
+
try:
|
| 73 |
+
with open(path, "r", encoding="utf-8", errors="ignore") as f:
|
| 74 |
+
text = f.read()
|
| 75 |
+
title = os.path.splitext(name)[0].replace("_", " ").title()
|
| 76 |
+
docs.append({"title": title, "source": name, "text": text})
|
| 77 |
+
except Exception:
|
| 78 |
+
continue
|
| 79 |
+
return docs
|
| 80 |
+
|
| 81 |
+
def _retrieve(self, question: str, top_k: int, external_text: Optional[str] = None) -> List[Citation]:
|
| 82 |
+
tokens = [t.strip().lower() for t in question.split() if t.strip()]
|
| 83 |
+
candidates: List[tuple[int, dict]] = []
|
| 84 |
+
sources = []
|
| 85 |
+
if external_text and external_text.strip():
|
| 86 |
+
sources = [{"title": "Context", "source": "", "text": external_text}]
|
| 87 |
+
else:
|
| 88 |
+
sources = self._read_docs()
|
| 89 |
+
for doc in sources:
|
| 90 |
+
paragraphs = [p.strip() for p in doc["text"].split("\n\n") if p.strip()]
|
| 91 |
+
for para in paragraphs:
|
| 92 |
+
score = sum(1 for t in tokens if t in para.lower())
|
| 93 |
+
if score > 0:
|
| 94 |
+
candidates.append((score, {"title": doc["title"], "source": doc["source"], "snippet": para[:800]}))
|
| 95 |
+
if not candidates and sources:
|
| 96 |
+
for doc in sources[:top_k]:
|
| 97 |
+
candidates.append((1, {"title": doc["title"], "source": doc["source"], "snippet": doc["text"][:800]}))
|
| 98 |
+
candidates.sort(key=lambda x: x[0], reverse=True)
|
| 99 |
+
cites: List[Citation] = []
|
| 100 |
+
seen = set()
|
| 101 |
+
for _, item in candidates:
|
| 102 |
+
key = (item["title"], item["source"], item["snippet"][:120])
|
| 103 |
+
if key in seen:
|
| 104 |
+
continue
|
| 105 |
+
seen.add(key)
|
| 106 |
+
cites.append(Citation(**item))
|
| 107 |
+
if len(cites) >= top_k:
|
| 108 |
+
break
|
| 109 |
+
return cites
|
| 110 |
+
|
| 111 |
+
def compare(self, req: CompareRequest) -> CompareResponse:
|
| 112 |
+
# 1) Fine-tuned simulation
|
| 113 |
+
ft = self.client.chat.completions.create(
|
| 114 |
+
model=self.model,
|
| 115 |
+
messages=[
|
| 116 |
+
{"role": "system", "content": FT_SYSTEM},
|
| 117 |
+
{"role": "user", "content": req.question},
|
| 118 |
+
],
|
| 119 |
+
temperature=0.1,
|
| 120 |
+
response_format={"type": "text"},
|
| 121 |
+
).choices[0].message.content or ""
|
| 122 |
+
|
| 123 |
+
# 2) RAG with inline context and structured output
|
| 124 |
+
citations = self._retrieve(req.question, req.top_k, req.context)
|
| 125 |
+
context = []
|
| 126 |
+
for i, c in enumerate(citations, start=1):
|
| 127 |
+
context.append(f"=== Doc {i} ===\nTitle: {c.title}\nSource: {c.source}\nExcerpt: {c.snippet}")
|
| 128 |
+
ctx = "\n\n".join(context)
|
| 129 |
+
|
| 130 |
+
rag_resp = self.client.chat.completions.create(
|
| 131 |
+
model=self.model,
|
| 132 |
+
messages=[
|
| 133 |
+
{"role": "system", "content": RAG_SYSTEM},
|
| 134 |
+
{"role": "system", "content": ctx},
|
| 135 |
+
{"role": "user", "content": req.question},
|
| 136 |
+
],
|
| 137 |
+
temperature=0.0,
|
| 138 |
+
response_format={"type": "text"},
|
| 139 |
+
).choices[0].message.content or ""
|
| 140 |
+
|
| 141 |
+
return CompareResponse(question=req.question, fine_tuned_answer=ft.strip(), rag_answer=rag_resp.strip(), rag_citations=citations)
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
app = FastAPI(title="Dual Demo Bot (RAG vs Fine-Tuning)", version="0.1.0")
|
| 145 |
+
app.add_middleware(
|
| 146 |
+
CORSMiddleware,
|
| 147 |
+
allow_origins=["*"],
|
| 148 |
+
allow_credentials=True,
|
| 149 |
+
allow_methods=["*"],
|
| 150 |
+
allow_headers=["*"],
|
| 151 |
+
)
|
| 152 |
+
|
| 153 |
+
router = APIRouter(prefix="/dual", tags=["dual"])
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
@router.get("/health")
|
| 157 |
+
def health() -> dict:
|
| 158 |
+
return {"status": "ok", "openai_key": bool(os.getenv("OPENAI_API_KEY")), "model": os.getenv("OPENAI_MODEL", "gpt-4o-mini")}
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
@router.post("/compare", response_model=CompareResponse)
|
| 162 |
+
def compare(payload: CompareRequest) -> CompareResponse:
|
| 163 |
+
try:
|
| 164 |
+
return DualDemoService().compare(payload)
|
| 165 |
+
except Exception as exc:
|
| 166 |
+
raise HTTPException(status_code=502, detail=f"Dual demo error: {exc}")
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
app.include_router(router)
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
@app.get("/", response_class=FileResponse)
|
| 173 |
+
def index() -> FileResponse:
|
| 174 |
+
base = os.path.dirname(__file__)
|
| 175 |
+
return FileResponse(os.path.join(base, "static", "index.html"))
|
| 176 |
+
|
| 177 |
+
|
AIDual/app/static/docs/expenses.txt
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Expense Policy (excerpt)
|
| 2 |
+
|
| 3 |
+
Submission: Expenses must be submitted within 30 days of the transaction date.
|
| 4 |
+
Receipts: Itemized receipts required for charges above $25.
|
| 5 |
+
Approvals: Manager approval required; >$1,000 needs Finance approval.
|
| 6 |
+
Meals: Reasonable meal costs allowed; alcohol is not reimbursable.
|
| 7 |
+
Tools: Use the Expenses app (Slack: /expenses) to file reports.
|
| 8 |
+
Per-diem: See Travel Policy for daily allowances by region.
|
| 9 |
+
|
| 10 |
+
|
AIDual/app/static/docs/oncall.txt
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
On‑Call Policy (excerpt)
|
| 2 |
+
|
| 3 |
+
Coverage: Primary and secondary engineers are scheduled weekly.
|
| 4 |
+
Handover: Include current incidents, known risks, and runbooks.
|
| 5 |
+
SLA: P1 acknowledge within 5 minutes, restore within 1 hour.
|
| 6 |
+
Escalation: Page secondary if no ack; page manager after 15 minutes.
|
| 7 |
+
Compensation: Stipend plus overtime for P1/P2 work; see HR policy.
|
| 8 |
+
Tools: Use Pager app for rotations and escalation paths.
|
| 9 |
+
|
| 10 |
+
|
AIDual/app/static/docs/policy.txt
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Password Reset Policy (excerpt)
|
| 2 |
+
|
| 3 |
+
Users can reset passwords via the “Forgot Password” link. Tokens expire in 15 minutes.
|
| 4 |
+
Self-service resets are available for SSO and local accounts. Support may verify identity for manual resets.
|
| 5 |
+
|
| 6 |
+
|
AIDual/app/static/docs/travel.txt
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Travel Policy (excerpt)
|
| 2 |
+
|
| 3 |
+
Booking: Book flights at least 14 days in advance via the corporate portal.
|
| 4 |
+
Class: Economy for flights under 6 hours; Premium Economy allowed over 6 hours with approval.
|
| 5 |
+
Hotels: Standard rooms only; nightly cap per city applies.
|
| 6 |
+
Per-diem: Daily allowance covers meals and incidentals; see regional table.
|
| 7 |
+
Ground: Ride‑share allowed; choose the least expensive safe option.
|
| 8 |
+
Safety: Follow local advisories and log itineraries with the Travel Desk.
|
| 9 |
+
|
| 10 |
+
|
AIDual/app/static/index.html
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8"/>
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
| 6 |
+
<title>Dual Demo Bot — RAG vs Fine‑Tuning</title>
|
| 7 |
+
<link rel="stylesheet" href="/static/suite.css"/>
|
| 8 |
+
<style>
|
| 9 |
+
.cols{display:grid;gap:16px;grid-template-columns:1fr;margin-top:12px}
|
| 10 |
+
@media (min-width:1000px){.cols{grid-template-columns:1fr 1fr}}
|
| 11 |
+
.answer{white-space:pre-wrap}
|
| 12 |
+
.cites{display:grid;gap:8px;margin-top:8px}
|
| 13 |
+
.cite{background:#fafafa;border:1px dashed var(--border);padding:8px;border-radius:8px}
|
| 14 |
+
.actions{display:flex;gap:12px;flex-wrap:wrap;margin-top:12px}
|
| 15 |
+
</style>
|
| 16 |
+
</head>
|
| 17 |
+
<body>
|
| 18 |
+
<div id="suite-shared-header"></div>
|
| 19 |
+
<div class="container">
|
| 20 |
+
<h1>RAG vs Fine‑Tuning — Dual Demo</h1>
|
| 21 |
+
<p class="muted">Ask any question. See a simulated Fine‑Tuned answer and a RAG answer side‑by‑side.</p>
|
| 22 |
+
<div class="card">
|
| 23 |
+
<label for="q">Your question</label>
|
| 24 |
+
<textarea id="q" rows="3" placeholder="How do we handle password resets?"></textarea>
|
| 25 |
+
<label for="ctx">Optional context (paste policy text, notes, docs)</label>
|
| 26 |
+
<textarea id="ctx" rows="6" placeholder="Paste any text here to power the RAG side..."></textarea>
|
| 27 |
+
<div class="form-row">
|
| 28 |
+
<div>
|
| 29 |
+
<label for="k">Citations</label>
|
| 30 |
+
<input id="k" type="number" min="1" max="10" value="3"/>
|
| 31 |
+
</div>
|
| 32 |
+
</div>
|
| 33 |
+
<div class="actions"><button id="run" class="btn">Compare</button></div>
|
| 34 |
+
</div>
|
| 35 |
+
<div class="cols">
|
| 36 |
+
<div class="card" id="ft"><h2>Fine‑Tuned</h2><div class="answer muted">(waiting)</div></div>
|
| 37 |
+
<div class="card" id="rag"><h2>RAG</h2><div class="answer muted">(waiting)</div><div class="cites" id="cites"></div></div>
|
| 38 |
+
</div>
|
| 39 |
+
</div>
|
| 40 |
+
<script src="/static/header.js"></script>
|
| 41 |
+
<script>
|
| 42 |
+
const run = document.getElementById('run');
|
| 43 |
+
const elQ = document.getElementById('q');
|
| 44 |
+
const elK = document.getElementById('k');
|
| 45 |
+
const elFT = document.querySelector('#ft .answer');
|
| 46 |
+
const elRAG = document.querySelector('#rag .answer');
|
| 47 |
+
const elC = document.getElementById('cites');
|
| 48 |
+
run.addEventListener('click', async ()=>{
|
| 49 |
+
elFT.textContent='(thinking...)';
|
| 50 |
+
elRAG.textContent='(thinking...)';
|
| 51 |
+
elC.innerHTML='';
|
| 52 |
+
try{
|
| 53 |
+
const res = await fetch('/dual/compare',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({question:elQ.value||'', context: (document.getElementById('ctx').value||''), top_k:parseInt(elK.value,10)||3})});
|
| 54 |
+
if(!res.ok) throw new Error(await res.text());
|
| 55 |
+
const data = await res.json();
|
| 56 |
+
elFT.textContent = data.fine_tuned_answer || '';
|
| 57 |
+
elRAG.textContent = data.rag_answer || '';
|
| 58 |
+
(data.rag_citations||[]).forEach(c=>{
|
| 59 |
+
const d=document.createElement('div');
|
| 60 |
+
d.className='cite';
|
| 61 |
+
const title = (c.title||'').replace(/</g,'<').replace(/>/g,'>');
|
| 62 |
+
const src = (c.source||'').replace(/</g,'<').replace(/>/g,'>');
|
| 63 |
+
const snip = (c.snippet||'').replace(/</g,'<').replace(/>/g,'>');
|
| 64 |
+
d.innerHTML = `<strong>${title}</strong> <span class="muted">(${src})</span><div class="muted" style="margin-top:6px;">${snip}</div>`;
|
| 65 |
+
elC.appendChild(d);
|
| 66 |
+
});
|
| 67 |
+
}catch(e){
|
| 68 |
+
elFT.textContent = `Error: ${String(e)}`;
|
| 69 |
+
elRAG.textContent = `Error: ${String(e)}`;
|
| 70 |
+
}
|
| 71 |
+
});
|
| 72 |
+
</script>
|
| 73 |
+
</body>
|
| 74 |
+
</html>
|
| 75 |
+
|
| 76 |
+
|
AIFit/app/main.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI, APIRouter, HTTPException
|
| 2 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
+
from fastapi.responses import FileResponse
|
| 4 |
+
from pydantic import BaseModel, Field
|
| 5 |
+
from typing import List, Literal, Optional
|
| 6 |
+
import os
|
| 7 |
+
import sys
|
| 8 |
+
import json
|
| 9 |
+
from dotenv import load_dotenv, find_dotenv
|
| 10 |
+
|
| 11 |
+
ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
| 12 |
+
if ROOT_DIR not in sys.path:
|
| 13 |
+
sys.path.append(ROOT_DIR)
|
| 14 |
+
|
| 15 |
+
load_dotenv(find_dotenv(), override=True)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class FitRequest(BaseModel):
|
| 19 |
+
product_name: Optional[str] = None
|
| 20 |
+
description: str = Field(..., description="Describe the product, users, core workflows, scale, data, constraints")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class FitSummary(BaseModel):
|
| 24 |
+
verdict: Literal["must_have", "nice_to_have", "overkill"]
|
| 25 |
+
rationale: List[str]
|
| 26 |
+
suggested_ai_capabilities: List[str]
|
| 27 |
+
risks: List[str]
|
| 28 |
+
next_steps: List[str]
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
from openai import OpenAI
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
SYSTEM = (
|
| 35 |
+
"You are an impartial AI fit evaluator for product teams. Read the product description and decide whether AI is a "
|
| 36 |
+
"must_have, nice_to_have, or overkill. Be conservative and practical.\n\n"
|
| 37 |
+
"Decision criteria (apply all):\n"
|
| 38 |
+
"- Value vs. complexity: Does AI unlock step‑change outcomes (conversion, accuracy, cost, speed) vs simpler heuristics?\n"
|
| 39 |
+
"- Data readiness: Do we have sufficient labeled or implicit feedback, signal‑to‑noise, and a pathway to ongoing labels?\n"
|
| 40 |
+
"- Latency & scale: Can inference meet UX/SLA constraints (e.g., <100ms) at projected volumes and cost?\n"
|
| 41 |
+
"- Risk & safety: Failure modes, abuse, compliance/privacy, auditability, and the blast radius of wrong outputs.\n"
|
| 42 |
+
"- Maintenance: Who will own monitoring, retraining, and evaluation; can we sustain it?\n\n"
|
| 43 |
+
"Verdict guidelines:\n"
|
| 44 |
+
"- must_have: Clear business lift that non‑AI cannot match; data pathway exists; latency/cost acceptable.\n"
|
| 45 |
+
"- nice_to_have: Tangible benefits but not critical path; partial data; or constraints require phased pilot.\n"
|
| 46 |
+
"- overkill: Minimal incremental value vs rules/UI; no meaningful data; operational risk too high.\n\n"
|
| 47 |
+
"Output requirements:\n"
|
| 48 |
+
"- rationale: 3–6 crisp bullets mapping evidence to the verdict (no fluff, no repeating the prompt).\n"
|
| 49 |
+
"- suggested_ai_capabilities: 3–6 scoped items (e.g., ranking, extraction, anomaly detection), not vague 'use AI'.\n"
|
| 50 |
+
"- risks: 3–6 specific risks (data gaps, model drift, eval difficulty, privacy/SOC2/PII, hallucination impact).\n"
|
| 51 |
+
"- next_steps: 3–6 concrete actions (baseline heuristic, KPI definition, data/label plan, pilot scope, eval rubric).\n"
|
| 52 |
+
"Tone: executive‑ready, direct, and vendor‑agnostic. Do NOT upsell AI where ROI is weak."
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class AIFitService:
|
| 57 |
+
def __init__(self) -> None:
|
| 58 |
+
key = (os.getenv("OPENAI_API_KEY") or "").strip().strip('"').strip("'")
|
| 59 |
+
if not key:
|
| 60 |
+
raise RuntimeError("OPENAI_API_KEY is not set")
|
| 61 |
+
base_url = (os.getenv("OPENAI_BASE_URL") or "").strip().strip('"').strip("'") or None
|
| 62 |
+
kwargs = {"api_key": key}
|
| 63 |
+
if base_url:
|
| 64 |
+
kwargs["base_url"] = base_url
|
| 65 |
+
self.client = OpenAI(**kwargs)
|
| 66 |
+
self.model = os.getenv("OPENAI_MODEL", "gpt-4o-mini")
|
| 67 |
+
|
| 68 |
+
def evaluate(self, req: FitRequest) -> FitSummary:
|
| 69 |
+
user = (
|
| 70 |
+
f"Product: {req.product_name or 'N/A'}\n" +
|
| 71 |
+
"Description:\n" + req.description.strip()
|
| 72 |
+
)
|
| 73 |
+
resp = self.client.chat.completions.create(
|
| 74 |
+
model=self.model,
|
| 75 |
+
messages=[
|
| 76 |
+
{"role": "system", "content": SYSTEM},
|
| 77 |
+
{"role": "user", "content": user},
|
| 78 |
+
],
|
| 79 |
+
temperature=0.1,
|
| 80 |
+
response_format={
|
| 81 |
+
"type": "json_schema",
|
| 82 |
+
"json_schema": {
|
| 83 |
+
"name": "FitSummary",
|
| 84 |
+
"schema": {
|
| 85 |
+
"type": "object",
|
| 86 |
+
"additionalProperties": False,
|
| 87 |
+
"required": ["verdict", "rationale", "suggested_ai_capabilities", "risks", "next_steps"],
|
| 88 |
+
"properties": {
|
| 89 |
+
"verdict": {"type": "string", "enum": ["must_have", "nice_to_have", "overkill"]},
|
| 90 |
+
"rationale": {"type": "array", "items": {"type": "string"}},
|
| 91 |
+
"suggested_ai_capabilities": {"type": "array", "items": {"type": "string"}},
|
| 92 |
+
"risks": {"type": "array", "items": {"type": "string"}},
|
| 93 |
+
"next_steps": {"type": "array", "items": {"type": "string"}}
|
| 94 |
+
}
|
| 95 |
+
}
|
| 96 |
+
}
|
| 97 |
+
},
|
| 98 |
+
)
|
| 99 |
+
content = resp.choices[0].message.content
|
| 100 |
+
try:
|
| 101 |
+
data = json.loads(content)
|
| 102 |
+
except Exception:
|
| 103 |
+
start = content.find('{'); end = content.rfind('}')
|
| 104 |
+
if start != -1 and end != -1 and end > start:
|
| 105 |
+
data = json.loads(content[start:end+1])
|
| 106 |
+
else:
|
| 107 |
+
raise RuntimeError("Invalid JSON from model")
|
| 108 |
+
return FitSummary.model_validate(data)
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
app = FastAPI(title="AI Fit Evaluator", version="0.1.0")
|
| 112 |
+
app.add_middleware(
|
| 113 |
+
CORSMiddleware,
|
| 114 |
+
allow_origins=["*"],
|
| 115 |
+
allow_credentials=True,
|
| 116 |
+
allow_methods=["*"],
|
| 117 |
+
allow_headers=["*"],
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
router = APIRouter(prefix="/fit", tags=["fit"])
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
@router.get("/health")
|
| 124 |
+
def health() -> dict:
|
| 125 |
+
return {"status":"ok","openai_key": bool(os.getenv("OPENAI_API_KEY")), "model": os.getenv("OPENAI_MODEL","gpt-4o-mini")}
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
@router.post("/evaluate", response_model=FitSummary)
|
| 129 |
+
def evaluate(payload: FitRequest) -> FitSummary:
|
| 130 |
+
try:
|
| 131 |
+
return AIFitService().evaluate(payload)
|
| 132 |
+
except Exception as exc:
|
| 133 |
+
raise HTTPException(status_code=502, detail=f"Fit evaluator error: {exc}")
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
app.include_router(router)
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
@app.get("/", response_class=FileResponse)
|
| 140 |
+
def index() -> FileResponse:
|
| 141 |
+
base = os.path.dirname(__file__)
|
| 142 |
+
return FileResponse(os.path.join(base, "static", "index.html"))
|
| 143 |
+
|
| 144 |
+
|
AIFit/app/static/index.html
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8"/>
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
| 6 |
+
<title>AI Fit Evaluator</title>
|
| 7 |
+
<link rel="stylesheet" href="/static/suite.css"/>
|
| 8 |
+
<style>
|
| 9 |
+
.actions{display:flex;gap:12px;flex-wrap:wrap;margin-top:12px}
|
| 10 |
+
.pill{background:#fff;border:1px solid var(--border);border-radius:12px;padding:10px;margin-top:12px}
|
| 11 |
+
.pill .k{font-weight:800}
|
| 12 |
+
.pill .l{color:var(--muted);font-size:12px}
|
| 13 |
+
</style>
|
| 14 |
+
</head>
|
| 15 |
+
<body>
|
| 16 |
+
<div id="suite-shared-header"></div>
|
| 17 |
+
<div class="container">
|
| 18 |
+
<h1>AI Fit Evaluator</h1>
|
| 19 |
+
<p class="muted">Describe your product; get an honest verdict on whether AI is a must‑have, nice‑to‑have, or overkill.</p>
|
| 20 |
+
<div class="grid two-col">
|
| 21 |
+
<div class="card">
|
| 22 |
+
<label for="name">Product name (optional)</label>
|
| 23 |
+
<input id="name" type="text" placeholder="Acme Task Manager"/>
|
| 24 |
+
<label for="desc">Product description</label>
|
| 25 |
+
<textarea id="desc" rows="10" placeholder="Audience, workflows, where value is created, scale, data you have, constraints, latency needs..."></textarea>
|
| 26 |
+
<div class="actions">
|
| 27 |
+
<button id="eval" class="btn">Evaluate</button>
|
| 28 |
+
</div>
|
| 29 |
+
</div>
|
| 30 |
+
<div class="card" id="out" style="display:none"></div>
|
| 31 |
+
</div>
|
| 32 |
+
</div>
|
| 33 |
+
<script src="/static/header.js"></script>
|
| 34 |
+
<script>
|
| 35 |
+
const out=document.getElementById('out');
|
| 36 |
+
document.getElementById('eval').addEventListener('click', async ()=>{
|
| 37 |
+
const payload={product_name: (document.getElementById('name').value||null), description: (document.getElementById('desc').value||'')};
|
| 38 |
+
out.style.display='block'; out.innerHTML='<div class="muted">(evaluating...)</div>';
|
| 39 |
+
try{
|
| 40 |
+
const res=await fetch('/fit/evaluate',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
|
| 41 |
+
if(!res.ok) throw new Error(await res.text());
|
| 42 |
+
const data=await res.json();
|
| 43 |
+
const verdictLabel = {must_have:'Must‑have', nice_to_have:'Nice‑to‑have', overkill:'Overkill'}[data.verdict] || data.verdict;
|
| 44 |
+
const list = (arr)=> (arr||[]).map(x=>`<li>${(x||'').replace(/</g,'<').replace(/>/g,'>')}</li>`).join('') || '<li class="muted">None</li>';
|
| 45 |
+
out.innerHTML=`
|
| 46 |
+
<div class="pill"><div class="k">Verdict: ${verdictLabel}</div><div class="l">Honest assessment based on value vs complexity and data readiness</div></div>
|
| 47 |
+
<h2>Rationale</h2>
|
| 48 |
+
<ul>${list(data.rationale)}</ul>
|
| 49 |
+
<h2>Suggested AI capabilities</h2>
|
| 50 |
+
<ul>${list(data.suggested_ai_capabilities)}</ul>
|
| 51 |
+
<h2>Risks</h2>
|
| 52 |
+
<ul>${list(data.risks)}</ul>
|
| 53 |
+
<h2>Next steps</h2>
|
| 54 |
+
<ul>${list(data.next_steps)}</ul>
|
| 55 |
+
`;
|
| 56 |
+
}catch(e){ out.innerHTML=`<div style="color:#b91c1c;">Error: ${String(e)}</div>`; }
|
| 57 |
+
});
|
| 58 |
+
</script>
|
| 59 |
+
</body>
|
| 60 |
+
</html>
|
| 61 |
+
|
| 62 |
+
|
AIHealthcare/app/main.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI, APIRouter, HTTPException
|
| 2 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
+
from fastapi.responses import FileResponse
|
| 4 |
+
from pydantic import BaseModel, Field
|
| 5 |
+
from typing import List, Optional
|
| 6 |
+
import os
|
| 7 |
+
from dotenv import load_dotenv, find_dotenv
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
# Load .env from project root; override shell for consistency
|
| 11 |
+
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
| 12 |
+
load_dotenv(find_dotenv(), override=True)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class TriageRequest(BaseModel):
|
| 16 |
+
symptoms: str = Field(..., description="Patient-reported symptoms narrative")
|
| 17 |
+
age: Optional[int] = Field(None, description="Approximate age in years")
|
| 18 |
+
duration: Optional[str] = Field(None, description="Symptom duration, e.g., '2 days' or '3 weeks'")
|
| 19 |
+
risk_factors: Optional[str] = Field(None, description="Known conditions/meds e.g., 'asthma, diabetes' or 'pregnant'")
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class TriageResponse(BaseModel):
|
| 23 |
+
disposition: str # urgent | routine | self-care
|
| 24 |
+
rationale: str
|
| 25 |
+
red_flags: List[str]
|
| 26 |
+
suggested_actions: List[str]
|
| 27 |
+
disclaimer: str
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
app = FastAPI(title="AI Symptom Triage (Demo)", version="0.1.0")
|
| 31 |
+
app.add_middleware(
|
| 32 |
+
CORSMiddleware,
|
| 33 |
+
allow_origins=["*"],
|
| 34 |
+
allow_credentials=True,
|
| 35 |
+
allow_methods=["*"],
|
| 36 |
+
allow_headers=["*"],
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
router = APIRouter(prefix="", tags=["triage"])
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
@router.get("/health")
|
| 43 |
+
def health() -> dict:
|
| 44 |
+
return {
|
| 45 |
+
"status": "ok",
|
| 46 |
+
"openai_key": bool(os.getenv("OPENAI_API_KEY")),
|
| 47 |
+
"model": os.getenv("OPENAI_MODEL", "gpt-4o-mini"),
|
| 48 |
+
"base_url": os.getenv("OPENAI_BASE_URL") or None,
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
from openai import OpenAI
|
| 53 |
+
import json
|
| 54 |
+
import re
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
SYSTEM_PROMPT = (
|
| 58 |
+
"You are a cautious, non‑diagnostic symptom triage assistant. Classify as 'urgent', 'routine', or 'self-care' "
|
| 59 |
+
"based on symptoms, duration, age, and risk factors. Output must be concise, safety‑first, and vendor‑agnostic.\n\n"
|
| 60 |
+
"Disposition rules (apply in order; if any red flag applies, mark 'urgent'):\n"
|
| 61 |
+
"- URGENT: chest pain/pressure; shortness of breath; severe headache or 'worst ever'; confusion; fainting; new weakness, numbness, facial droop, slurred speech; uncontrolled bleeding; severe dehydration (very dry mouth, no urine, dizziness); persistent high fever (>=39.4°C/103°F) with toxicity; severe abdominal pain; anaphylaxis signs (hives + breathing/swallowing difficulty, swelling); pregnancy complications (bleeding, severe abdominal pain, reduced fetal movement); suicidal ideation.\n"
|
| 62 |
+
"- ROUTINE: persistent or worsening symptoms without red flags (e.g., lingering cough, localized musculoskeletal pain, mild rash).\n"
|
| 63 |
+
"- SELF‑CARE: mild, self‑limited symptoms with no red flags (e.g., common cold signs, minor cuts with controlled bleeding).\n\n"
|
| 64 |
+
"Age/risk considerations:\n"
|
| 65 |
+
"- Infants/elderly, pregnancy, immunocompromised, or serious comorbidities warrant lower threshold for 'urgent'.\n"
|
| 66 |
+
"- Always adapt rationale to the provided age, duration, and risk factors.\n\n"
|
| 67 |
+
"Guidance format:\n"
|
| 68 |
+
"- rationale: 1–3 short sentences justifying disposition (mention key findings and uncertainties).\n"
|
| 69 |
+
"- red_flags: list only when present (name each one clearly).\n"
|
| 70 |
+
"- suggested_actions: actionable but non‑diagnostic (e.g., 'seek urgent care now', 'book primary care in 1–2 weeks', 'rest, fluids, OTC analgesic per label').\n"
|
| 71 |
+
"- disclaimer: include a clear statement that this is educational only, not medical advice, and to contact a clinician/emergency services when indicated.\n\n"
|
| 72 |
+
"Return STRICT JSON only matching the schema."
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
class SymptomTriageService:
|
| 77 |
+
def __init__(self) -> None:
|
| 78 |
+
key = os.getenv("OPENAI_API_KEY")
|
| 79 |
+
if not key:
|
| 80 |
+
raise RuntimeError("OPENAI_API_KEY is not set")
|
| 81 |
+
key = key.strip().strip('"').strip("'")
|
| 82 |
+
base_url = (os.getenv("OPENAI_BASE_URL") or "").strip().strip('"').strip("'") or None
|
| 83 |
+
kwargs = {"api_key": key}
|
| 84 |
+
if base_url:
|
| 85 |
+
kwargs["base_url"] = base_url
|
| 86 |
+
self.client = OpenAI(**kwargs)
|
| 87 |
+
self.model = os.getenv("OPENAI_MODEL", "gpt-4o-mini")
|
| 88 |
+
|
| 89 |
+
def assess(self, req: TriageRequest) -> TriageResponse:
|
| 90 |
+
user = (
|
| 91 |
+
f"Symptoms: {req.symptoms}\nAge: {req.age or 'unknown'}\nDuration: {req.duration or 'unknown'}\nRisk factors: {req.risk_factors or 'none'}"
|
| 92 |
+
)
|
| 93 |
+
try:
|
| 94 |
+
resp = self.client.chat.completions.create(
|
| 95 |
+
model=self.model,
|
| 96 |
+
messages=[
|
| 97 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 98 |
+
{"role": "user", "content": user},
|
| 99 |
+
],
|
| 100 |
+
temperature=0.15,
|
| 101 |
+
response_format={
|
| 102 |
+
"type": "json_schema",
|
| 103 |
+
"json_schema": {
|
| 104 |
+
"name": "TriageResponse",
|
| 105 |
+
"schema": {
|
| 106 |
+
"type": "object",
|
| 107 |
+
"required": ["disposition", "rationale", "red_flags", "suggested_actions", "disclaimer"],
|
| 108 |
+
"additionalProperties": False,
|
| 109 |
+
"properties": {
|
| 110 |
+
"disposition": {"type": "string", "enum": ["urgent", "routine", "self-care"]},
|
| 111 |
+
"rationale": {"type": "string"},
|
| 112 |
+
"red_flags": {"type": "array", "items": {"type": "string"}},
|
| 113 |
+
"suggested_actions": {"type": "array", "items": {"type": "string"}},
|
| 114 |
+
"disclaimer": {"type": "string"}
|
| 115 |
+
}
|
| 116 |
+
}
|
| 117 |
+
}
|
| 118 |
+
}
|
| 119 |
+
)
|
| 120 |
+
except Exception:
|
| 121 |
+
resp = self.client.chat.completions.create(
|
| 122 |
+
model=self.model,
|
| 123 |
+
messages=[
|
| 124 |
+
{"role": "system", "content": SYSTEM_PROMPT + "\nReturn ONLY JSON with disposition, rationale, red_flags[], suggested_actions[], disclaimer."},
|
| 125 |
+
{"role": "user", "content": user},
|
| 126 |
+
],
|
| 127 |
+
temperature=0.15,
|
| 128 |
+
response_format={"type": "json_object"},
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
content = resp.choices[0].message.content
|
| 132 |
+
try:
|
| 133 |
+
data = json.loads(content)
|
| 134 |
+
except Exception:
|
| 135 |
+
start = content.find("{")
|
| 136 |
+
end = content.rfind("}")
|
| 137 |
+
if start != -1 and end != -1 and end > start:
|
| 138 |
+
data = json.loads(content[start : end + 1])
|
| 139 |
+
else:
|
| 140 |
+
raise RuntimeError("Invalid JSON from model")
|
| 141 |
+
# Post-processing safety normalization
|
| 142 |
+
try:
|
| 143 |
+
text_blob = " ".join([
|
| 144 |
+
str(data.get("rationale") or ""),
|
| 145 |
+
str(data.get("suggested_actions") or ""),
|
| 146 |
+
str(data.get("red_flags") or ""),
|
| 147 |
+
])
|
| 148 |
+
user_blob = " ".join([
|
| 149 |
+
req.symptoms or "",
|
| 150 |
+
req.duration or "",
|
| 151 |
+
req.risk_factors or "",
|
| 152 |
+
str(req.age or ""),
|
| 153 |
+
]).lower()
|
| 154 |
+
# Normalize disposition synonyms
|
| 155 |
+
disp = (data.get("disposition") or "").strip().lower()
|
| 156 |
+
synonym_map = {
|
| 157 |
+
"self care": "self-care",
|
| 158 |
+
"selfcare": "self-care",
|
| 159 |
+
"non-urgent": "routine",
|
| 160 |
+
"non urgent": "routine",
|
| 161 |
+
"emergency": "urgent",
|
| 162 |
+
"er": "urgent",
|
| 163 |
+
"ed": "urgent",
|
| 164 |
+
"urgent care": "urgent",
|
| 165 |
+
}
|
| 166 |
+
disp = synonym_map.get(disp, disp)
|
| 167 |
+
|
| 168 |
+
red_keywords = [
|
| 169 |
+
r"chest\s*pain|pressure",
|
| 170 |
+
r"short(ness)?\s*of\s*breath|difficulty\s*(breathing|swallowing)",
|
| 171 |
+
r"worst\s*headache|severe\s*headache|stiff\s*neck",
|
| 172 |
+
r"confusion|faint(ing)?|passed\s*out",
|
| 173 |
+
r"weakness|numb(ness)?|facial\s*droop|slurred\s*speech|stroke",
|
| 174 |
+
r"uncontrolled\s*bleeding",
|
| 175 |
+
r"dehydration|no\s*urine|very\s*dry\s*mouth|dizziness",
|
| 176 |
+
r"high\s*fever|104|40\s*°?c",
|
| 177 |
+
r"severe\s*abdominal\s*pain",
|
| 178 |
+
r"anaphylaxis|hives",
|
| 179 |
+
r"pregnan|fetal|bleeding\s*during\s*pregnancy",
|
| 180 |
+
r"suicidal|self\s*h(arm|arming)",
|
| 181 |
+
]
|
| 182 |
+
red_found: list[str] = []
|
| 183 |
+
for pat in red_keywords:
|
| 184 |
+
if re.search(pat, user_blob):
|
| 185 |
+
red_found.append(pat)
|
| 186 |
+
red_flags = data.get("red_flags") or []
|
| 187 |
+
if red_found and disp != "urgent":
|
| 188 |
+
disp = "urgent"
|
| 189 |
+
# Ensure at least one human-readable red flag message
|
| 190 |
+
if not red_flags:
|
| 191 |
+
red_flags = ["Detected potential red flags; seek urgent in‑person evaluation."]
|
| 192 |
+
rationale = (data.get("rationale") or "").strip()
|
| 193 |
+
prefix = "Red flags present; elevating to urgent. "
|
| 194 |
+
data["rationale"] = (prefix + rationale) if rationale else prefix
|
| 195 |
+
data["disposition"] = disp if disp in {"urgent", "routine", "self-care"} else "urgent" if red_found else "routine"
|
| 196 |
+
data["red_flags"] = red_flags
|
| 197 |
+
# Enforce a standard disclaimer if missing/weak
|
| 198 |
+
disclaimer = (data.get("disclaimer") or "").strip()
|
| 199 |
+
min_disclaimer = (
|
| 200 |
+
"This is an educational triage demo, not medical advice. If symptoms are severe or you are concerned, "
|
| 201 |
+
"seek care from a licensed clinician or emergency services."
|
| 202 |
+
)
|
| 203 |
+
if len(disclaimer) < 80:
|
| 204 |
+
data["disclaimer"] = min_disclaimer
|
| 205 |
+
except Exception:
|
| 206 |
+
pass
|
| 207 |
+
|
| 208 |
+
return TriageResponse.model_validate(data)
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
@router.post("/assess", response_model=TriageResponse)
|
| 212 |
+
def assess(payload: TriageRequest) -> TriageResponse:
|
| 213 |
+
try:
|
| 214 |
+
return SymptomTriageService().assess(payload)
|
| 215 |
+
except Exception as exc:
|
| 216 |
+
raise HTTPException(status_code=502, detail=f"Triage bot error: {exc}")
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
app.include_router(router)
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
@app.get("/", response_class=FileResponse)
|
| 223 |
+
def index() -> FileResponse:
|
| 224 |
+
base = os.path.dirname(__file__)
|
| 225 |
+
return FileResponse(os.path.join(base, "static", "index.html"))
|
| 226 |
+
|
| 227 |
+
|
AIHealthcare/app/static/index.html
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8"/>
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
| 6 |
+
<title>Symptom Triage (Demo)</title>
|
| 7 |
+
<link rel="stylesheet" href="/static/suite.css">
|
| 8 |
+
<style>
|
| 9 |
+
:root{--bg:#f7f7f8;--surface:#fff;--text:#0f172a;--muted:#6b7280;--border:#e5e7eb;--brand:#111827;--radius:12px;--space-1:8px;--space-2:12px;--space-3:16px;--space-4:24px;--space-5:32px}
|
| 10 |
+
*{box-sizing:border-box}
|
| 11 |
+
body{font-family:system-ui,Segoe UI,Roboto,Helvetica,Arial,sans-serif;background:var(--bg);color:var(--text);margin:0}
|
| 12 |
+
.site-header{position:sticky;top:0;background:var(--surface);border-bottom:1px solid var(--border);z-index:50}
|
| 13 |
+
.site-header .nav{display:flex;align-items:center;justify-content:space-between;max-width:1200px;margin:0 auto;padding:12px 24px}
|
| 14 |
+
.site-header .brand a{text-decoration:none;color:var(--text)}
|
| 15 |
+
.site-header .links{display:flex;gap:16px}
|
| 16 |
+
.site-header .links a{text-decoration:none;color:var(--muted);padding:6px 10px;border-radius:8px}
|
| 17 |
+
.site-header .links a:hover{background:#f3f4f6;color:var(--text)}
|
| 18 |
+
.container{max-width:900px;margin:0 auto;padding:var(--space-5) var(--space-5)}
|
| 19 |
+
.card{background:#fff;border:1px solid var(--border);border-radius:var(--radius);padding:var(--space-4)}
|
| 20 |
+
label{display:block;margin:var(--space-2) 0 var(--space-1);font-weight:600}
|
| 21 |
+
input,textarea{width:100%;padding:var(--space-2);border:1px solid var(--border);border-radius:8px;font-size:16px;background:#fff}
|
| 22 |
+
.grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}
|
| 23 |
+
.btn{appearance:none;border:0;border-radius:8px;padding:10px 16px;font-weight:600;cursor:pointer;background:var(--brand);color:#fff}
|
| 24 |
+
.pill{display:inline-block;border-radius:999px;padding:4px 10px;font-size:12px;margin-right:6px}
|
| 25 |
+
.pill.urgent{background:#fee2e2;color:#991b1b}
|
| 26 |
+
.pill.routine{background:#e0e7ff;color:#3730a3}
|
| 27 |
+
.pill.selfcare{background:#dcfce7;color:#166534}
|
| 28 |
+
.muted{color:var(--muted)}
|
| 29 |
+
</style>
|
| 30 |
+
</head>
|
| 31 |
+
<body>
|
| 32 |
+
<div id="suite-shared-header"></div>
|
| 33 |
+
<script src="/static/header.js"></script>
|
| 34 |
+
<div class="container">
|
| 35 |
+
<h1>Symptom Triage (Demo)</h1>
|
| 36 |
+
<p class="muted">Educational demo — not medical advice. If this is an emergency, call your local emergency number.</p>
|
| 37 |
+
<div class="card">
|
| 38 |
+
<form id="form">
|
| 39 |
+
<label>Symptoms</label>
|
| 40 |
+
<textarea id="symptoms" rows="3" placeholder="Describe what's going on"></textarea>
|
| 41 |
+
<div class="grid">
|
| 42 |
+
<div>
|
| 43 |
+
<label>Age</label>
|
| 44 |
+
<input id="age" type="number" min="0" />
|
| 45 |
+
</div>
|
| 46 |
+
<div>
|
| 47 |
+
<label>Duration</label>
|
| 48 |
+
<input id="duration" placeholder="e.g., 2 days" />
|
| 49 |
+
</div>
|
| 50 |
+
</div>
|
| 51 |
+
<label>Risk factors (optional)</label>
|
| 52 |
+
<input id="risks" placeholder="e.g., asthma, pregnant" />
|
| 53 |
+
<div style="margin-top:12px"><button class="btn" type="submit">Assess</button></div>
|
| 54 |
+
</form>
|
| 55 |
+
</div>
|
| 56 |
+
<div id="out" style="margin-top:16px"></div>
|
| 57 |
+
</div>
|
| 58 |
+
<script>
|
| 59 |
+
const form = document.getElementById('form');
|
| 60 |
+
const out = document.getElementById('out');
|
| 61 |
+
form.addEventListener('submit', async (e)=>{
|
| 62 |
+
e.preventDefault();
|
| 63 |
+
out.innerHTML = '(assessing)';
|
| 64 |
+
const body = {
|
| 65 |
+
symptoms: document.getElementById('symptoms').value.trim(),
|
| 66 |
+
age: Number(document.getElementById('age').value||'') || undefined,
|
| 67 |
+
duration: document.getElementById('duration').value.trim() || undefined,
|
| 68 |
+
risk_factors: document.getElementById('risks').value.trim() || undefined,
|
| 69 |
+
};
|
| 70 |
+
const r = await fetch('/triage/assess', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body) });
|
| 71 |
+
const text = await r.text();
|
| 72 |
+
let data={}; try{ data = text ? JSON.parse(text) : {}; }catch{ data = { error:text } }
|
| 73 |
+
if(!r.ok){ out.textContent = 'Error: ' + (data.detail || text); return; }
|
| 74 |
+
const disp = (data.disposition||'').toLowerCase();
|
| 75 |
+
const pill = disp==='urgent'?'urgent':(disp==='self-care'?'selfcare':'routine');
|
| 76 |
+
out.innerHTML = `
|
| 77 |
+
<div class="card">
|
| 78 |
+
<p><span class="pill ${pill}">${data.disposition}</span></p>
|
| 79 |
+
<p>${data.rationale||''}</p>
|
| 80 |
+
${ (data.red_flags||[]).length ? '<p><strong>Red flags</strong></p><ul>'+data.red_flags.map(x=>'<li>'+x+'</li>').join('')+'</ul>' : '' }
|
| 81 |
+
${ (data.suggested_actions||[]).length ? '<p><strong>Suggested actions</strong></p><ul>'+data.suggested_actions.map(x=>'<li>'+x+'</li>').join('')+'</ul>' : '' }
|
| 82 |
+
<p class="muted" style="margin-top:8px">${data.disclaimer||''}</p>
|
| 83 |
+
</div>`;
|
| 84 |
+
});
|
| 85 |
+
</script>
|
| 86 |
+
</body>
|
| 87 |
+
</html>
|
| 88 |
+
|
| 89 |
+
|
AIQuiz/app/main.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI, APIRouter, HTTPException
|
| 2 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
+
from fastapi.responses import FileResponse
|
| 4 |
+
from pydantic import BaseModel, Field, conint
|
| 5 |
+
from typing import List, Optional
|
| 6 |
+
import os
|
| 7 |
+
import sys
|
| 8 |
+
import json
|
| 9 |
+
from dotenv import load_dotenv, find_dotenv
|
| 10 |
+
import difflib
|
| 11 |
+
import re
|
| 12 |
+
|
| 13 |
+
ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
| 14 |
+
if ROOT_DIR not in sys.path:
|
| 15 |
+
sys.path.append(ROOT_DIR)
|
| 16 |
+
|
| 17 |
+
load_dotenv(find_dotenv(), override=True)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
class QuizState(BaseModel):
|
| 21 |
+
topic: str
|
| 22 |
+
level: conint(ge=1, le=5) = 3
|
| 23 |
+
questions_asked: conint(ge=0) = 0
|
| 24 |
+
correct: conint(ge=0) = 0
|
| 25 |
+
history: List[dict] = Field(default_factory=list)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class NextQuestionRequest(BaseModel):
|
| 29 |
+
state: QuizState
|
| 30 |
+
last_answer: Optional[str] = None
|
| 31 |
+
last_choices: Optional[List[str]] = None
|
| 32 |
+
last_prompt: Optional[str] = None
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
class Question(BaseModel):
|
| 36 |
+
prompt: str
|
| 37 |
+
choices: List[str]
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class NextQuestionResponse(BaseModel):
|
| 41 |
+
question: Question
|
| 42 |
+
state: QuizState
|
| 43 |
+
feedback: Optional[str] = None
|
| 44 |
+
correct_answer: Optional[str] = None
|
| 45 |
+
is_correct: Optional[bool] = None
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
from openai import OpenAI
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
SYSTEM_PROMPT = (
|
| 52 |
+
"You are an adaptive quiz tutor. Ask exactly one multiple-choice question at a time (4 choices), "
|
| 53 |
+
"and adjust difficulty (1-5) based on the learner's previous answer correctness. "
|
| 54 |
+
"If the previous answer was correct, slightly increase difficulty; if wrong, slightly decrease. "
|
| 55 |
+
"Keep questions concise and unambiguous. Provide short feedback referencing the correct answer. "
|
| 56 |
+
"CRITICAL: 'correct_answer' MUST be exactly one of the provided 'choices'. "
|
| 57 |
+
"Set 'is_correct' = (last_answer exactly equals correct_answer). "
|
| 58 |
+
"Feedback template: if correct -> 'Correct. Correct answer: <choice>. <one-line reason>'; "
|
| 59 |
+
"if wrong -> 'Incorrect. Correct answer: <choice>. <one-line reason>'. "
|
| 60 |
+
"Return STRICTLY VALID JSON per schema."
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
class AdaptiveQuizService:
|
| 65 |
+
def __init__(self) -> None:
|
| 66 |
+
key = (os.getenv("OPENAI_API_KEY") or "").strip().strip('"').strip("'")
|
| 67 |
+
if not key:
|
| 68 |
+
raise RuntimeError("OPENAI_API_KEY is not set")
|
| 69 |
+
base_url = (os.getenv("OPENAI_BASE_URL") or "").strip().strip('"').strip("'") or None
|
| 70 |
+
kwargs = {"api_key": key}
|
| 71 |
+
if base_url:
|
| 72 |
+
kwargs["base_url"] = base_url
|
| 73 |
+
self.client = OpenAI(**kwargs)
|
| 74 |
+
self.model = os.getenv("OPENAI_MODEL", "gpt-4o-mini")
|
| 75 |
+
|
| 76 |
+
def next(self, req: NextQuestionRequest) -> NextQuestionResponse:
|
| 77 |
+
payload = {
|
| 78 |
+
"topic": req.state.topic,
|
| 79 |
+
"level": req.state.level,
|
| 80 |
+
"questions_asked": req.state.questions_asked,
|
| 81 |
+
"correct": req.state.correct,
|
| 82 |
+
"history": req.state.history,
|
| 83 |
+
"last_answer": req.last_answer,
|
| 84 |
+
"last_choices": req.last_choices,
|
| 85 |
+
"last_prompt": req.last_prompt,
|
| 86 |
+
}
|
| 87 |
+
user_json = json.dumps(payload, ensure_ascii=False)
|
| 88 |
+
try:
|
| 89 |
+
resp = self.client.chat.completions.create(
|
| 90 |
+
model=self.model,
|
| 91 |
+
messages=[
|
| 92 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 93 |
+
{"role": "user", "content": user_json},
|
| 94 |
+
],
|
| 95 |
+
temperature=0.2,
|
| 96 |
+
response_format={
|
| 97 |
+
"type": "json_schema",
|
| 98 |
+
"json_schema": {
|
| 99 |
+
"name": "NextQuestionResponse",
|
| 100 |
+
"schema": {
|
| 101 |
+
"type": "object",
|
| 102 |
+
"additionalProperties": False,
|
| 103 |
+
"required": ["question", "state"],
|
| 104 |
+
"properties": {
|
| 105 |
+
"question": {
|
| 106 |
+
"type": "object",
|
| 107 |
+
"required": ["prompt", "choices"],
|
| 108 |
+
"properties": {
|
| 109 |
+
"prompt": {"type": "string"},
|
| 110 |
+
"choices": {"type": "array", "minItems": 4, "maxItems": 4, "items": {"type": "string"}}
|
| 111 |
+
}
|
| 112 |
+
},
|
| 113 |
+
"state": {
|
| 114 |
+
"type": "object",
|
| 115 |
+
"required": ["topic", "level", "questions_asked", "correct", "history"],
|
| 116 |
+
"properties": {
|
| 117 |
+
"topic": {"type": "string"},
|
| 118 |
+
"level": {"type": "integer", "minimum": 1, "maximum": 5},
|
| 119 |
+
"questions_asked": {"type": "integer", "minimum": 0},
|
| 120 |
+
"correct": {"type": "integer", "minimum": 0},
|
| 121 |
+
"history": {"type": "array", "items": {"type": "object"}}
|
| 122 |
+
}
|
| 123 |
+
},
|
| 124 |
+
"feedback": {"type": "string"},
|
| 125 |
+
"correct_answer": {"type": "string"},
|
| 126 |
+
"is_correct": {"type": "boolean"}
|
| 127 |
+
}
|
| 128 |
+
}
|
| 129 |
+
}
|
| 130 |
+
},
|
| 131 |
+
)
|
| 132 |
+
content = resp.choices[0].message.content
|
| 133 |
+
data = json.loads(content)
|
| 134 |
+
except Exception:
|
| 135 |
+
# Fallback parse
|
| 136 |
+
try:
|
| 137 |
+
start = content.find('{'); end = content.rfind('}')
|
| 138 |
+
if start != -1 and end != -1 and end > start:
|
| 139 |
+
data = json.loads(content[start:end+1])
|
| 140 |
+
else:
|
| 141 |
+
raise RuntimeError('Invalid JSON')
|
| 142 |
+
except Exception as exc:
|
| 143 |
+
raise RuntimeError(f"Adaptive quiz error: {exc}")
|
| 144 |
+
# Post-validate and normalize correctness deterministically
|
| 145 |
+
try:
|
| 146 |
+
prev_choices = req.last_choices or []
|
| 147 |
+
correct_answer = (data.get("correct_answer") or "").strip()
|
| 148 |
+
# Ensure correct_answer maps to one of previous choices (fuzzy-insensitive mapping)
|
| 149 |
+
def _normalize(s: str) -> str:
|
| 150 |
+
# Generic normalization only (no domain-specific mapping)
|
| 151 |
+
s = (s or "").lower()
|
| 152 |
+
s = re.sub(r"[^a-z0-9\s]", "", s)
|
| 153 |
+
s = re.sub(r"\s+", " ", s).strip()
|
| 154 |
+
return s
|
| 155 |
+
if prev_choices:
|
| 156 |
+
target = _normalize(correct_answer)
|
| 157 |
+
mapped = None
|
| 158 |
+
best = 0.0
|
| 159 |
+
for c in prev_choices:
|
| 160 |
+
score = difflib.SequenceMatcher(None, _normalize(c), target).ratio()
|
| 161 |
+
if score > best:
|
| 162 |
+
best = score
|
| 163 |
+
mapped = c
|
| 164 |
+
if not mapped:
|
| 165 |
+
mapped = prev_choices[0]
|
| 166 |
+
correct_answer = mapped
|
| 167 |
+
data["correct_answer"] = correct_answer
|
| 168 |
+
# Compute is_correct if last_answer was provided
|
| 169 |
+
if req.last_answer is not None:
|
| 170 |
+
is_correct = (_normalize(req.last_answer) == _normalize(correct_answer))
|
| 171 |
+
data["is_correct"] = bool(is_correct)
|
| 172 |
+
# Standardize feedback if missing or unhelpful
|
| 173 |
+
if is_correct:
|
| 174 |
+
default_fb = f"Correct. Correct answer: {correct_answer}."
|
| 175 |
+
else:
|
| 176 |
+
default_fb = f"Incorrect. Correct answer: {correct_answer}."
|
| 177 |
+
fb = (data.get("feedback") or "").strip()
|
| 178 |
+
if not fb or correct_answer not in fb:
|
| 179 |
+
data["feedback"] = default_fb
|
| 180 |
+
except Exception:
|
| 181 |
+
# Best-effort; proceed with whatever the model returned
|
| 182 |
+
pass
|
| 183 |
+
|
| 184 |
+
return NextQuestionResponse.model_validate(data)
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
app = FastAPI(title="Adaptive Quiz Bot", version="0.1.0")
|
| 188 |
+
app.add_middleware(
|
| 189 |
+
CORSMiddleware,
|
| 190 |
+
allow_origins=["*"],
|
| 191 |
+
allow_credentials=True,
|
| 192 |
+
allow_methods=["*"],
|
| 193 |
+
allow_headers=["*"],
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
router = APIRouter(prefix="/quiz", tags=["quiz"])
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
@router.get("/health")
|
| 200 |
+
def health() -> dict:
|
| 201 |
+
return {"status":"ok","openai_key": bool(os.getenv("OPENAI_API_KEY")), "model": os.getenv("OPENAI_MODEL","gpt-4o-mini")}
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
@router.post("/next", response_model=NextQuestionResponse)
|
| 205 |
+
def next_turn(payload: NextQuestionRequest) -> NextQuestionResponse:
|
| 206 |
+
try:
|
| 207 |
+
return AdaptiveQuizService().next(payload)
|
| 208 |
+
except Exception as exc:
|
| 209 |
+
raise HTTPException(status_code=502, detail=str(exc))
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
app.include_router(router)
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
@app.get("/", response_class=FileResponse)
|
| 216 |
+
def index() -> FileResponse:
|
| 217 |
+
base = os.path.dirname(__file__)
|
| 218 |
+
return FileResponse(os.path.join(base, "static", "index.html"))
|
| 219 |
+
|
| 220 |
+
|
AIQuiz/app/static/index.html
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8"/>
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
| 6 |
+
<title>Adaptive Quiz Bot</title>
|
| 7 |
+
<link rel="stylesheet" href="/static/suite.css"/>
|
| 8 |
+
<style>
|
| 9 |
+
.choice{display:block;background:#fff;border:1px solid var(--border);border-radius:10px;padding:10px;margin-top:8px;cursor:pointer}
|
| 10 |
+
.choice:hover{background:#f8fafc}
|
| 11 |
+
.choice.selected{outline:3px solid #6366f1}
|
| 12 |
+
.choice.correct{background:#ecfdf5;border-color:#22c55e}
|
| 13 |
+
.choice.wrong{background:#fef2f2;border-color:#ef4444}
|
| 14 |
+
.pill{background:#fff;border:1px solid var(--border);border-radius:12px;padding:10px}
|
| 15 |
+
.pill .k{font-weight:800}
|
| 16 |
+
.pill .l{color:var(--muted);font-size:12px}
|
| 17 |
+
.progress{height:8px;background:#eef2ff;border-radius:8px;overflow:hidden;margin:8px 0}
|
| 18 |
+
.progress .bar{height:100%;background:#6366f1;width:0%}
|
| 19 |
+
.actions{display:flex;gap:12px;flex-wrap:wrap;margin-top:12px}
|
| 20 |
+
</style>
|
| 21 |
+
</head>
|
| 22 |
+
<body>
|
| 23 |
+
<div id="suite-shared-header"></div>
|
| 24 |
+
<div class="container">
|
| 25 |
+
<h1>Adaptive Quiz Bot</h1>
|
| 26 |
+
<p class="muted">Ask 3 quiz questions and adjust difficulty in real time based on your answers.</p>
|
| 27 |
+
<div class="grid two-col">
|
| 28 |
+
<div class="card">
|
| 29 |
+
<label for="topic">Topic</label>
|
| 30 |
+
<input id="topic" type="text" placeholder="Machine Learning basics"/>
|
| 31 |
+
<div class="form-row">
|
| 32 |
+
<div>
|
| 33 |
+
<label for="level">Start difficulty (1-5)</label>
|
| 34 |
+
<input id="level" type="number" min="1" max="5" value="3"/>
|
| 35 |
+
</div>
|
| 36 |
+
<div>
|
| 37 |
+
<label for="num">Questions</label>
|
| 38 |
+
<input id="num" type="number" min="1" max="8" value="3"/>
|
| 39 |
+
</div>
|
| 40 |
+
</div>
|
| 41 |
+
<div class="actions"><button id="start" class="btn">Start</button></div>
|
| 42 |
+
</div>
|
| 43 |
+
<div class="card" id="play" style="display:none"></div>
|
| 44 |
+
</div>
|
| 45 |
+
</div>
|
| 46 |
+
<script src="/static/header.js"></script>
|
| 47 |
+
<script>
|
| 48 |
+
let state = null, remaining = 0, quizAsked = 0, quizCorrect = 0, total = 0;
|
| 49 |
+
const play = document.getElementById('play');
|
| 50 |
+
async function fetchNext(lastAnswer, lastChoices, lastPrompt){
|
| 51 |
+
const res = await fetch('/quiz/next', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({state, last_answer: lastAnswer||null, last_choices: lastChoices||null, last_prompt: lastPrompt||null})});
|
| 52 |
+
if(!res.ok) throw new Error(await res.text());
|
| 53 |
+
const data = await res.json();
|
| 54 |
+
state = data.state || state || {};
|
| 55 |
+
return data;
|
| 56 |
+
}
|
| 57 |
+
function renderQuestion(data, reveal){
|
| 58 |
+
const q = data.question;
|
| 59 |
+
const feedback = data.feedback || '';
|
| 60 |
+
play.style.display='block';
|
| 61 |
+
play.innerHTML = `
|
| 62 |
+
<div class="pill" style="margin-bottom:8px;display:flex;gap:12px;align-items:center;">
|
| 63 |
+
<div class="k">Level ${state.level}</div>
|
| 64 |
+
<div class="l">Asked ${quizAsked}/${total} • Correct ${quizCorrect}</div>
|
| 65 |
+
</div>
|
| 66 |
+
<div class="progress"><div class="bar" style="width:${Math.min(100, Math.round((quizAsked/Math.max(1,total))*100))}%;"></div></div>
|
| 67 |
+
<div style="font-weight:700;margin-bottom:8px;">${q.prompt}</div>
|
| 68 |
+
<div id="choices"></div>
|
| 69 |
+
<div class="actions">
|
| 70 |
+
<button id="nextBtn" class="btn" type="button" disabled>Next</button>
|
| 71 |
+
</div>
|
| 72 |
+
${reveal && feedback ? `<div class="muted" style="margin-top:8px;">${feedback}</div>` : ''}
|
| 73 |
+
`;
|
| 74 |
+
const box = play.querySelector('#choices');
|
| 75 |
+
let selected = null;
|
| 76 |
+
const nextBtn = play.querySelector('#nextBtn');
|
| 77 |
+
q.choices.forEach((c)=>{
|
| 78 |
+
const btn = document.createElement('button');
|
| 79 |
+
btn.className = 'choice';
|
| 80 |
+
btn.type = 'button';
|
| 81 |
+
btn.textContent = c;
|
| 82 |
+
btn.addEventListener('click', ()=>{
|
| 83 |
+
selected = c;
|
| 84 |
+
Array.from(box.children).forEach(el=>el.classList.remove('selected'));
|
| 85 |
+
btn.classList.add('selected');
|
| 86 |
+
nextBtn.disabled = false;
|
| 87 |
+
});
|
| 88 |
+
box.appendChild(btn);
|
| 89 |
+
});
|
| 90 |
+
let buffered = null;
|
| 91 |
+
nextBtn.addEventListener('click', async ()=>{
|
| 92 |
+
if(!selected) return;
|
| 93 |
+
// First click: submit and reveal on current view
|
| 94 |
+
if (!buffered) {
|
| 95 |
+
const lastChoices = Array.from(box.children).map(el=>el.textContent);
|
| 96 |
+
const lastPrompt = q.prompt;
|
| 97 |
+
try{ buffered = await fetchNext(selected, lastChoices, lastPrompt); }catch(e){ play.innerHTML = `<div style=\"color:#b91c1c;\">${String(e)}</div>`; return; }
|
| 98 |
+
// update counters
|
| 99 |
+
quizAsked++;
|
| 100 |
+
if (buffered.is_correct === true) quizCorrect++;
|
| 101 |
+
// highlight
|
| 102 |
+
const ca = buffered.correct_answer || '';
|
| 103 |
+
Array.from(box.children).forEach(el=>{
|
| 104 |
+
const val = el.textContent;
|
| 105 |
+
if (val === ca) el.classList.add('correct');
|
| 106 |
+
if (val === selected && val !== ca) el.classList.add('wrong');
|
| 107 |
+
el.disabled = true;
|
| 108 |
+
});
|
| 109 |
+
// show feedback
|
| 110 |
+
if (buffered.feedback) {
|
| 111 |
+
const fb = document.createElement('div'); fb.className='muted'; fb.style.marginTop='8px'; fb.textContent = buffered.feedback; play.appendChild(fb);
|
| 112 |
+
}
|
| 113 |
+
// progress + header refresh
|
| 114 |
+
const header = play.querySelector('.pill .l');
|
| 115 |
+
if (header) header.textContent = `Asked ${quizAsked}/${total} • Correct ${quizCorrect}`;
|
| 116 |
+
const bar = play.querySelector('.progress .bar'); if (bar) bar.style.width = `${Math.min(100, Math.round((quizAsked/Math.max(1,total))*100))}%`;
|
| 117 |
+
nextBtn.textContent = (remaining-1) <= 0 ? 'Finish' : 'Next';
|
| 118 |
+
remaining--;
|
| 119 |
+
return;
|
| 120 |
+
}
|
| 121 |
+
// Second click: render next question from buffer
|
| 122 |
+
if (remaining <= 0) {
|
| 123 |
+
play.innerHTML = `<div class=\"pill\" style=\"margin-top:12px;\"><div class=\"k\">Done!</div><div class=\"l\">Topic: ${state.topic} • Correct: ${quizCorrect}/${quizAsked}</div></div>`;
|
| 124 |
+
return;
|
| 125 |
+
}
|
| 126 |
+
const nextData = buffered; buffered = null; renderQuestion(nextData, false);
|
| 127 |
+
});
|
| 128 |
+
}
|
| 129 |
+
document.getElementById('start').addEventListener('click', async ()=>{
|
| 130 |
+
state = { topic: document.getElementById('topic').value || 'General knowledge', level: parseInt(document.getElementById('level').value||'3',10), questions_asked:0, correct:0, history:[] };
|
| 131 |
+
quizAsked = 0; quizCorrect = 0;
|
| 132 |
+
remaining = parseInt(document.getElementById('num').value||'3',10); total = remaining;
|
| 133 |
+
try{ const first = await fetchNext(null, null, null); renderQuestion(first, false); }catch(e){ play.style.display='block'; play.innerHTML = `<div style=\"color:#b91c1c;\">${String(e)}</div>`; }
|
| 134 |
+
});
|
| 135 |
+
</script>
|
| 136 |
+
</body>
|
| 137 |
+
</html>
|
| 138 |
+
|
| 139 |
+
|
AIRoute/app/main.py
ADDED
|
@@ -0,0 +1,301 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI, APIRouter, HTTPException
|
| 2 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
+
from fastapi.responses import FileResponse
|
| 4 |
+
from pydantic import BaseModel, Field, conint, confloat
|
| 5 |
+
from typing import List, Optional, Tuple
|
| 6 |
+
import os
|
| 7 |
+
import sys
|
| 8 |
+
import json
|
| 9 |
+
from dotenv import load_dotenv, find_dotenv
|
| 10 |
+
|
| 11 |
+
ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
| 12 |
+
if ROOT_DIR not in sys.path:
|
| 13 |
+
sys.path.append(ROOT_DIR)
|
| 14 |
+
|
| 15 |
+
load_dotenv(find_dotenv(), override=True)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class Address(BaseModel):
|
| 19 |
+
label: str
|
| 20 |
+
address: str
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class RouteRequest(BaseModel):
|
| 24 |
+
origin: Address
|
| 25 |
+
destinations: List[Address] = Field(min_items=1, max_items=8)
|
| 26 |
+
fuel_cost_per_km: confloat(ge=0) = 0.2
|
| 27 |
+
average_speed_kmph: confloat(ge=5, le=120) = 35.0
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class Stop(BaseModel):
|
| 31 |
+
order: conint(ge=1)
|
| 32 |
+
label: str
|
| 33 |
+
address: str
|
| 34 |
+
eta_minutes: conint(ge=0)
|
| 35 |
+
distance_km: confloat(ge=0)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class GeoPoint(BaseModel):
|
| 39 |
+
label: str
|
| 40 |
+
address: str
|
| 41 |
+
lat: float
|
| 42 |
+
lng: float
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class RouteResponse(BaseModel):
|
| 46 |
+
summary: str
|
| 47 |
+
total_distance_km: confloat(ge=0)
|
| 48 |
+
total_time_minutes: conint(ge=0)
|
| 49 |
+
estimated_fuel_cost: confloat(ge=0)
|
| 50 |
+
estimated_savings: confloat(ge=0)
|
| 51 |
+
route: List[Stop]
|
| 52 |
+
maps_url: Optional[str] = None
|
| 53 |
+
points: Optional[List[GeoPoint]] = None
|
| 54 |
+
order: Optional[List[int]] = None
|
| 55 |
+
maps_embed_url: Optional[str] = None
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
from openai import OpenAI
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
GEOCODE_PROMPT = (
|
| 62 |
+
"You are a geocoding assistant. Convert each address to a clean, standardized form with latitude and longitude. "
|
| 63 |
+
"If uncertain, provide your best estimate based on the city and well-known landmarks. Return STRICT JSON only."
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class RouteService:
|
| 68 |
+
def __init__(self) -> None:
|
| 69 |
+
key = (os.getenv("OPENAI_API_KEY") or "").strip().strip('"').strip("'")
|
| 70 |
+
if not key:
|
| 71 |
+
raise RuntimeError("OPENAI_API_KEY is not set")
|
| 72 |
+
base_url = (os.getenv("OPENAI_BASE_URL") or "").strip().strip('"').strip("'") or None
|
| 73 |
+
kwargs = {"api_key": key}
|
| 74 |
+
if base_url:
|
| 75 |
+
kwargs["base_url"] = base_url
|
| 76 |
+
self.client = OpenAI(**kwargs)
|
| 77 |
+
self.model = os.getenv("OPENAI_MODEL", "gpt-4o-mini")
|
| 78 |
+
self.maps_embed_key = (os.getenv("GOOGLE_MAPS_EMBED_KEY") or "").strip().strip('"').strip("'") or None
|
| 79 |
+
|
| 80 |
+
def optimize(self, req: RouteRequest) -> RouteResponse:
|
| 81 |
+
# 1) Geocode all points via model
|
| 82 |
+
points = [req.origin] + req.destinations
|
| 83 |
+
user_json = json.dumps({"points": [p.model_dump() for p in points]}, ensure_ascii=False)
|
| 84 |
+
try:
|
| 85 |
+
resp = self.client.chat.completions.create(
|
| 86 |
+
model=self.model,
|
| 87 |
+
messages=[
|
| 88 |
+
{"role": "system", "content": GEOCODE_PROMPT},
|
| 89 |
+
{"role": "user", "content": user_json},
|
| 90 |
+
],
|
| 91 |
+
temperature=0.0,
|
| 92 |
+
response_format={
|
| 93 |
+
"type": "json_schema",
|
| 94 |
+
"json_schema": {
|
| 95 |
+
"name": "GeoPoints",
|
| 96 |
+
"schema": {
|
| 97 |
+
"type": "object",
|
| 98 |
+
"additionalProperties": False,
|
| 99 |
+
"required": ["points"],
|
| 100 |
+
"properties": {
|
| 101 |
+
"points": {
|
| 102 |
+
"type": "array",
|
| 103 |
+
"minItems": 2,
|
| 104 |
+
"items": {
|
| 105 |
+
"type": "object",
|
| 106 |
+
"required": ["label","address","lat","lng"],
|
| 107 |
+
"properties": {
|
| 108 |
+
"label": {"type": "string"},
|
| 109 |
+
"address": {"type": "string"},
|
| 110 |
+
"lat": {"type": "number"},
|
| 111 |
+
"lng": {"type": "number"}
|
| 112 |
+
}
|
| 113 |
+
}
|
| 114 |
+
}
|
| 115 |
+
}
|
| 116 |
+
}
|
| 117 |
+
}
|
| 118 |
+
},
|
| 119 |
+
)
|
| 120 |
+
geo_content = resp.choices[0].message.content
|
| 121 |
+
geo_data = json.loads(geo_content)
|
| 122 |
+
except Exception as exc:
|
| 123 |
+
raise RuntimeError(f"Geocoding failed: {exc}")
|
| 124 |
+
|
| 125 |
+
geo_points = geo_data.get("points", [])
|
| 126 |
+
if len(geo_points) < 2:
|
| 127 |
+
raise RuntimeError("Not enough points after geocoding")
|
| 128 |
+
|
| 129 |
+
# 2) Build distance matrix using haversine
|
| 130 |
+
def haversine_km(a: Tuple[float, float], b: Tuple[float, float]) -> float:
|
| 131 |
+
from math import radians, sin, cos, asin, sqrt
|
| 132 |
+
lat1, lon1 = a
|
| 133 |
+
lat2, lon2 = b
|
| 134 |
+
R = 6371.0
|
| 135 |
+
dlat = radians(lat2 - lat1)
|
| 136 |
+
dlon = radians(lon2 - lon1)
|
| 137 |
+
lat1 = radians(lat1)
|
| 138 |
+
lat2 = radians(lat2)
|
| 139 |
+
h = sin(dlat/2)**2 + cos(lat1)*cos(lat2)*sin(dlon/2)**2
|
| 140 |
+
return 2*R*asin(sqrt(h))
|
| 141 |
+
|
| 142 |
+
coords = [(p["lat"], p["lng"]) for p in geo_points]
|
| 143 |
+
n = len(coords)
|
| 144 |
+
dist = [[0.0]*n for _ in range(n)]
|
| 145 |
+
for i in range(n):
|
| 146 |
+
for j in range(i+1, n):
|
| 147 |
+
d = haversine_km(coords[i], coords[j])
|
| 148 |
+
dist[i][j] = dist[j][i] = d
|
| 149 |
+
|
| 150 |
+
# 3) Naive sequence cost (origin -> input order)
|
| 151 |
+
naive_order = list(range(n)) # 0 origin, then in-given order
|
| 152 |
+
def path_cost(order: List[int]) -> float:
|
| 153 |
+
c = 0.0
|
| 154 |
+
for i in range(len(order)-1):
|
| 155 |
+
c += dist[order[i]][order[i+1]]
|
| 156 |
+
return c
|
| 157 |
+
|
| 158 |
+
naive_cost = path_cost(naive_order)
|
| 159 |
+
|
| 160 |
+
# 4) Heuristic route: nearest neighbor then 2-opt
|
| 161 |
+
remaining = list(range(1, n))
|
| 162 |
+
route = [0]
|
| 163 |
+
while remaining:
|
| 164 |
+
last = route[-1]
|
| 165 |
+
next_idx = min(remaining, key=lambda k: dist[last][k])
|
| 166 |
+
route.append(next_idx)
|
| 167 |
+
remaining.remove(next_idx)
|
| 168 |
+
|
| 169 |
+
def two_opt(order: List[int]) -> List[int]:
|
| 170 |
+
improved = True
|
| 171 |
+
best = order[:]
|
| 172 |
+
best_cost = path_cost(best)
|
| 173 |
+
while improved:
|
| 174 |
+
improved = False
|
| 175 |
+
for i in range(1, len(best)-2):
|
| 176 |
+
for j in range(i+1, len(best)-1):
|
| 177 |
+
if j - i == 1:
|
| 178 |
+
continue
|
| 179 |
+
new_order = best[:i] + best[i:j][::-1] + best[j:]
|
| 180 |
+
new_cost = path_cost(new_order)
|
| 181 |
+
if new_cost + 1e-6 < best_cost:
|
| 182 |
+
best, best_cost = new_order, new_cost
|
| 183 |
+
improved = True
|
| 184 |
+
# Cap iterations implicitly by convergence
|
| 185 |
+
return best
|
| 186 |
+
|
| 187 |
+
route = two_opt(route)
|
| 188 |
+
|
| 189 |
+
# 5) Compose response: distances/time per leg from origin to last stop
|
| 190 |
+
avg_speed = float(req.average_speed_kmph)
|
| 191 |
+
legs: List[Stop] = []
|
| 192 |
+
total_km = 0.0
|
| 193 |
+
total_min = 0
|
| 194 |
+
for order_idx in range(1, len(route)):
|
| 195 |
+
prev_i = route[order_idx-1]
|
| 196 |
+
curr_i = route[order_idx]
|
| 197 |
+
leg_km = dist[prev_i][curr_i]
|
| 198 |
+
leg_min = int(round((leg_km / max(1e-6, avg_speed)) * 60))
|
| 199 |
+
total_km += leg_km
|
| 200 |
+
total_min += leg_min
|
| 201 |
+
p = geo_points[curr_i]
|
| 202 |
+
legs.append(Stop(order=order_idx, label=p["label"], address=p["address"], eta_minutes=leg_min, distance_km=leg_km))
|
| 203 |
+
|
| 204 |
+
fuel_cost = round(total_km * float(req.fuel_cost_per_km), 2)
|
| 205 |
+
naive_cost_km = naive_cost
|
| 206 |
+
naive_fuel = round(naive_cost_km * float(req.fuel_cost_per_km), 2)
|
| 207 |
+
savings = max(0.0, naive_fuel - fuel_cost)
|
| 208 |
+
|
| 209 |
+
# 6) Google Maps URL (best-effort)
|
| 210 |
+
try:
|
| 211 |
+
from urllib.parse import quote
|
| 212 |
+
origin_addr = quote(geo_points[route[0]]["address"]) # should be origin
|
| 213 |
+
final_addr = quote(geo_points[route[-1]]["address"]) if len(route) > 1 else origin_addr
|
| 214 |
+
waypoints = []
|
| 215 |
+
if len(route) > 2:
|
| 216 |
+
for idx in route[1:-1]:
|
| 217 |
+
waypoints.append(quote(geo_points[idx]["address"]))
|
| 218 |
+
wp = "%7C".join(waypoints)
|
| 219 |
+
maps_url = f"https://www.google.com/maps/dir/?api=1&origin={origin_addr}&destination={final_addr}" + (f"&waypoints={wp}" if wp else "")
|
| 220 |
+
except Exception:
|
| 221 |
+
maps_url = None
|
| 222 |
+
|
| 223 |
+
# Google Maps Embed API (optional, requires key)
|
| 224 |
+
maps_embed_url = None
|
| 225 |
+
try:
|
| 226 |
+
if self.maps_embed_key:
|
| 227 |
+
# Prefer lat,lng to avoid geocoding ambiguity on client
|
| 228 |
+
def latlng(i: int) -> str:
|
| 229 |
+
p = geo_points[i]
|
| 230 |
+
return f"{p['lat']},{p['lng']}"
|
| 231 |
+
origin_ll = latlng(route[0])
|
| 232 |
+
dest_ll = latlng(route[-1]) if len(route) > 1 else origin_ll
|
| 233 |
+
wps = "|".join(latlng(i) for i in route[1:-1]) if len(route) > 2 else ""
|
| 234 |
+
from urllib.parse import quote
|
| 235 |
+
wps_q = quote(wps, safe='|,')
|
| 236 |
+
maps_embed_url = (
|
| 237 |
+
f"https://www.google.com/maps/embed/v1/directions?key={self.maps_embed_key}"
|
| 238 |
+
f"&origin={quote(origin_ll)}&destination={quote(dest_ll)}" + (f"&waypoints={wps_q}" if wps else "")
|
| 239 |
+
)
|
| 240 |
+
except Exception:
|
| 241 |
+
maps_embed_url = None
|
| 242 |
+
|
| 243 |
+
summary = (
|
| 244 |
+
f"Optimized a route with {len(legs)} stops at ~{avg_speed:.0f} km/h. "
|
| 245 |
+
f"Estimated distance {total_km:.1f} km in {total_min} min. "
|
| 246 |
+
f"Fuel cost ${fuel_cost:.2f}. Compared to naive order (~{naive_cost_km:.1f} km), estimated savings ${savings:.2f}."
|
| 247 |
+
)
|
| 248 |
+
|
| 249 |
+
return RouteResponse(
|
| 250 |
+
summary=summary,
|
| 251 |
+
total_distance_km=round(total_km, 2),
|
| 252 |
+
total_time_minutes=int(total_min),
|
| 253 |
+
estimated_fuel_cost=fuel_cost,
|
| 254 |
+
estimated_savings=round(savings, 2),
|
| 255 |
+
route=legs,
|
| 256 |
+
maps_url=maps_url,
|
| 257 |
+
points=[GeoPoint(**p) for p in geo_points],
|
| 258 |
+
order=route,
|
| 259 |
+
maps_embed_url=maps_embed_url,
|
| 260 |
+
)
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
app = FastAPI(title="Route Optimizer", version="0.1.0")
|
| 264 |
+
app.add_middleware(
|
| 265 |
+
CORSMiddleware,
|
| 266 |
+
allow_origins=["*"],
|
| 267 |
+
allow_credentials=True,
|
| 268 |
+
allow_methods=["*"],
|
| 269 |
+
allow_headers=["*"],
|
| 270 |
+
)
|
| 271 |
+
|
| 272 |
+
router = APIRouter(prefix="/route", tags=["route"])
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
@router.get("/health")
|
| 276 |
+
def health() -> dict:
|
| 277 |
+
return {
|
| 278 |
+
"status": "ok",
|
| 279 |
+
"openai_key": bool(os.getenv("OPENAI_API_KEY")),
|
| 280 |
+
"model": os.getenv("OPENAI_MODEL", "gpt-4o-mini"),
|
| 281 |
+
"base_url": os.getenv("OPENAI_BASE_URL") or None,
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
@router.post("/optimize", response_model=RouteResponse)
|
| 286 |
+
def optimize(payload: RouteRequest) -> RouteResponse:
|
| 287 |
+
try:
|
| 288 |
+
return RouteService().optimize(payload)
|
| 289 |
+
except Exception as exc:
|
| 290 |
+
raise HTTPException(status_code=502, detail=f"Route optimizer error: {exc}")
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
app.include_router(router)
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
@app.get("/", response_class=FileResponse)
|
| 297 |
+
def index() -> FileResponse:
|
| 298 |
+
base = os.path.dirname(__file__)
|
| 299 |
+
return FileResponse(os.path.join(base, "static", "index.html"))
|
| 300 |
+
|
| 301 |
+
|
AIRoute/app/static/index.html
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8"/>
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
| 6 |
+
<title>Route Optimizer</title>
|
| 7 |
+
<link rel="stylesheet" href="/static/suite.css"/>
|
| 8 |
+
<style>
|
| 9 |
+
.mono{font-family:ui-monospace,Menlo,Consolas,monospace}
|
| 10 |
+
.grid.two-col{grid-template-columns:1fr}
|
| 11 |
+
@media (min-width:1000px){.grid.two-col{grid-template-columns:1fr 1fr}}
|
| 12 |
+
.stops{display:grid;gap:12px}
|
| 13 |
+
.actions{display:flex;gap:12px;flex-wrap:wrap;margin-top:12px}
|
| 14 |
+
.stats{display:grid;gap:12px;grid-template-columns:1fr 1fr; margin:8px 0}
|
| 15 |
+
@media (min-width:900px){.stats{grid-template-columns:repeat(4,1fr)}}
|
| 16 |
+
.pill{background:#fff;border:1px solid var(--border);border-radius:12px;padding:10px}
|
| 17 |
+
.pill .k{font-weight:800;font-size:18px}
|
| 18 |
+
.pill .l{color:var(--muted);font-size:12px;margin-top:4px}
|
| 19 |
+
.pill.good{border-color:#22c55e33;background:#ecfdf5}
|
| 20 |
+
.stop-remove{appearance:none;border:0;background:#f3f4f6;color:#111827;padding:8px 10px;border-radius:8px;font-weight:700;cursor:pointer}
|
| 21 |
+
.stop-remove:hover{background:#e5e7eb}
|
| 22 |
+
</style>
|
| 23 |
+
</head>
|
| 24 |
+
<body>
|
| 25 |
+
<div id="suite-shared-header"></div>
|
| 26 |
+
<div class="container">
|
| 27 |
+
<h1>Route Optimizer</h1>
|
| 28 |
+
<p class="muted">Enter origin and destinations to get the fastest route, ETAs, and savings.</p>
|
| 29 |
+
<div class="grid" id="grid">
|
| 30 |
+
<div class="card">
|
| 31 |
+
<div class="form-row">
|
| 32 |
+
<div>
|
| 33 |
+
<label for="originLabel">Origin label</label>
|
| 34 |
+
<input id="originLabel" type="text" placeholder="Warehouse"/>
|
| 35 |
+
</div>
|
| 36 |
+
<div>
|
| 37 |
+
<label for="origin">Origin address</label>
|
| 38 |
+
<input id="origin" type="text" placeholder="1600 Amphitheatre Pkwy, Mountain View, CA"/>
|
| 39 |
+
</div>
|
| 40 |
+
</div>
|
| 41 |
+
<label for="fuel">Fuel cost per km</label>
|
| 42 |
+
<input id="fuel" type="number" min="0" step="0.01" value="0.2"/>
|
| 43 |
+
<h2>Destinations</h2>
|
| 44 |
+
<div id="stops" class="stops"></div>
|
| 45 |
+
<div class="actions">
|
| 46 |
+
<button id="addStop" class="btn" type="button">Add destination</button>
|
| 47 |
+
<button id="optimize" class="btn" type="button">Optimize</button>
|
| 48 |
+
</div>
|
| 49 |
+
</div>
|
| 50 |
+
<div id="out" class="card" style="display:none"></div>
|
| 51 |
+
</div>
|
| 52 |
+
</div>
|
| 53 |
+
<script src="/static/header.js"></script>
|
| 54 |
+
<script>
|
| 55 |
+
const stopsEl = document.getElementById('stops');
|
| 56 |
+
const addStopBtn = document.getElementById('addStop');
|
| 57 |
+
const out = document.getElementById('out');
|
| 58 |
+
const grid = document.getElementById('grid');
|
| 59 |
+
function stopRow(i){
|
| 60 |
+
const wrap = document.createElement('div');
|
| 61 |
+
wrap.className='form-row';
|
| 62 |
+
wrap.innerHTML = `
|
| 63 |
+
<div>
|
| 64 |
+
<label>Label</label>
|
| 65 |
+
<input type="text" placeholder="Stop ${i+1}"/>
|
| 66 |
+
</div>
|
| 67 |
+
<div>
|
| 68 |
+
<label>Address</label>
|
| 69 |
+
<input type="text" placeholder="address line"/>
|
| 70 |
+
</div>
|
| 71 |
+
<div style="align-self:flex-end;">
|
| 72 |
+
<button class="stop-remove" type="button">Remove</button>
|
| 73 |
+
</div>
|
| 74 |
+
`;
|
| 75 |
+
wrap.querySelector('.stop-remove').addEventListener('click', ()=>{
|
| 76 |
+
wrap.remove();
|
| 77 |
+
});
|
| 78 |
+
return wrap;
|
| 79 |
+
}
|
| 80 |
+
function addStop(){
|
| 81 |
+
stopsEl.appendChild(stopRow(stopsEl.children.length));
|
| 82 |
+
}
|
| 83 |
+
addStopBtn.addEventListener('click', ()=>{
|
| 84 |
+
addStop();
|
| 85 |
+
});
|
| 86 |
+
// seed 5
|
| 87 |
+
for(let i=0;i<5;i++) addStop();
|
| 88 |
+
|
| 89 |
+
document.getElementById('optimize').addEventListener('click', async ()=>{
|
| 90 |
+
const origin = {
|
| 91 |
+
label: document.getElementById('originLabel').value || 'Origin',
|
| 92 |
+
address: document.getElementById('origin').value || ''
|
| 93 |
+
};
|
| 94 |
+
const dests = Array.from(stopsEl.children).map(row=>{
|
| 95 |
+
const inputs = row.querySelectorAll('input');
|
| 96 |
+
return { label: inputs[0].value || 'Stop', address: inputs[1].value || '' };
|
| 97 |
+
}).filter(d=>d.address.trim().length>0);
|
| 98 |
+
const fuel = parseFloat(document.getElementById('fuel').value || '0.2');
|
| 99 |
+
out.style.display='block';
|
| 100 |
+
grid.classList.add('two-col');
|
| 101 |
+
out.innerHTML = '<div class="muted">(optimizing...)</div>';
|
| 102 |
+
try{
|
| 103 |
+
const res = await fetch('/route/optimize', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({origin, destinations:dests, fuel_cost_per_km:fuel})});
|
| 104 |
+
if(!res.ok) throw new Error(await res.text());
|
| 105 |
+
const data = await res.json();
|
| 106 |
+
const legs = (data.route||[]).map(s=>`<tr><td>${s.order}</td><td>${s.label}</td><td>${s.address}</td><td>${s.distance_km.toFixed(2)} km</td><td>${s.eta_minutes} min</td></tr>`).join('');
|
| 107 |
+
// Visual: simple SVG polyline map approximation using normalized lat/lng
|
| 108 |
+
let svg = '';
|
| 109 |
+
try{
|
| 110 |
+
const pts = (data.points||[]);
|
| 111 |
+
const ord = (data.order||[]);
|
| 112 |
+
if(pts.length && ord.length){
|
| 113 |
+
const lats = ord.map(i=>pts[i].lat);
|
| 114 |
+
const lngs = ord.map(i=>pts[i].lng);
|
| 115 |
+
const minLat=Math.min(...lats), maxLat=Math.max(...lats);
|
| 116 |
+
const minLng=Math.min(...lngs), maxLng=Math.max(...lngs);
|
| 117 |
+
const pad=12, W=440, H=280;
|
| 118 |
+
const norm=(lat,lng)=>[
|
| 119 |
+
pad + ( (lng-minLng)/(maxLng-minLng||1) )*(W-2*pad),
|
| 120 |
+
pad + ( 1-((lat-minLat)/(maxLat-minLat||1)) )*(H-2*pad)
|
| 121 |
+
];
|
| 122 |
+
const pointsStr = ord.map(i=>{
|
| 123 |
+
const p = norm(pts[i].lat, pts[i].lng);
|
| 124 |
+
return p[0].toFixed(1)+','+p[1].toFixed(1);
|
| 125 |
+
}).join(' ');
|
| 126 |
+
const markers = ord.map((i,idx)=>{
|
| 127 |
+
const p = norm(pts[i].lat, pts[i].lng);
|
| 128 |
+
return `<circle cx="${p[0].toFixed(1)}" cy="${p[1].toFixed(1)}" r="4" fill="#111827"/><text x="${(p[0]+6).toFixed(1)}" y="${(p[1]-6).toFixed(1)}" font-size="10" fill="#111827">${idx}</text>`;
|
| 129 |
+
}).join('');
|
| 130 |
+
svg = `<svg viewBox="0 0 ${W} ${H}" style="width:100%;max-width:520px;border:1px solid var(--border);border-radius:8px;background:#fff">
|
| 131 |
+
<polyline points="${pointsStr}" fill="none" stroke="#3b82f6" stroke-width="2"/>
|
| 132 |
+
${markers}
|
| 133 |
+
</svg>`;
|
| 134 |
+
}
|
| 135 |
+
}catch(_){ svg=''; }
|
| 136 |
+
|
| 137 |
+
const stats = `
|
| 138 |
+
<div class="stats">
|
| 139 |
+
<div class="pill"><div class="k">${data.total_distance_km.toFixed(1)} km</div><div class="l">Distance</div></div>
|
| 140 |
+
<div class="pill"><div class="k">${data.total_time_minutes} min</div><div class="l">Time</div></div>
|
| 141 |
+
<div class="pill"><div class="k">$${data.estimated_fuel_cost.toFixed(2)}</div><div class="l">Fuel</div></div>
|
| 142 |
+
<div class="pill good"><div class="k">$${data.estimated_savings.toFixed(2)}</div><div class="l">Savings</div></div>
|
| 143 |
+
</div>`;
|
| 144 |
+
|
| 145 |
+
out.innerHTML = `
|
| 146 |
+
<div><strong>Summary</strong></div>
|
| 147 |
+
<p style="white-space:pre-wrap;">${(data.summary||'').replace(/</g,'<').replace(/>/g,'>')}</p>
|
| 148 |
+
${stats}
|
| 149 |
+
${svg ? `<div style=\"margin:8px 0;\"><strong>Route preview</strong></div>${svg}` : ''}
|
| 150 |
+
${data.maps_embed_url ? `<div style=\"margin-top:8px;\"><strong>Google Maps</strong></div><iframe title=\"Route\" width=\"100%\" height=\"360\" style=\"border:0;border-radius:10px;\" loading=\"lazy\" referrerpolicy=\"no-referrer-when-downgrade\" src=\"${data.maps_embed_url}\"></iframe>` : (data.maps_url ? `<div style=\"margin-top:8px;\"><a class=\"btn\" href=\"${data.maps_url}\" target=\"_blank\" rel=\"noopener\">Open in Google Maps</a></div>` : '')}
|
| 151 |
+
<table class="table" style="margin-top:8px;">
|
| 152 |
+
<thead><tr><th>#</th><th>Label</th><th>Address</th><th>Distance</th><th>ETA</th></tr></thead>
|
| 153 |
+
<tbody>${legs}</tbody>
|
| 154 |
+
</table>
|
| 155 |
+
<div style="margin-top:8px;">
|
| 156 |
+
<strong>Total:</strong> ${data.total_distance_km.toFixed(2)} km, ${data.total_time_minutes} min
|
| 157 |
+
</div>
|
| 158 |
+
<div><strong>Fuel cost:</strong> $${data.estimated_fuel_cost.toFixed(2)} | <strong>Estimated savings:</strong> $${data.estimated_savings.toFixed(2)}</div>
|
| 159 |
+
`;
|
| 160 |
+
}catch(e){
|
| 161 |
+
out.innerHTML = `<div style="color:#b91c1c;">Error: ${String(e)}</div>`;
|
| 162 |
+
}
|
| 163 |
+
});
|
| 164 |
+
</script>
|
| 165 |
+
</body>
|
| 166 |
+
</html>
|
| 167 |
+
|
| 168 |
+
|
AISaas/app/main.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI, APIRouter, HTTPException
|
| 2 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
+
from fastapi.responses import FileResponse
|
| 4 |
+
from pydantic import BaseModel, Field, conint, confloat
|
| 5 |
+
from typing import List, Optional
|
| 6 |
+
import os
|
| 7 |
+
import sys
|
| 8 |
+
import json
|
| 9 |
+
from dotenv import load_dotenv, find_dotenv
|
| 10 |
+
|
| 11 |
+
ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
| 12 |
+
if ROOT_DIR not in sys.path:
|
| 13 |
+
sys.path.append(ROOT_DIR)
|
| 14 |
+
|
| 15 |
+
load_dotenv(find_dotenv(), override=True)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class ActivityEvent(BaseModel):
|
| 19 |
+
user_id: str
|
| 20 |
+
timestamp: str
|
| 21 |
+
event: str
|
| 22 |
+
meta: Optional[dict] = None
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class ChurnAnalysisRequest(BaseModel):
|
| 26 |
+
company: Optional[str] = None
|
| 27 |
+
lookback_days: conint(ge=7, le=365) = 90
|
| 28 |
+
events: List[ActivityEvent] = Field(default_factory=list)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class AtRiskCustomer(BaseModel):
|
| 32 |
+
user_id: str
|
| 33 |
+
risk_score: confloat(ge=0.0, le=1.0)
|
| 34 |
+
signals: List[str]
|
| 35 |
+
recommended_actions: List[str]
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class ChurnAnalysisResponse(BaseModel):
|
| 39 |
+
summary: str
|
| 40 |
+
cohort: List[str] = []
|
| 41 |
+
at_risk: List[AtRiskCustomer]
|
| 42 |
+
global_recommendations: List[str]
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
from openai import OpenAI
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
SYSTEM_PROMPT = (
|
| 49 |
+
"You are a senior SaaS Churn Analyst. Your job is to review raw user activity logs and produce an executive-grade "
|
| 50 |
+
"churn-risk analysis that is precise, defensible, and actionable.\n\n"
|
| 51 |
+
"Guidelines:\n"
|
| 52 |
+
"- Be conservative; never invent facts. Base every claim on the provided logs only.\n"
|
| 53 |
+
"- Consider signals such as: dropping login frequency, no usage in lookback window, reduced feature adoption, loss of breadth (fewer distinct features), recent plan downgrade, failed billing, support tickets (severity), email unsubscribes, expiring or ended trials, lack of invites/seat growth.\n"
|
| 54 |
+
"- Calibrate risk_score as: 0.80–1.00 = high risk, 0.40–0.79 = medium, 0.00–0.39 = low. Use increments of ~0.05 and avoid rounding to 0 or 1 unless clearly warranted.\n"
|
| 55 |
+
"- For each at-risk user, include 3–6 concise signals. Each signal should reference specific evidence (event names, dates, simple counts) in plain text.\n"
|
| 56 |
+
"- Recommended actions must be practical and role-targeted (e.g., Customer Success email, in‑app nudge, tailored enablement, billing outreach) and limited to 3–5 per user.\n"
|
| 57 |
+
"- Keep the summary executive-friendly (5–8 sentences).\n"
|
| 58 |
+
"- Sort at_risk in descending risk_score. If no users are risky, return an empty list and explain why in the summary.\n\n"
|
| 59 |
+
"Return STRICTLY VALID JSON exactly matching the schema."
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class ChurnPredictorService:
|
| 64 |
+
def __init__(self) -> None:
|
| 65 |
+
key = (os.getenv("OPENAI_API_KEY") or "").strip().strip('"').strip("'")
|
| 66 |
+
if not key:
|
| 67 |
+
raise RuntimeError("OPENAI_API_KEY is not set")
|
| 68 |
+
base_url = (os.getenv("OPENAI_BASE_URL") or "").strip().strip('"').strip("'") or None
|
| 69 |
+
kwargs = {"api_key": key}
|
| 70 |
+
if base_url:
|
| 71 |
+
kwargs["base_url"] = base_url
|
| 72 |
+
self.client = OpenAI(**kwargs)
|
| 73 |
+
self.model = os.getenv("OPENAI_MODEL", "gpt-4o-mini")
|
| 74 |
+
|
| 75 |
+
def analyze(self, req: ChurnAnalysisRequest) -> ChurnAnalysisResponse:
|
| 76 |
+
# Compose a compact event list for the model
|
| 77 |
+
lines = [
|
| 78 |
+
f"Company: {req.company or 'N/A'}",
|
| 79 |
+
f"Lookback days: {req.lookback_days}",
|
| 80 |
+
"Events: user_id | timestamp | event | meta",
|
| 81 |
+
]
|
| 82 |
+
for ev in req.events[:2000]:
|
| 83 |
+
meta_str = json.dumps(ev.meta, ensure_ascii=False) if ev.meta else "{}"
|
| 84 |
+
meta_str = meta_str.replace("\n", " ")
|
| 85 |
+
lines.append(f"{ev.user_id} | {ev.timestamp} | {ev.event} | {meta_str}")
|
| 86 |
+
user_msg = "\n".join(lines)
|
| 87 |
+
|
| 88 |
+
try:
|
| 89 |
+
resp = self.client.chat.completions.create(
|
| 90 |
+
model=self.model,
|
| 91 |
+
messages=[
|
| 92 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 93 |
+
{"role": "user", "content": user_msg},
|
| 94 |
+
],
|
| 95 |
+
temperature=0.1,
|
| 96 |
+
response_format={
|
| 97 |
+
"type": "json_schema",
|
| 98 |
+
"json_schema": {
|
| 99 |
+
"name": "ChurnAnalysisResponse",
|
| 100 |
+
"schema": {
|
| 101 |
+
"type": "object",
|
| 102 |
+
"additionalProperties": False,
|
| 103 |
+
"required": ["summary", "at_risk", "global_recommendations"],
|
| 104 |
+
"properties": {
|
| 105 |
+
"summary": {"type": "string"},
|
| 106 |
+
"cohort": {"type": "array", "items": {"type": "string"}},
|
| 107 |
+
"at_risk": {
|
| 108 |
+
"type": "array",
|
| 109 |
+
"items": {
|
| 110 |
+
"type": "object",
|
| 111 |
+
"required": ["user_id", "risk_score", "signals", "recommended_actions"],
|
| 112 |
+
"properties": {
|
| 113 |
+
"user_id": {"type": "string"},
|
| 114 |
+
"risk_score": {"type": "number", "minimum": 0.0, "maximum": 1.0},
|
| 115 |
+
"signals": {"type": "array", "items": {"type": "string"}},
|
| 116 |
+
"recommended_actions": {"type": "array", "items": {"type": "string"}}
|
| 117 |
+
}
|
| 118 |
+
}
|
| 119 |
+
},
|
| 120 |
+
"global_recommendations": {"type": "array", "items": {"type": "string"}}
|
| 121 |
+
}
|
| 122 |
+
}
|
| 123 |
+
}
|
| 124 |
+
},
|
| 125 |
+
)
|
| 126 |
+
content = resp.choices[0].message.content
|
| 127 |
+
except Exception:
|
| 128 |
+
resp = self.client.chat.completions.create(
|
| 129 |
+
model=self.model,
|
| 130 |
+
messages=[
|
| 131 |
+
{"role": "system", "content": SYSTEM_PROMPT + "\nReturn ONLY JSON."},
|
| 132 |
+
{"role": "user", "content": user_msg},
|
| 133 |
+
],
|
| 134 |
+
temperature=0.2,
|
| 135 |
+
response_format={"type": "json_object"},
|
| 136 |
+
)
|
| 137 |
+
content = resp.choices[0].message.content
|
| 138 |
+
|
| 139 |
+
try:
|
| 140 |
+
data = json.loads(content)
|
| 141 |
+
except Exception:
|
| 142 |
+
start = content.find("{")
|
| 143 |
+
end = content.rfind("}")
|
| 144 |
+
if start != -1 and end != -1 and end > start:
|
| 145 |
+
data = json.loads(content[start : end + 1])
|
| 146 |
+
else:
|
| 147 |
+
raise RuntimeError("Invalid JSON from model")
|
| 148 |
+
|
| 149 |
+
return ChurnAnalysisResponse.model_validate(data)
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
app = FastAPI(title="SaaS Churn Predictor", version="0.1.0")
|
| 153 |
+
app.add_middleware(
|
| 154 |
+
CORSMiddleware,
|
| 155 |
+
allow_origins=["*"],
|
| 156 |
+
allow_credentials=True,
|
| 157 |
+
allow_methods=["*"],
|
| 158 |
+
allow_headers=["*"],
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
router = APIRouter(prefix="/saas", tags=["saas"])
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
@router.get("/health")
|
| 165 |
+
def health() -> dict:
|
| 166 |
+
return {
|
| 167 |
+
"status": "ok",
|
| 168 |
+
"openai_key": bool(os.getenv("OPENAI_API_KEY")),
|
| 169 |
+
"model": os.getenv("OPENAI_MODEL", "gpt-4o-mini"),
|
| 170 |
+
"base_url": os.getenv("OPENAI_BASE_URL") or None,
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
@router.post("/analyze", response_model=ChurnAnalysisResponse)
|
| 175 |
+
def analyze(payload: ChurnAnalysisRequest) -> ChurnAnalysisResponse:
|
| 176 |
+
try:
|
| 177 |
+
return ChurnPredictorService().analyze(payload)
|
| 178 |
+
except Exception as exc:
|
| 179 |
+
raise HTTPException(status_code=502, detail=f"Churn predictor error: {exc}")
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
app.include_router(router)
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
@app.get("/", response_class=FileResponse)
|
| 186 |
+
def index() -> FileResponse:
|
| 187 |
+
base = os.path.dirname(__file__)
|
| 188 |
+
return FileResponse(os.path.join(base, "static", "index.html"))
|
| 189 |
+
|
| 190 |
+
|
AISaas/app/static/index.html
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8"/>
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
| 6 |
+
<title>SaaS Churn Predictor</title>
|
| 7 |
+
<link rel="stylesheet" href="/static/suite.css"/>
|
| 8 |
+
<style>
|
| 9 |
+
.mono{font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace}
|
| 10 |
+
.grid.two-col{grid-template-columns:1fr}
|
| 11 |
+
@media (min-width:1000px){.grid.two-col{grid-template-columns:1fr 1fr}}
|
| 12 |
+
</style>
|
| 13 |
+
</head>
|
| 14 |
+
<body>
|
| 15 |
+
<div id="suite-shared-header"></div>
|
| 16 |
+
<div class="container">
|
| 17 |
+
<h1>SaaS Churn Predictor</h1>
|
| 18 |
+
<p class="muted">Paste mock user activity logs; get at‑risk customers and actions.</p>
|
| 19 |
+
|
| 20 |
+
<div class="grid" id="grid">
|
| 21 |
+
<div class="card">
|
| 22 |
+
<div class="form-row">
|
| 23 |
+
<div>
|
| 24 |
+
<label for="company">Company (optional)</label>
|
| 25 |
+
<input id="company" type="text" placeholder="Acme SaaS"/>
|
| 26 |
+
</div>
|
| 27 |
+
<div>
|
| 28 |
+
<label for="lookback">Lookback (days)</label>
|
| 29 |
+
<input id="lookback" type="number" min="7" max="365" value="90"/>
|
| 30 |
+
</div>
|
| 31 |
+
</div>
|
| 32 |
+
<label for="events">User activity logs (JSON array)</label>
|
| 33 |
+
<textarea id="events" class="mono" rows="14" placeholder='[
|
| 34 |
+
{"user_id": "u_1", "timestamp": "2025-09-01T08:00:00Z", "event": "login"},
|
| 35 |
+
{"user_id": "u_1", "timestamp": "2025-09-05T12:14:00Z", "event": "feature_used", "meta": {"feature": "dashboards"}},
|
| 36 |
+
{"user_id": "u_2", "timestamp": "2025-09-02T09:10:00Z", "event": "support_ticket", "meta": {"severity": "medium"}}
|
| 37 |
+
]'></textarea>
|
| 38 |
+
<button id="analyzeBtn" class="btn">Analyze</button>
|
| 39 |
+
</div>
|
| 40 |
+
<div class="card" id="output" style="display:none;"></div>
|
| 41 |
+
</div>
|
| 42 |
+
</div>
|
| 43 |
+
<script src="/static/header.js"></script>
|
| 44 |
+
<script>
|
| 45 |
+
const elCompany = document.getElementById('company');
|
| 46 |
+
const elLook = document.getElementById('lookback');
|
| 47 |
+
const elEvents = document.getElementById('events');
|
| 48 |
+
const elOut = document.getElementById('output');
|
| 49 |
+
document.getElementById('analyzeBtn').addEventListener('click', async () => {
|
| 50 |
+
let events = [];
|
| 51 |
+
try {
|
| 52 |
+
events = JSON.parse(elEvents.value || '[]');
|
| 53 |
+
if (!Array.isArray(events)) throw new Error('Events must be a JSON array');
|
| 54 |
+
} catch (e) {
|
| 55 |
+
elOut.style.display = 'block';
|
| 56 |
+
document.getElementById('grid').classList.add('two-col');
|
| 57 |
+
elOut.innerHTML = `<div style="color:#b91c1c;">Invalid JSON: ${String(e)}</div>`;
|
| 58 |
+
return;
|
| 59 |
+
}
|
| 60 |
+
const payload = {
|
| 61 |
+
company: elCompany.value.trim() || null,
|
| 62 |
+
lookback_days: parseInt(elLook.value, 10) || 90,
|
| 63 |
+
events
|
| 64 |
+
};
|
| 65 |
+
elOut.style.display = 'block';
|
| 66 |
+
document.getElementById('grid').classList.add('two-col');
|
| 67 |
+
elOut.innerHTML = '<div class="muted">(analyzing...)</div>';
|
| 68 |
+
try {
|
| 69 |
+
const res = await fetch('/saas/analyze', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(payload) });
|
| 70 |
+
if (!res.ok) throw new Error(await res.text());
|
| 71 |
+
const data = await res.json();
|
| 72 |
+
const risks = (data.at_risk || []).map(r => {
|
| 73 |
+
const sig = (r.signals||[]).map(s=>`<li>${s}</li>`).join('');
|
| 74 |
+
const acts = (r.recommended_actions||[]).map(a=>`<li>${a}</li>`).join('');
|
| 75 |
+
return `<div class="card" style="margin-top:12px;">
|
| 76 |
+
<div><strong>User:</strong> ${r.user_id} <span class="muted">(risk ${(r.risk_score??0).toFixed(2)})</span></div>
|
| 77 |
+
<div style="margin-top:6px;"><strong>Signals</strong><ul>${sig || '<li class="muted">None</li>'}</ul></div>
|
| 78 |
+
<div style="margin-top:6px;"><strong>Recommended actions</strong><ul>${acts || '<li class="muted">None</li>'}</ul></div>
|
| 79 |
+
</div>`;
|
| 80 |
+
}).join('');
|
| 81 |
+
const cohort = (data.cohort||[]).map(c=>`<code class="mono">${c}</code>`).join(' ');
|
| 82 |
+
elOut.innerHTML = `
|
| 83 |
+
<div><strong>Summary</strong></div>
|
| 84 |
+
<div style="white-space:pre-wrap;margin-top:6px;">${(data.summary||'').replace(/</g,'<').replace(/>/g,'>')}</div>
|
| 85 |
+
<div style="margin-top:12px;"><strong>At-risk customers</strong></div>
|
| 86 |
+
${risks || '<div class="muted">None identified</div>'}
|
| 87 |
+
<div style="margin-top:12px;"><strong>Global recommendations</strong></div>
|
| 88 |
+
<ul>${(data.global_recommendations||[]).map(g=>`<li>${g}</li>`).join('') || '<li class="muted">None</li>'}</ul>
|
| 89 |
+
<div style="margin-top:12px;"><strong>Cohort</strong> ${cohort || '<span class="muted">N/A</span>'}</div>
|
| 90 |
+
`;
|
| 91 |
+
} catch (e) {
|
| 92 |
+
elOut.innerHTML = `<div style="color:#b91c1c;">Error: ${String(e)}</div>`;
|
| 93 |
+
}
|
| 94 |
+
});
|
| 95 |
+
</script>
|
| 96 |
+
</body>
|
| 97 |
+
</html>
|
| 98 |
+
|
| 99 |
+
|
AIStrategy/app/main.py
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI, APIRouter, HTTPException
|
| 2 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
+
from fastapi.responses import FileResponse
|
| 4 |
+
from pydantic import BaseModel, Field
|
| 5 |
+
from typing import List, Literal, Optional
|
| 6 |
+
import os
|
| 7 |
+
import sys
|
| 8 |
+
import json
|
| 9 |
+
from dotenv import load_dotenv, find_dotenv
|
| 10 |
+
|
| 11 |
+
ROOT_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
| 12 |
+
if ROOT_DIR not in sys.path:
|
| 13 |
+
sys.path.append(ROOT_DIR)
|
| 14 |
+
|
| 15 |
+
load_dotenv(find_dotenv(), override=True)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class StrategyRequest(BaseModel):
|
| 19 |
+
product_idea: str = Field(..., description="Describe the product idea and target users")
|
| 20 |
+
context: Optional[str] = Field(None, description="Optional business constraints, competitors, goals")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class StrategyAdvice(BaseModel):
|
| 24 |
+
verdict: Literal["feature", "product", "unclear"]
|
| 25 |
+
rationale: List[str]
|
| 26 |
+
differentiation: List[str]
|
| 27 |
+
scope_mvp: List[str]
|
| 28 |
+
risks: List[str]
|
| 29 |
+
next_steps: List[str]
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
from openai import OpenAI
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
SYSTEM = (
|
| 36 |
+
"You are an executive product strategy coach. Given a product idea, advise whether AI should be a FEATURE inside a wider "
|
| 37 |
+
"product or whether AI IS THE PRODUCT. Your advice must be practical, defensible, and immediately useful.\n\n"
|
| 38 |
+
"Decision policy (apply all):\n"
|
| 39 |
+
"- Data advantage: Is there proprietary/high‑quality data and a learning loop?\n"
|
| 40 |
+
"- Workflow dependence: Does the core user outcome hinge on model output vs. orchestration/UI/integrations?\n"
|
| 41 |
+
"- Defensibility: Is there a moat (feedback, evals, infra, distribution) beyond prompt engineering?\n"
|
| 42 |
+
"- ROI and constraints: Clear business lift vs. simpler heuristics; latency/cost fit SLAs; safety/compliance manageable.\n"
|
| 43 |
+
"- Operability: Can the org monitor, evaluate, and retrain reliably?\n\n"
|
| 44 |
+
"Verdict rules:\n"
|
| 45 |
+
"- product: Core value is the model’s decisions/creations and strengthens with data network effects; non‑AI alternatives are weak.\n"
|
| 46 |
+
"- feature: AI augments a workflow; primary value is product/UX/system; heuristics could deliver most value.\n"
|
| 47 |
+
"- unclear: Information is insufficient or highly contingent on GTM/constraints. Prefer 'unclear' over guessing.\n\n"
|
| 48 |
+
"Output requirements (JSON schema enforced):\n"
|
| 49 |
+
"- rationale: 4–7 terse bullets mapping evidence to the verdict (no fluff, no restating the prompt).\n"
|
| 50 |
+
"- differentiation: 3–6 bullets on moats (data, evals, distribution, UX), not vendor names.\n"
|
| 51 |
+
"- scope_mvp: 3–6 concrete MVP items with success metrics (e.g., +X% reply rate, <Y ms latency).\n"
|
| 52 |
+
"- risks: 3–6 specific risks (data gaps, drift, eval difficulty, privacy/regulatory, hallucination impact).\n"
|
| 53 |
+
"- next_steps: 3–6 actions with a 30/60/90‑day shape (data plan, evals, baseline, pilot scope).\n\n"
|
| 54 |
+
"Rules: Be vendor‑agnostic, no hand‑waving, no hallucinated internal policies. If info is missing, set verdict='unclear' and include
|
| 55 |
+
clarifying questions in next_steps."
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class StrategyCoachService:
|
| 60 |
+
def __init__(self) -> None:
|
| 61 |
+
key = (os.getenv("OPENAI_API_KEY") or "").strip().strip('"').strip("'")
|
| 62 |
+
if not key:
|
| 63 |
+
raise RuntimeError("OPENAI_API_KEY is not set")
|
| 64 |
+
base_url = (os.getenv("OPENAI_BASE_URL") or "").strip().strip('"').strip("'") or None
|
| 65 |
+
kwargs = {"api_key": key}
|
| 66 |
+
if base_url:
|
| 67 |
+
kwargs["base_url"] = base_url
|
| 68 |
+
self.client = OpenAI(**kwargs)
|
| 69 |
+
self.model = os.getenv("OPENAI_MODEL", "gpt-4o-mini")
|
| 70 |
+
|
| 71 |
+
def advise(self, req: StrategyRequest) -> StrategyAdvice:
|
| 72 |
+
user = (
|
| 73 |
+
"Product idea:\n" + req.product_idea.strip() + "\n\n" +
|
| 74 |
+
("Context:\n" + req.context.strip() if (req.context and req.context.strip()) else "")
|
| 75 |
+
)
|
| 76 |
+
resp = self.client.chat.completions.create(
|
| 77 |
+
model=self.model,
|
| 78 |
+
messages=[
|
| 79 |
+
{"role": "system", "content": SYSTEM},
|
| 80 |
+
{"role": "user", "content": user},
|
| 81 |
+
],
|
| 82 |
+
temperature=0.1,
|
| 83 |
+
response_format={
|
| 84 |
+
"type": "json_schema",
|
| 85 |
+
"json_schema": {
|
| 86 |
+
"name": "StrategyAdvice",
|
| 87 |
+
"schema": {
|
| 88 |
+
"type": "object",
|
| 89 |
+
"additionalProperties": False,
|
| 90 |
+
"required": ["verdict", "rationale", "differentiation", "scope_mvp", "risks", "next_steps"],
|
| 91 |
+
"properties": {
|
| 92 |
+
"verdict": {"type": "string", "enum": ["feature", "product", "unclear"]},
|
| 93 |
+
"rationale": {"type": "array", "items": {"type": "string"}},
|
| 94 |
+
"differentiation": {"type": "array", "items": {"type": "string"}},
|
| 95 |
+
"scope_mvp": {"type": "array", "items": {"type": "string"}},
|
| 96 |
+
"risks": {"type": "array", "items": {"type": "string"}},
|
| 97 |
+
"next_steps": {"type": "array", "items": {"type": "string"}}
|
| 98 |
+
}
|
| 99 |
+
}
|
| 100 |
+
}
|
| 101 |
+
},
|
| 102 |
+
)
|
| 103 |
+
content = resp.choices[0].message.content
|
| 104 |
+
try:
|
| 105 |
+
data = json.loads(content)
|
| 106 |
+
except Exception:
|
| 107 |
+
start = content.find('{'); end = content.rfind('}')
|
| 108 |
+
if start != -1 and end != -1 and end > start:
|
| 109 |
+
data = json.loads(content[start:end+1])
|
| 110 |
+
else:
|
| 111 |
+
raise RuntimeError("Invalid JSON from model")
|
| 112 |
+
return StrategyAdvice.model_validate(data)
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
app = FastAPI(title="Strategy Coach Bot", version="0.1.0")
|
| 116 |
+
app.add_middleware(
|
| 117 |
+
CORSMiddleware,
|
| 118 |
+
allow_origins=["*"],
|
| 119 |
+
allow_credentials=True,
|
| 120 |
+
allow_methods=["*"],
|
| 121 |
+
allow_headers=["*"],
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
router = APIRouter(prefix="/strategy", tags=["strategy"])
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
@router.get("/health")
|
| 128 |
+
def health() -> dict:
|
| 129 |
+
return {"status":"ok","openai_key": bool(os.getenv("OPENAI_API_KEY")), "model": os.getenv("OPENAI_MODEL","gpt-4o-mini")}
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
@router.post("/advise", response_model=StrategyAdvice)
|
| 133 |
+
def advise(payload: StrategyRequest) -> StrategyAdvice:
|
| 134 |
+
try:
|
| 135 |
+
return StrategyCoachService().advise(payload)
|
| 136 |
+
except Exception as exc:
|
| 137 |
+
raise HTTPException(status_code=502, detail=f"Strategy coach error: {exc}")
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
app.include_router(router)
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
@app.get("/", response_class=FileResponse)
|
| 144 |
+
def index() -> FileResponse:
|
| 145 |
+
base = os.path.dirname(__file__)
|
| 146 |
+
return FileResponse(os.path.join(base, "static", "index.html"))
|
| 147 |
+
|
| 148 |
+
|
AIStrategy/app/static/index.html
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8"/>
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
| 6 |
+
<title>Strategy Coach Bot</title>
|
| 7 |
+
<link rel="stylesheet" href="/static/suite.css"/>
|
| 8 |
+
<style>
|
| 9 |
+
.actions{display:flex;gap:12px;flex-wrap:wrap;margin-top:12px}
|
| 10 |
+
</style>
|
| 11 |
+
</head>
|
| 12 |
+
<body>
|
| 13 |
+
<div id="suite-shared-header"></div>
|
| 14 |
+
<div class="container">
|
| 15 |
+
<h1>Strategy Coach Bot</h1>
|
| 16 |
+
<p class="muted">Input a product idea — get a verdict: AI as a feature vs AI as the product.</p>
|
| 17 |
+
<div class="grid two-col">
|
| 18 |
+
<div class="card">
|
| 19 |
+
<label for="idea">Product idea</label>
|
| 20 |
+
<textarea id="idea" rows="8" placeholder="Describe the user, problem, workflow, and where AI could help"></textarea>
|
| 21 |
+
<label for="ctx">Optional business context</label>
|
| 22 |
+
<textarea id="ctx" rows="6" placeholder="Constraints, competitors, goals"></textarea>
|
| 23 |
+
<div class="actions"><button id="go" class="btn">Advise</button></div>
|
| 24 |
+
</div>
|
| 25 |
+
<div class="card" id="out" style="display:none"></div>
|
| 26 |
+
</div>
|
| 27 |
+
</div>
|
| 28 |
+
<script src="/static/header.js"></script>
|
| 29 |
+
<script>
|
| 30 |
+
const out=document.getElementById('out');
|
| 31 |
+
document.getElementById('go').addEventListener('click', async ()=>{
|
| 32 |
+
const payload={product_idea:(document.getElementById('idea').value||''), context:(document.getElementById('ctx').value||null)};
|
| 33 |
+
out.style.display='block'; out.innerHTML='<div class="muted">(thinking...)</div>';
|
| 34 |
+
try{
|
| 35 |
+
const res=await fetch('/strategy/advise',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
|
| 36 |
+
if(!res.ok) throw new Error(await res.text());
|
| 37 |
+
const d=await res.json();
|
| 38 |
+
const vlabel={feature:'AI should be a feature', product:'AI is the product', unclear:'Unclear'}[d.verdict]||d.verdict;
|
| 39 |
+
const list=(a)=> (a||[]).map(x=>`<li>${(x||'').replace(/</g,'<').replace(/>/g,'>')}</li>`).join('')||'<li class="muted">None</li>';
|
| 40 |
+
out.innerHTML=`
|
| 41 |
+
<h2>${vlabel}</h2>
|
| 42 |
+
<h3>Rationale</h3><ul>${list(d.rationale)}</ul>
|
| 43 |
+
<h3>Differentiation & Moat</h3><ul>${list(d.differentiation)}</ul>
|
| 44 |
+
<h3>MVP Scope</h3><ul>${list(d.scope_mvp)}</ul>
|
| 45 |
+
<h3>Risks</h3><ul>${list(d.risks)}</ul>
|
| 46 |
+
<h3>Next steps</h3><ul>${list(d.next_steps)}</ul>
|
| 47 |
+
`;
|
| 48 |
+
}catch(e){ out.innerHTML=`<div style="color:#b91c1c;">Error: ${String(e)}</div>`; }
|
| 49 |
+
});
|
| 50 |
+
</script>
|
| 51 |
+
</body>
|
| 52 |
+
</html>
|
| 53 |
+
|
| 54 |
+
|