LeadCraft / app.py
Suryasticsai's picture
Update app.py
75e0201 verified
Raw
History Blame Contribute Delete
51.7 kB
# AgileCraft – Agile Multi Utility Toolkit v0.1
# Built with ❤️ and ☀️ by Sai Varakala · Techno‑Agilist · Scrum Master @ TCS
# © 2026 Sai Varakala · MIT License
import gradio as gr, chromadb, os, requests, json, hashlib, logging, re, difflib, time, io
from chromadb.utils import embedding_functions
from groq import Groq
from typing import List, Optional, Tuple, Dict
from dataclasses import dataclass
from pathlib import Path
from datetime import datetime
# Optional spellchecker
try:
from spellchecker import SpellChecker
spell = SpellChecker()
logging.getLogger('spellchecker').setLevel(logging.ERROR)
except:
spell = None
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
logger = logging.getLogger(__name__)
def auto_correct_text(text: str) -> str:
if not text or not spell: return text
words = re.findall(r'\b[a-zA-Z]{3,}\b', text)
corrections = {}
for w in set(words):
if w[0].isupper(): continue
corrected = spell.correction(w)
if corrected and corrected.lower() != w.lower():
corrections[w] = corrected
for wrong, right in corrections.items():
text = re.sub(r'\b' + re.escape(wrong) + r'\b', right, text)
return text
@dataclass
class Config:
GITHUB_USERNAME: str = "suryasticsai"
GIST_ID: str = "c25ec2898e91a2ce09ab74930741907e"
GIST_FILENAME: str = "agile-knowledge-base.txt"
TEAM_GIST_ID: str = "23a81d7b8bf25a2430a10bebfcbf208b"
TEAM_GIST_FILENAME: str = "agile-knowledge-base.json"
COLLECTION_NAME: str = "agile_knowledge"
CHROMA_PERSIST_DIR: str = "./chroma_db"
MAX_QUERY_LENGTH: int = 1000
MAX_FEED_LENGTH: int = 20000
DEFAULT_N_RESULTS: int = 2
GROQ_MODEL: str = "llama-3.1-8b-instant"
GROQ_TEMPERATURE: float = 0.4
GROQ_MAX_TOKENS: int = 3000
REQUEST_TIMEOUT: int = 15
GROQ_TIMEOUT: float = 90.0
CATEGORIES: Tuple[str,...] = ("ceremony","role","artifact","metric","anti-pattern","gamification","general")
MOODS: Tuple[str,...] = ("Coach Mode",)
FUZZY_DUPE_THRESHOLD: float = 0.80
@property
def gist_url(self):
return f"https://gist.githubusercontent.com/{self.GITHUB_USERNAME}/{self.GIST_ID}/raw/{self.GIST_FILENAME}"
@property
def gist_api_url(self):
return f"https://api.github.com/gists/{self.GIST_ID}"
@property
def team_gist_url(self):
return f"https://gist.githubusercontent.com/{self.GITHUB_USERNAME}/{self.TEAM_GIST_ID}/raw/{self.TEAM_GIST_FILENAME}"
@property
def team_gist_api_url(self):
return f"https://api.github.com/gists/{self.TEAM_GIST_ID}"
CONFIG = Config()
# Branding
BRAND_HEADER_HTML = """<div style="text-align:center;padding:16px;background:linear-gradient(135deg,#1e293b,#0f172a);border:1px solid #334155;border-radius:12px;margin-bottom:16px;">
<div style="font-size:1.2rem;font-weight:700;color:#00d4aa;">🌀 AgileCraft v0.1</div>
<div style="font-size:0.8rem;color:#94a3b8;margin-top:4px;">Master Your Sprint with Precision</div>
<div style="font-size:0.7rem;color:#64748b;margin-top:4px;">Built with ❤️ and ☀️ by Sai Varakala · Techno‑Agilist · Scrum Master @ TCS</div>
</div>"""
BRAND_FOOTER_HTML = """<div style="text-align:center;margin-top:32px;padding:24px;background:linear-gradient(135deg,#1e293b,#0f172a);border:1px solid #334155;border-radius:16px;font-size:0.8rem;color:#e2e8f0;line-height:1.8;">
<div style="font-weight:700;color:#f1f5f9;font-size:0.95rem;margin-bottom:12px;">🌐 Tools & Projects</div>
<div style="display:flex;flex-wrap:wrap;justify-content:center;gap:16px;margin-bottom:20px;">
<a href="https://sprintanalyzer.netlify.app" target="_blank" style="color:#00d4aa;text-decoration:none;font-weight:600;">🧞 SprintAnalyzer</a>
<a href="https://scrumlens.netlify.app" target="_blank" style="color:#00d4aa;text-decoration:none;font-weight:600;">🔍 ScrumLens</a>
<a href="https://alkembic1.netlify.app" target="_blank" style="color:#00d4aa;text-decoration:none;font-weight:600;">🧪 Alkembic</a>
</div>
<div style="font-weight:700;color:#f1f5f9;font-size:0.95rem;margin-bottom:12px;">📡 Find Me Online</div>
<div style="display:flex;flex-wrap:wrap;justify-content:center;gap:14px;margin-bottom:20px;">
<a href="https://linkedin.com/in/suryasticsai" target="_blank" style="color:#cbd5e1;text-decoration:none;">💼 LinkedIn</a>
<a href="https://github.com/suryasticsai" target="_blank" style="color:#cbd5e1;text-decoration:none;">🐙 GitHub</a>
<a href="https://huggingface.co/suryasticsai" target="_blank" style="color:#cbd5e1;text-decoration:none;">🤗 HuggingFace</a>
<a href="https://suryasticsai.medium.com" target="_blank" style="color:#cbd5e1;text-decoration:none;">✍️ Medium</a>
<a href="https://instagram.com/suryasticsai" target="_blank" style="color:#cbd5e1;text-decoration:none;">📷 Instagram</a>
<a href="https://x.com/suryasticsai" target="_blank" style="color:#cbd5e1;text-decoration:none;">🐦 X</a>
</div>
<div style="font-size:0.75rem;padding-top:16px;border-top:1px solid #334155;color:#94a3b8;">
🤖 Powered by Groq · ChromaDB · Gradio · GitHub Gist · Hugging Face<br>
© 2026 Sai Varakala · MIT License · v0.1<br>
<span style="color:#00d4aa;font-style:italic;">"Crafted for Scrum Masters who build excellence."</span>
</div>
</div>"""
@dataclass
class TeamMember:
name: str; role: str; team: str = ""; reports_to: str = ""
notes: List[str] = None; skills: List[str] = None; emoji: str = "👤"
def __post_init__(self):
if self.notes is None: self.notes = []
if self.skills is None: self.skills = []
def to_dict(self):
return {"name":self.name,"role":self.role,"team":self.team,"reports_to":self.reports_to,"notes":self.notes,"skills":self.skills,"emoji":self.emoji}
@classmethod
def from_dict(cls, data):
return cls(name=data.get("name",""),role=data.get("role",""),team=data.get("team",""),reports_to=data.get("reports_to",""),notes=data.get("notes",[]),skills=data.get("skills",[]),emoji=data.get("emoji","👤"))
class TeamProfileManager:
def __init__(self):
self.members: Dict[str, TeamMember] = {}
self._load_from_gist()
def _load_from_gist(self):
try:
r = requests.get(CONFIG.team_gist_url+f"?cb={int(time.time())}", timeout=CONFIG.REQUEST_TIMEOUT); r.raise_for_status()
data = r.json()
for name, md in data.items(): self.members[name] = TeamMember.from_dict(md)
logger.info(f"Loaded {len(self.members)} team members")
except Exception as e: logger.warning(f"Team load failed: {e}"); self._load_defaults()
def save_to_gist(self) -> str:
token = os.environ.get("GITHUB_TOKEN")
if not token: return "❌ GITHUB_TOKEN not found!"
try:
data = {name: m.to_dict() for name, m in self.members.items()}
payload = {"files": {CONFIG.TEAM_GIST_FILENAME: {"content": json.dumps(data, indent=2)}}}
headers = {"Authorization": f"token {token}", "Accept": "application/vnd.github.v3+json"}
r = requests.patch(CONFIG.team_gist_api_url, headers=headers, data=json.dumps(payload), timeout=CONFIG.REQUEST_TIMEOUT)
r.raise_for_status(); return f"✅ Saved {len(self.members)} members"
except Exception as e: return f"❌ Error: {e}"
def _load_defaults(self):
self.members = {
"Simran": TeamMember("Simran","RTE","SilverStars","",["SAFe certified"],["Facilitation","PI Planning"],"🚂"),
"Anjali": TeamMember("Anjali","Product Owner","Team1, Team2","Simran",["Multi‑team PO"],["Prioritization"],"🎯"),
"Sai": TeamMember("Sai","Scrum Master","SilverStars","Simran",["Process guardian"],["Coaching"],"🛡️"),
"Shruthi": TeamMember("Shruthi","Tech Lead","SilverStars","Sai",["Pro in DevOps"],["DevOps","CI/CD"],"💻"),
"Aanand": TeamMember("Aanand","QA Lead","SilverStars","Sai",["Good at art"],["Test automation","BDD"],"🔍"),
}
def add_member(self, name, role, team="", reports_to="", notes="", skills="", emoji="👤"):
name = auto_correct_text(name)
nl = [auto_correct_text(n.strip()) for n in notes.split("\n") if n.strip()] if notes else []
sl = [auto_correct_text(s.strip()) for s in skills.split(",") if s.strip()] if skills else []
self.members[name] = TeamMember(name, role, team, reports_to, nl, sl, emoji)
return self.save_to_gist()
def add_from_entities(self, entities: Dict[str, str]) -> str:
added = []
for name, role in entities.items():
name = auto_correct_text(name)
if name not in self.members:
self.members[name] = TeamMember(name=name, role=role); added.append(name)
if added: return self.save_to_gist()
return "✅ No new members to add."
def remove_member(self, name):
if name in self.members:
del self.members[name]
for m in self.members.values():
if m.reports_to == name: m.reports_to = ""
return self.save_to_gist()
return "❌ Not found"
def get_hierarchy_html(self) -> str:
"""Row‑based org chart: top row = roots, then next rows = children."""
if not self.members:
return "<div style='padding:40px;color:#cbd5e1;text-align:center;'>🌳 No team members yet.</div>"
roots = [m for m in self.members.values() if not m.reports_to or m.reports_to not in self.members]
levels = []
visited = set()
current = roots[:]
while current:
levels.append(current)
visited.update(m.name for m in current)
next_level = []
for m in current:
children = [c for c in self.members.values() if c.reports_to == m.name and c.name not in visited]
next_level.extend(children)
current = next_level
remaining = [m for m in self.members.values() if m.name not in visited]
if remaining:
levels.append(remaining)
colors = {"RTE":"#f472b6","Product Owner":"#a78bfa","Scrum Master":"#34d399","Tech Lead":"#60a5fa","QA Lead":"#fbbf24","Developer":"#94a3b8","QA":"#fb923c"}
def member_card(m):
color = colors.get(m.role, "#94a3b8")
skills_html = ""
if m.skills: skills_html = '<div class="row-skills">' + " ".join([f'<span class="row-skill">{s}</span>' for s in m.skills[:3]]) + '</div>'
return f"""<div class="row-card" style="border-top:3px solid {color};">
<div class="row-emoji">{m.emoji}</div>
<div class="row-name">{m.name}</div>
<div class="row-role" style="color:{color};">{m.role}</div>
{f'<div class="row-team">{m.team}</div>' if m.team else ''}
{skills_html}
</div>"""
rows_html = ""
for idx, level in enumerate(levels):
cards = "".join([member_card(m) for m in level])
rows_html += f"""<div class="org-row">
<div class="row-label">Level {idx+1}</div>
<div class="row-cards">{cards}</div>
</div>"""
return f"""<style>
.org-chart{{font-family:system-ui,sans-serif;padding:20px;background:white;border-radius:16px;overflow-x:auto;border:1px solid #1e293b;}}
.org-title{{text-align:center;font-size:1.3rem;font-weight:700;color:#f1f5f9;margin-bottom:4px;}}
.org-subtitle{{text-align:center;font-size:0.8rem;color:#94a3b8;margin-bottom:20px;}}
.org-row{{display:flex;flex-direction:column;align-items:center;margin-bottom:24px;}}
.row-label{{font-size:0.75rem;color:#64748b;font-weight:600;margin-bottom:8px;text-transform:uppercase;letter-spacing:1px;}}
.row-cards{{display:flex;flex-wrap:wrap;justify-content:center;gap:12px;}}
.row-card{{background:white;border:1px solid #334155;border-radius:12px;padding:14px 16px;text-align:center;min-width:130px;max-width:150px;transition:all 0.3s;}}
.row-card:hover{{transform:translateY(-3px);box-shadow:0 8px 20px rgba(0,0,0,0.5);border-color:#00d4aa;}}
.row-emoji{{font-size:1.6rem;margin-bottom:4px;}}
.row-name{{font-weight:700;color:#f8fafc;font-size:0.85rem;}}
.row-role{{font-size:0.68rem;font-weight:600;margin:2px 0;}}
.row-team{{background:#ffd0d7;color:#e2e8f0;padding:2px 8px;border-radius:12px;font-size:0.6rem;margin-top:4px;display:inline-block;}}
.row-skills{{display:flex;flex-wrap:wrap;justify-content:center;gap:3px;margin-top:5px;}}
.row-skill{{background:rgba(0,212,170,0.15);color:#5eead4;padding:1px 6px;border-radius:8px;font-size:0.55rem;border:1px solid rgba(0,212,170,0.25);}}
</style>
<div class="org-chart">
<div class="org-title">🌳 Team Organization Chart</div>
<div class="org-subtitle">{len(self.members)} members</div>
{rows_html}
</div>"""
def get_entity_context(self) -> str:
if not self.members: return ""
lines = ["YOUR TEAM:"]
for m in self.members.values():
ts = f" [{m.team}]" if m.team else ""
lines.append(f"- {m.name}: {m.role}{ts}")
return "\n".join(lines) + "\n"
def get_dropdown_choices(self) -> List[str]:
return ["(None)"] + list(self.members.keys())
# ------------------------------
# Knowledge Base (same as original)
# ------------------------------
class KnowledgeBase:
def __init__(self):
self.config = CONFIG
self.client = chromadb.PersistentClient(path=self.config.CHROMA_PERSIST_DIR)
self.emb = embedding_functions.DefaultEmbeddingFunction()
self.collection = self._init_collection()
gk = os.environ.get("GROQ_API_KEY")
self.groq_client = Groq(api_key=gk, timeout=self.config.GROQ_TIMEOUT, max_retries=2) if gk else None
if not gk: logger.error("GROQ_API_KEY missing!")
else: logger.info("Groq client initialized")
self._load_data()
def _init_collection(self):
try:
c = self.client.get_or_create_collection(name=self.config.COLLECTION_NAME, embedding_function=self.emb, metadata={"description":"Agile knowledge base","version":"2.1"})
logger.info(f"Collection ready: {c.count()} docs"); return c
except Exception as e: raise RuntimeError(f"ChromaDB init failed: {e}")
def auto_tag(self, text):
tl = text.lower(); tags = set()
km = {"ceremony":["sprint planning","daily standup","sprint review","retrospective","refinement"],"role":["product owner","scrum master","dev team","stakeholder","rte"],"artifact":["product backlog","sprint backlog","increment","definition of done"],"metric":["velocity","burndown","lead time","cycle time","throughput","wip"],"anti-pattern":["anti-pattern","mistake","wrong","fail","bad practice"],"gamification":["game","points","badge","reward","kudos"]}
for t, kws in km.items():
if any(k in tl for k in kws): tags.add(t)
if not tags: tags.add("general")
return sorted(list(tags))
def _parse_line(self, line):
line = line.strip()
if not line: return "", []
m = re.match(r"^\[([a-zA-Z0-9_, -]+)\]\s*(.+)", line)
if m: return m.group(2).strip(), [t.strip().lower() for t in m.group(1).split(",")]
return line, ["general"]
def _format_line(self, content, tags):
p = [t for t in tags if t != "general"]
return f"[{','.join(p)}] {content}" if p else content
def check_duplicate_fuzzy(self, text, threshold=None):
threshold = threshold or self.config.FUZZY_DUPE_THRESHOLD
tc = text.strip().lower()
if not tc: return False, 0.0, None
try:
existing = self.collection.get(include=["documents"])
docs = existing.get("documents",[]) if existing else []
best, best_doc = 0.0, None
for doc in docs:
if not doc: continue
s = difflib.SequenceMatcher(None, tc, doc.strip().lower()).ratio()
if s > best: best, best_doc = s, doc
return best >= threshold, round(best*100,1), best_doc
except: return False, 0.0, None
def preview_feed(self, new_text):
if not new_text.strip(): return "<div style='color:#94a3b8;padding:12px;'>Type to preview...</div>"
new_text = auto_correct_text(new_text)
lines = [l.strip() for l in new_text.split("\n") if l.strip()]
parts = []
for line in lines:
tags = self.auto_tag(line); is_dup, sim, matched = self.check_duplicate_fuzzy(line)
tb = "".join([f'<span style="background:#1e293b;color:#00d4aa;padding:2px 8px;border-radius:4px;font-size:0.7rem;margin-right:4px;">{t}</span>' for t in tags])
sc = "#10b981" if sim < 50 else "#f59e0b" if sim < 80 else "#ef4444"
sb = f'<span style="background:{sc}20;color:{sc};padding:2px 8px;border-radius:4px;font-size:0.7rem;">📊 {sim}%</span>'
dw = f'<div style="color:#ef4444;font-size:0.78rem;margin-top:4px;">⚠️ Similar to: {matched[:120]}...</div>' if matched and sim >= 80 else ""
parts.append(f'<div style="border-bottom:1px solid #1e293b;padding:8px 0;"><div style="color:#cbd5e1;font-size:0.85rem;">{line}</div><div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:4px;">{tb} {sb}</div>{dw}</div>')
return f'<div style="background:#0f172a;border:1px solid #1e293b;border-radius:8px;padding:12px;">{"".join(parts)}</div>'
def check_feed_duplicates(self, new_text):
if not new_text.strip(): return "Enter text first", "", False
new_text = auto_correct_text(new_text)
lines = [l.strip() for l in new_text.split("\n") if l.strip()]
warnings, max_sim, has_high = [], 0.0, False
for line in lines:
is_dup, sim, matched = self.check_duplicate_fuzzy(line)
if sim > max_sim: max_sim = sim
if sim >= 80: has_high = True; warnings.append(f"• <b>{sim}%</b> match: <i>{matched[:100]}...</i>")
if warnings: return f"⚠️ {len(warnings)} fact(s) above 80% (max: {max_sim}%)", "<br>".join(warnings), True
return f"✅ No duplicates. Highest: {max_sim}%", "", False
def _load_data(self):
try:
r = requests.get(self.config.gist_url+f"?cb={int(time.time())}", timeout=self.config.REQUEST_TIMEOUT); r.raise_for_status()
raw = r.text.split("\n"); self._raw_gist_line_count = len([l for l in raw if l.strip()])
lines = [l.strip() for l in raw if l.strip()]
docs, tags = [], []
for line in lines:
c, t = self._parse_line(line)
if c: docs.append(c); tags.append(t)
if docs: self._update_collection(docs, tags, replace=True); logger.info(f"Loaded {len(docs)} facts")
else: self._load_defaults()
except Exception as e: logger.warning(f"Gist load failed: {e}"); self._load_defaults()
def _load_defaults(self):
defaults = [
("Sprint Planning is where the Scrum Team selects work from the Product Backlog",["ceremony"]),
("Daily Standup is a 15‑minute time‑boxed event for the Development Team",["ceremony"]),
("Sprint Review is held at the end of the Sprint to inspect the Increment",["ceremony"]),
("Product Owner is responsible for maximizing the value of the product",["role"]),
("Scrum Master is responsible for promoting and supporting Scrum",["role"]),
("Definition of Done is a shared understanding of what it means for work to be complete",["artifact"]),
("Velocity is a measure of the amount of work a Team can tackle during a single Sprint",["metric"]),
]
self._update_collection([d[0] for d in defaults], [d[1] for d in defaults], replace=True)
def _update_collection(self, documents, tags_list=None, replace=False):
try:
if replace:
try:
existing = self.collection.get()
if existing and existing.get("ids"): self.collection.delete(ids=existing["ids"])
except: pass
if documents:
seen, dd, dt = {}, [], []
for i, doc in enumerate(documents):
if not doc or not doc.strip(): continue
dh = hashlib.md5(doc.encode()).hexdigest()
tags = tags_list[i] if tags_list and i < len(tags_list) else ["general"]
if dh not in seen: seen[dh] = len(dd); dd.append(doc.strip()); dt.append(tags)
else: ei = seen[dh]; dt[ei] = list(set(dt[ei] + tags))
if not dd: return
metadatas = [{"source":"gist","index":i,"tags":",".join(dt[i]),"hash":hashlib.md5(d.encode()).hexdigest()} for i,d in enumerate(dd)]
self.collection.add(documents=dd, ids=[f"doc_{hashlib.md5(d.encode()).hexdigest()}" for d in dd], metadatas=metadatas)
except Exception as e: logger.error(f"Update failed: {e}"); raise
def query(self, question, n_results=None):
if not question or not question.strip(): return [], "No question.", 0.0
question = auto_correct_text(question)
n_results = n_results or self.config.DEFAULT_N_RESULTS
try:
available = self.collection.count(); n_results = min(n_results, available or 1)
results = self.collection.query(query_texts=[question.strip()], n_results=n_results, include=["documents","distances","metadatas"])
documents = results.get("documents")
if not documents or not isinstance(documents, list) or len(documents) == 0: return [], "No matching knowledge found.", 0.0
docs = documents[0]
if not docs: return [], "No relevant docs.", 0.0
parts, distances = [], results.get("distances",[[]])[0] if results.get("distances") else [0.5]*len(docs)
metadatas = results.get("metadatas",[[]])[0] if results.get("metadatas") else [{}]*len(docs)
best_dist = distances[0] if distances else 1.0
confidence = max(0.0, min(100.0, round((1.0 - best_dist)*100)))
for i, (doc, dist, meta) in enumerate(zip(docs, distances, metadatas), 1):
rel = "High" if dist < 0.3 else "Medium" if dist < 0.6 else "Low"
tags = meta.get("tags","general"); parts.append(f"[Match {i}] (Relevance: {rel} | Tags: {tags})\n{doc}")
return docs, "\n\n".join(parts), confidence
except Exception as e: logger.error(f"Query failed: {e}"); return [], f"Query error: {e}", 0.0
def _append_lines_to_gist(self, lines):
if not lines: return "Nothing to append."
token = os.environ.get("GITHUB_TOKEN")
if not token: return "GITHUB_TOKEN not found!"
try:
headers = {"Authorization": f"token {token}", "Accept": "application/vnd.github.v3+json"}
r = requests.get(self.config.gist_api_url, headers=headers, timeout=self.config.REQUEST_TIMEOUT); r.raise_for_status()
current = r.json()["files"][self.config.GIST_FILENAME]["content"]
existing = set(l.strip() for l in current.split("\n") if l.strip())
to_add = [l for l in lines if l.strip() not in existing]
if not to_add: return "✅ All facts already exist."
updated = current.rstrip() + "\n" + "\n".join(to_add)
payload = {"files": {self.config.GIST_FILENAME: {"content": updated}}}
ur = requests.patch(self.config.gist_api_url, headers=headers, data=json.dumps(payload), timeout=self.config.REQUEST_TIMEOUT)
ur.raise_for_status()
all_lines = [l.strip() for l in updated.split("\n") if l.strip()]
self._raw_gist_line_count = len(all_lines)
docs, tags = [], []
for line in all_lines:
c, t = self._parse_line(line)
if c: docs.append(c); tags.append(t)
self._update_collection(docs, tags, replace=True)
return f"🚀 Added {len(to_add)} new fact(s)."
except Exception as e: logger.error(f"Gist append failed: {e}"); return f"Error: {e}"
def update_gist(self, new_text):
if not new_text or not new_text.strip(): return "Empty input!"
new_text = auto_correct_text(new_text)
raw = [l.strip() for l in new_text.split("\n") if l.strip()]
formatted = [self._format_line(line, self.auto_tag(line)) for line in raw]
return self._append_lines_to_gist(formatted)
def feed_single_fact(self, text, tags_str):
if not text or not text.strip(): return "No fact to feed."
text = auto_correct_text(text)
tags = [t.strip() for t in tags_str.split(",") if t.strip()]
if not tags: tags = self.auto_tag(text)
return self._append_lines_to_gist([self._format_line(text.strip(), tags)])
def get_stats(self):
try:
return {"total_documents":self.collection.count(),"raw_gist_lines":getattr(self,"_raw_gist_line_count",self.collection.count()),"groq_ready":self.groq_client is not None}
except: return {"total_documents":0,"raw_gist_lines":0,"groq_ready":False}
# Document Processor for file upload
class DocumentProcessor:
@staticmethod
def extract_text(file_bytes, filename):
ext = Path(filename).suffix.lower()
try:
if ext == '.pdf': return DocumentProcessor._extract_pdf(file_bytes)
elif ext in ('.docx','.doc'): return DocumentProcessor._extract_docx(file_bytes)
elif ext in ('.txt','.md'): return file_bytes.decode('utf-8', errors='ignore')
else: return f"❌ Unsupported: {ext}"
except Exception as e: return f"❌ Error: {e}"
@staticmethod
def _extract_pdf(file_bytes):
try:
import PyPDF2; reader = PyPDF2.PdfReader(io.BytesIO(file_bytes))
return "\n".join([p.extract_text() or "" for p in reader.pages])
except:
try:
import pdfplumber
with pdfplumber.open(io.BytesIO(file_bytes)) as pdf: return "\n".join([p.extract_text() or "" for p in pdf.pages])
except: return "❌ Install PyPDF2 or pdfplumber"
@staticmethod
def _extract_docx(file_bytes):
try:
import docx; doc = docx.Document(io.BytesIO(file_bytes))
return "\n".join([p.text for p in doc.paragraphs if p.text.strip()])
except: return "❌ Install python-docx"
@staticmethod
def fetch_from_url(url):
try:
r = requests.get(url, timeout=15, headers={"User-Agent":"AgileCraft/0.1"}); r.raise_for_status()
if url.lower().endswith('.pdf'): return DocumentProcessor._extract_pdf(r.content)
return r.text
except Exception as e: return f"❌ URL fetch failed: {e}"
# Export Engine (letterhead)
class ExportEngine:
@staticmethod
def to_html_card(html_content, title="AgileCraft Report", subject=""):
now = datetime.now().strftime("%d-%m-%Y %H:%M")
date_str = datetime.now().strftime("%d%m%Y")
full_html = f"""<!DOCTYPE html><html><head><meta charset="UTF-8"><title>{title} — AgileCraft</title>
<style>body{{margin:0;padding:0;font-family:system-ui,sans-serif;background:#f8fafc;}}.letterhead{{max-width:800px;margin:40px auto;background:#fff;box-shadow:0 4px 20px rgba(0,0,0,0.08);}}.header{{background:#0f172a;color:#fff;padding:24px 30px;text-align:center;}}.header h1{{margin:0;font-size:1.4rem;color:#00d4aa;}}.header .subtitle{{color:#94a3b8;font-size:0.85rem;}}.header .meta{{display:flex;justify-content:center;gap:16px;margin-top:8px;font-size:0.75rem;color:#64748b;}}.body{{padding:30px;background:#fff;color:#1e293b;line-height:1.6;}}.footer{{background:#0f172a;color:#94a3b8;padding:16px 30px;text-align:center;font-size:0.75rem;}}.footer a{{color:#00d4aa;text-decoration:none;}}.footer .social{{display:flex;justify-content:center;gap:12px;margin-top:8px;}}.footer .social a{{color:#94a3b8;}}</style></head><body>
<div class="letterhead">
<div class="header">
<h1>🔨 AgileCraft v0.1</h1>
<div class="subtitle">Agile Toolset · By Sai Varakala</div>
<div class="meta"><span>📅 {now}</span><span>📝 {subject or 'Agile Report'}</span><span>⚡ sprintanalyzer.netlify.app</span></div>
</div>
<div class="body">{html_content}</div>
<div class="footer">
<div style="font-weight:600;margin-bottom:6px;">🌐 Tools & Projects</div>
<div style="margin-bottom:8px;">
<a href="https://sprintanalyzer.netlify.app">🧞 SprintAnalyzer</a> ·
<a href="https://scrumlens.netlify.app">🔍 ScrumLens</a> ·
<a href="https://alkembic1.netlify.app">🧪 Alkembic</a>
</div>
<div class="social">
<a href="https://linkedin.com/in/suryasticsai">💼 LinkedIn</a>
<a href="https://github.com/suryasticsai">🐙 GitHub</a>
<a href="https://huggingface.co/suryasticsai">🤗 HuggingFace</a>
<a href="https://suryasticsai.medium.com">✍️ Medium</a>
<a href="https://instagram.com/suryasticsai">📷 Instagram</a>
<a href="https://x.com/suryasticsai">🐦 X</a>
</div>
<div style="margin-top:8px;">🤖 Powered by Groq · ChromaDB · Gradio · GitHub Gist · Hugging Face<br>© 2026 Sai Varakala · MIT License · v0.1</div>
</div>
</div></body></html>"""
return full_html
def sanitize_html(text):
if not text: return ""
text = re.sub(r"<script[^>]*>.*?</script>", "", text, flags=re.DOTALL|re.IGNORECASE)
text = re.sub(r"\s*on\w+\s*=\s*[^\"']*[\"']", "", text, flags=re.IGNORECASE)
text = re.sub(r"javascript:", "", text, flags=re.IGNORECASE)
return text
# Helper: extract entities ONLY from current query (no fallback to known names)
def extract_entities_from_question(question: str) -> Dict[str, str]:
entities: Dict[str, str] = {}
if not question or not question.strip():
return entities
NAME_BLACKLIST = {
"my name", "his name", "her name", "your name", "our name", "their name",
"the name", "a name", "name is", "i am a", "i am an", "he is a", "she is a",
"they are", "this is", "that is", "it is", "who is", "what is", "where is",
"how is", "which is", "there is", "here is",
}
def _is_valid_name(n: str) -> bool:
n_lower = n.lower().strip()
if len(n) < 2 or len(n) > 35: return False
if not any(c.isupper() for c in n): return False
for bad in NAME_BLACKLIST:
if bad in n_lower: return False
return True
def _clean_role(r: str) -> str:
return re.sub(r"\s+(and|who|that|with|for|also|but|or)$", "", r, flags=re.IGNORECASE).strip()
# Pattern 1: "Name is (a/an/the/our) Role"
for m in re.finditer(r"(?<![a-zA-Z])([A-Z][a-zA-Z]{1,15}(?:\s+[A-Z][a-zA-Z]{1,15})?)\s+is\s+(?:our\s+|the\s+|a\s+|an\s+)?([A-Z][a-zA-Z\s]{2,30})", question, re.IGNORECASE):
n, r = m.group(1).strip(), _clean_role(m.group(2).strip())
if _is_valid_name(n) and len(r) > 2: entities[n] = r
# Pattern 2: "Name as (our/the) Role"
for m in re.finditer(r"(?<![a-zA-Z])([A-Z][a-zA-Z]{1,15}(?:\s+[A-Z][a-zA-Z]{1,15})?)\s+as\s+(?:our\s+|the\s+)?([A-Z][a-zA-Z\s]{2,30})", question, re.IGNORECASE):
n, r = m.group(1).strip(), _clean_role(m.group(2).strip())
if _is_valid_name(n) and len(r) > 2: entities[n] = r
# Pattern 3: "Name (Role)"
for m in re.finditer(r"(?<![a-zA-Z])([A-Z][a-zA-Z]{1,15}(?:\s+[A-Z][a-zA-Z]{1,15})?)\s*\(([^)]+?)\)", question, re.IGNORECASE):
n, r = m.group(1).strip(), _clean_role(m.group(2).strip())
if _is_valid_name(n) and len(r) > 2: entities[n] = r
# Pattern 4: "I am Name, (a/an) Role"
for m in re.finditer(r"\bi am\s+([A-Z][a-zA-Z]{1,15}(?:\s+[A-Z][a-zA-Z]{1,15})?),?\s*(?:and\s+i am\s+)?(?:a\s+|an\s+)?([A-Z][a-zA-Z\s]{2,30})", question, re.IGNORECASE):
n, r = m.group(1).strip(), _clean_role(m.group(2).strip())
if _is_valid_name(n) and len(r) > 2: entities[n] = r
# Pattern 5: "Name, who is (our/the/a) Role"
for m in re.finditer(r"(?<![a-zA-Z])([A-Z][a-zA-Z]{1,15}(?:\s+[A-Z][a-zA-Z]{1,15})?),?\s+who\s+is\s+(?:our\s+|the\s+|a\s+)?([A-Z][a-zA-Z\s]{2,30})", question, re.IGNORECASE):
n, r = m.group(1).strip(), _clean_role(m.group(2).strip())
if _is_valid_name(n) and len(r) > 2: entities[n] = r
return entities
# -------------------------------------------------------------------
# AgileCraft Tools (no follow-up questions, one-click export)
# -------------------------------------------------------------------
class agileCraftTools:
def __init__(self, kb, team_manager):
self.kb = kb
self.team_manager = team_manager
self.config = CONFIG
def _error_card(self, msg: str) -> str:
return f'<div style="max-width:800px;margin:0 auto;padding:20px;background:#fff;border-radius:12px;color:#991b1b;">⚠️ {msg}</div>'
def analyze_transcript(self, transcript: str) -> str:
if not transcript or not transcript.strip():
return self._error_card("Paste a transcript first!")
if not self.kb.groq_client:
return self._error_card("Groq not configured!")
try:
transcript = auto_correct_text(transcript)
transcript = re.sub(r'[^\x20-\x7E\n\t]', '', transcript)
prompt = f"""TranscriptAnalyzer AI. Output pure HTML with Subject, People, Summary, Action Items, Risks, Health Score.
TRANSCRIPT:\n{transcript[:8000]}"""
r = self.kb.groq_client.chat.completions.create(model=self.config.GROQ_MODEL,
messages=[{"role":"system","content":"TranscriptAnalyzer AI. Output pure HTML."},{"role":"user","content":prompt}],
temperature=0.3, max_tokens=2500)
c = sanitize_html(r.choices[0].message.content)
if not c.strip().startswith("<"): c = f'<div style="max-width:800px;margin:0 auto;">{c}</div>'
return c
except Exception as e:
return self._error_card(f"Transcript analysis failed: {e}")
def estimate_story_points(self, story: str) -> str:
if not story or not story.strip():
return self._error_card("Paste a user story!")
if not self.kb.groq_client:
return self._error_card("Groq not configured!")
try:
story = auto_correct_text(story)
docs, context, _ = self.kb.query(story + " story points", n_results=2)
context = context[:1200]
prompt = f"""StoryPointEstimator. Output pure HTML with Fibonacci estimate, justification, risks, confidence %.
CONTEXT:\n{context}\nSTORY:\n{story}"""
r = self.kb.groq_client.chat.completions.create(model=self.config.GROQ_MODEL,
messages=[{"role":"system","content":"StoryPointEstimator. Output pure HTML."},{"role":"user","content":prompt}],
temperature=0.3, max_tokens=1000)
c = sanitize_html(r.choices[0].message.content)
if not c.strip().startswith("<"): c = f'<div style="max-width:800px;margin:0 auto;">{c}</div>'
return c
except Exception as e:
return self._error_card(f"Estimation failed: {e}")
def analyze_health(self, team_name, stories_completed, story_point_hours, sprint_days, team_size, defects_fixed, features_completed, additional_notes) -> str:
if not self.kb.groq_client:
return self._error_card("Groq not configured!")
try:
total_sp = stories_completed * story_point_hours
velocity = total_sp / max(sprint_days, 1)
prompt = f"""SprintHealth AI. Output pure HTML report with Overview, Metrics, Risks, Recommendations, Forecast.
Team: {team_name} | Stories: {stories_completed} ({total_sp} SP) | Sprint: {sprint_days}d | Team: {team_size} | Defects: {defects_fixed} | Features: {features_completed} | Velocity: {velocity:.1f} SP/day | Notes: {additional_notes}"""
r = self.kb.groq_client.chat.completions.create(model=self.config.GROQ_MODEL,
messages=[{"role":"system","content":"SprintHealth AI. Output pure HTML."},{"role":"user","content":prompt}],
temperature=0.3, max_tokens=2000)
c = sanitize_html(r.choices[0].message.content)
if not c.strip().startswith("<"): c = f'<div style="max-width:800px;margin:0 auto;">{c}</div>'
return c
except Exception as e:
return self._error_card(f"Health analysis failed: {e}")
def generate_sprint_goal(self, user_stories: str, team_context: str = "") -> str:
if not user_stories or not user_stories.strip():
return self._error_card("Enter at least one user story!")
if not self.kb.groq_client:
return self._error_card("Groq not configured!")
try:
prompt = f"""SprintGoal AI. Craft SMART sprint goal, 3 success criteria, and a slogan. Output pure HTML card.
Team Context: {team_context if team_context else 'Agile team'}
Stories:\n{user_stories[:2000]}"""
r = self.kb.groq_client.chat.completions.create(model=self.config.GROQ_MODEL,
messages=[{"role":"system","content":"SprintGoal AI. Output pure HTML."},{"role":"user","content":prompt}],
temperature=0.5, max_tokens=800)
c = sanitize_html(r.choices[0].message.content)
if not c.strip().startswith("<"): c = f'<div style="max-width:800px;margin:0 auto;">{c}</div>'
return c
except Exception as e:
return self._error_card(f"Sprint goal failed: {e}")
# -------------------------------------------------------------------
# Gradio App
# -------------------------------------------------------------------
custom_css = """
.stats-bar{background:linear-gradient(135deg,#1e293b,#0f172a);border-radius:12px;padding:12px 16px;margin-bottom:16px;border:1px solid #334155;color:#94a3b8;font-size:0.85rem;}
.stats-bar strong{color:#00d4aa;}
"""
def build_app(kb, team_manager, tools):
with gr.Blocks(title="AgileCraft v0.1", css=custom_css) as app:
gr.HTML(BRAND_HEADER_HTML)
stats = kb.get_stats()
status_color = "🟢" if stats.get("groq_ready") else "🔴"
gr.Markdown(f"""<div class="stats-bar">{status_color} <strong>KB:</strong> {stats.get('total_documents',0)} facts | <strong>Team:</strong> {len(team_manager.members)} members | <strong>Model:</strong> {CONFIG.GROQ_MODEL}</div>""")
# Helper for one-click export
def create_download_file(html_content, prefix="AgileCraft"):
if not html_content or len(html_content.strip()) < 10:
return None
date_str = datetime.now().strftime("%d%m%Y")
os.makedirs("/tmp/agilecraft_exports", exist_ok=True)
file_path = f"/tmp/agilecraft_exports/{prefix}_{date_str}.html"
with open(file_path, 'w', encoding='utf-8') as f:
f.write(ExportEngine.to_html_card(html_content, title=f"{prefix} Report"))
return file_path
# ------------------- Tab 1: Role Hierarchy -------------------
with gr.Tab("🌳 Role Hierarchy"):
hierarchy_display = gr.HTML(value=team_manager.get_hierarchy_html())
with gr.Row():
with gr.Column(scale=1):
member_name = gr.Textbox(label="Name")
member_role = gr.Dropdown(label="Role", choices=["RTE","Product Owner","Scrum Master","Tech Lead","QA Lead","Developer","QA","Stakeholder","Other"])
member_team = gr.Textbox(label="Team(s)")
member_reports_to = gr.Dropdown(label="Reports To", choices=team_manager.get_dropdown_choices())
with gr.Column(scale=1):
member_notes = gr.Textbox(label="Notes", lines=3)
member_skills = gr.Textbox(label="Skills (comma separated)")
member_emoji = gr.Dropdown(label="Emoji", choices=["👤","🚂","🎯","🛡️","💻","🔍","⭐","🎨","🏆","🔥","💡","🚀"], value="👤")
with gr.Row():
add_member_btn = gr.Button("➕ Add/Update", variant="primary")
remove_member_btn = gr.Button("❌ Remove", variant="stop")
refresh_hierarchy_btn = gr.Button("🔄 Refresh", variant="secondary")
team_status = gr.Textbox(label="Status", interactive=False)
def on_add_member(name, role, team, reports_to, notes, skills, emoji):
if not name or not role: return "❌ Required!", team_manager.get_hierarchy_html(), gr.update(choices=team_manager.get_dropdown_choices())
rto = "" if reports_to == "(None)" else reports_to
result = team_manager.add_member(name, role, team, rto, notes, skills, emoji)
return result, team_manager.get_hierarchy_html(), gr.update(choices=team_manager.get_dropdown_choices())
def on_remove_member(name):
if not name: return "❌ Enter name", team_manager.get_hierarchy_html(), gr.update(choices=team_manager.get_dropdown_choices())
result = team_manager.remove_member(name)
return result, team_manager.get_hierarchy_html(), gr.update(choices=team_manager.get_dropdown_choices())
add_member_btn.click(fn=on_add_member, inputs=[member_name, member_role, member_team, member_reports_to, member_notes, member_skills, member_emoji], outputs=[team_status, hierarchy_display, member_reports_to])
remove_member_btn.click(fn=on_remove_member, inputs=[member_name], outputs=[team_status, hierarchy_display, member_reports_to])
refresh_hierarchy_btn.click(fn=lambda: ("Refreshed", team_manager.get_hierarchy_html(), gr.update(choices=team_manager.get_dropdown_choices())), inputs=None, outputs=[team_status, hierarchy_display, member_reports_to])
# ------------------- Tab 2: Transcript Analyzer -------------------
with gr.Tab("📋 Transcript Analyzer"):
transcript_input = gr.Textbox(placeholder="Paste full meeting transcript here...", label="Transcript", lines=20, max_lines=60)
transcript_output = gr.HTML(label="Analysis Report")
with gr.Row():
analyze_btn = gr.Button("🔍 Analyze", variant="primary")
clear_transcript_btn = gr.Button("🗑️ Clear", variant="stop")
export_transcript_btn = gr.Button("📥 Export as HTML", size="sm")
transcript_file = gr.File(visible=False)
analyze_btn.click(fn=tools.analyze_transcript, inputs=transcript_input, outputs=transcript_output)
clear_transcript_btn.click(fn=lambda: ("", ""), inputs=None, outputs=[transcript_input, transcript_output])
export_transcript_btn.click(fn=lambda html: create_download_file(html, "TranscriptAnalysis") if html and len(html.strip())>10 else None, inputs=transcript_output, outputs=transcript_file)
# ------------------- Tab 3: Story Pointer (Story Points) -------------------
with gr.Tab("🎲 Story Pointer"):
story_input = gr.Textbox(placeholder="As a customer, I want to...", label="User Story", lines=8)
story_output = gr.HTML(label="Estimation")
with gr.Row():
estimate_btn = gr.Button("🎯 Estimate", variant="primary")
clear_story_btn = gr.Button("🗑️ Clear", variant="stop")
export_story_btn = gr.Button("📥 Export as HTML")
story_file = gr.File(visible=False)
estimate_btn.click(fn=tools.estimate_story_points, inputs=story_input, outputs=story_output)
clear_story_btn.click(fn=lambda: ("", ""), inputs=None, outputs=[story_input, story_output])
export_story_btn.click(fn=lambda html: create_download_file(html, "StoryPointEstimate") if html and len(html.strip())>10 else None, inputs=story_output, outputs=story_file)
# ------------------- Tab 4: Sprint Health -------------------
with gr.Tab("🩺 Sprint Health"):
with gr.Row():
with gr.Column():
health_team_name = gr.Textbox(label="Team Name", value="SilverStars")
health_stories = gr.Number(label="Stories Completed", value=8, precision=0)
health_sp_hours = gr.Number(label="Hours per Story Point", value=8, precision=0)
health_sprint_days = gr.Number(label="Sprint Duration (Days)", value=10, precision=0)
with gr.Column():
health_team_size = gr.Number(label="Team Size", value=5, precision=0)
health_defects = gr.Number(label="Defects Fixed", value=3, precision=0)
health_features = gr.Number(label="Features Completed", value=2, precision=0)
health_notes = gr.Textbox(label="Additional Notes", lines=3)
health_output = gr.HTML(label="Health Report")
with gr.Row():
health_btn = gr.Button("📊 Generate Report", variant="primary")
clear_health_btn = gr.Button("🗑️ Clear", variant="stop")
export_health_btn = gr.Button("📥 Export as HTML")
health_file = gr.File(visible=False)
health_btn.click(fn=tools.analyze_health, inputs=[health_team_name, health_stories, health_sp_hours, health_sprint_days, health_team_size, health_defects, health_features, health_notes], outputs=health_output)
clear_health_btn.click(fn=lambda: (health_output, health_team_name, health_stories, health_sp_hours, health_sprint_days, health_team_size, health_defects, health_features, health_notes), outputs=[health_output, health_team_name, health_stories, health_sp_hours, health_sprint_days, health_team_size, health_defects, health_features, health_notes])
export_health_btn.click(fn=lambda html: create_download_file(html, "SprintHealth") if html and len(html.strip())>10 else None, inputs=health_output, outputs=health_file)
# ------------------- Tab 5: Sprint Goals -------------------
with gr.Tab("🎯 Sprint Goals"):
goal_stories = gr.Textbox(placeholder="Paste user stories (one per line)...", label="User Stories", lines=6)
goal_context = gr.Textbox(placeholder="Optional: team context...", label="Team Context")
goal_output = gr.HTML(label="Sprint Goal")
with gr.Row():
goal_btn = gr.Button("✨ Generate Sprint Goal", variant="primary")
clear_goal_btn = gr.Button("🗑️ Clear", variant="stop")
export_goal_btn = gr.Button("📥 Export as HTML")
goal_file = gr.File(visible=False)
goal_btn.click(fn=tools.generate_sprint_goal, inputs=[goal_stories, goal_context], outputs=goal_output)
clear_goal_btn.click(fn=lambda: ("", "", ""), inputs=None, outputs=[goal_stories, goal_context, goal_output])
export_goal_btn.click(fn=lambda html: create_download_file(html, "SprintGoal") if html and len(html.strip())>10 else None, inputs=goal_output, outputs=goal_file)
# ------------------- Tab 6: Feed Dataset via File Upload -------------------
with gr.Tab("📦 Feed Dataset (File Upload)"):
gr.Markdown("## Upload PDF / DOCX / TXT or Import URL to feed into knowledge base")
doc_upload = gr.File(label="Upload Document", file_types=[".pdf",".docx",".txt",".md"])
url_input = gr.Textbox(label="Or Paste URL")
doc_preview = gr.Textbox(label="Extracted & Auto‑labeled Text", lines=20, max_lines=50)
with gr.Row():
doc_extract_btn = gr.Button("🔍 Extract from URL", variant="secondary")
doc_feed_btn = gr.Button("🚀 Feed to KB", variant="primary")
doc_status = gr.Textbox(label="Status", interactive=False)
def extract_doc(file):
if file is None: return "❌ No file uploaded"
if isinstance(file, str):
filepath = file; filename = os.path.basename(filepath)
with open(filepath, 'rb') as f: file_content = f.read()
elif isinstance(file, dict):
filepath = file.get('path',''); filename = file.get('name','unknown')
if not filepath: return "❌ Invalid"
with open(filepath, 'rb') as f: file_content = f.read()
else: return "❌ Unsupported format"
text = DocumentProcessor.extract_text(file_content, filename)
text = auto_correct_text(text)
lines = text.split("\n")
labeled = []
for line in lines[:50]:
if line.strip():
tags = kb.auto_tag(line)
labeled.append(f"[{','.join(tags)}] {line}")
else: labeled.append(line)
return "\n".join(labeled)
def fetch_url_text(url):
if not url or not url.strip(): return "❌ Enter URL"
text = DocumentProcessor.fetch_from_url(url)
return auto_correct_text(text)
doc_upload.change(fn=extract_doc, inputs=doc_upload, outputs=doc_preview)
doc_extract_btn.click(fn=fetch_url_text, inputs=url_input, outputs=doc_preview)
doc_feed_btn.click(fn=lambda t: kb.update_gist(t) if t and not t.startswith("❌") else "❌ Nothing to feed", inputs=doc_preview, outputs=doc_status)
# ------------------- Tab 7: About -------------------
with gr.Tab("ℹ️ About"):
gr.Markdown("""
## 🌀 AgileCraft v0.1
**Your complete Scrum Master toolkit** by Sai Varakala
### Tools included
- **Role Hierarchy** – Manage team org chart
- **Transcript Analyzer** – Extract insights from meeting notes
- **Story Pointer** – AI‑powered story point estimation
- **Sprint Health** – Metrics-driven sprint report
- **Sprint Goals** – SMART goal generation
- **Feed Dataset** – Upload documents to grow the knowledge base
### Creator
**Sai Surya Varakala** – Techno‑Agilist, Scrum Master @ TCS
[LinkedIn](https://linkedin.com/in/suryasticsai) · [GitHub](https://github.com/suryasticsai) · [HuggingFace](https://huggingface.co/suryasticsai)
### Powered by
Groq (Llama 3.1), ChromaDB, Gradio, GitHub Gist
""")
gr.HTML(BRAND_FOOTER_HTML)
gr.HTML(BRAND_FOOTER_HTML)
return app
def main():
has_groq = bool(os.environ.get("GROQ_API_KEY"))
has_github = bool(os.environ.get("GITHUB_TOKEN"))
logger.info(f"Secrets — GROQ: {'OK' if has_groq else 'MISSING'}, GITHUB: {'OK' if has_github else 'MISSING'}")
try:
kb = KnowledgeBase()
team_manager = TeamProfileManager()
tools = agileCraftTools(kb, team_manager)
app = build_app(kb, team_manager, tools)
app.launch(server_name="0.0.0.0", server_port=7860, share=False, ssr_mode=False, css=custom_css)
except Exception as e:
logger.critical(f"Fatal error: {e}")
raise
if __name__ == "__main__":
main()