Ali2206 commited on
Commit
0d75857
·
1 Parent(s): 037747d

Add remaining agents to Hugging Face Space

Browse files
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,'&lt;').replace(/>/g,'&gt;');
62
+ const src = (c.source||'').replace(/</g,'&lt;').replace(/>/g,'&gt;');
63
+ const snip = (c.snippet||'').replace(/</g,'&lt;').replace(/>/g,'&gt;');
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,'&lt;').replace(/>/g,'&gt;')}</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,'&lt;').replace(/>/g,'&gt;')}</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,'&lt;').replace(/>/g,'&gt;')}</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,'&lt;').replace(/>/g,'&gt;')}</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
+