Spaces:
Sleeping
Sleeping
| # 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 | |
| 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 | |
| def gist_url(self): | |
| return f"https://gist.githubusercontent.com/{self.GITHUB_USERNAME}/{self.GIST_ID}/raw/{self.GIST_FILENAME}" | |
| def gist_api_url(self): | |
| return f"https://api.github.com/gists/{self.GIST_ID}" | |
| def team_gist_url(self): | |
| return f"https://gist.githubusercontent.com/{self.GITHUB_USERNAME}/{self.TEAM_GIST_ID}/raw/{self.TEAM_GIST_FILENAME}" | |
| 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>""" | |
| 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} | |
| 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: | |
| 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}" | |
| 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" | |
| 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" | |
| 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: | |
| 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() |