# 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 = """
🌀 AgileCraft v0.1
Master Your Sprint with Precision
Built with ❤️ and ☀️ by Sai Varakala · Techno‑Agilist · Scrum Master @ TCS
""" BRAND_FOOTER_HTML = """
🌐 Tools & Projects
🧞 SprintAnalyzer 🔍 ScrumLens 🧪 Alkembic
📡 Find Me Online
💼 LinkedIn 🐙 GitHub 🤗 HuggingFace ✍️ Medium 📷 Instagram 🐦 X
🤖 Powered by Groq · ChromaDB · Gradio · GitHub Gist · Hugging Face
© 2026 Sai Varakala · MIT License · v0.1
"Crafted for Scrum Masters who build excellence."
""" @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 "
🌳 No team members yet.
" 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 = '
' + " ".join([f'{s}' for s in m.skills[:3]]) + '
' return f"""
{m.emoji}
{m.name}
{m.role}
{f'
{m.team}
' if m.team else ''} {skills_html}
""" rows_html = "" for idx, level in enumerate(levels): cards = "".join([member_card(m) for m in level]) rows_html += f"""
Level {idx+1}
{cards}
""" return f"""
🌳 Team Organization Chart
{len(self.members)} members
{rows_html}
""" 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 "
Type to preview...
" 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'{t}' for t in tags]) sc = "#10b981" if sim < 50 else "#f59e0b" if sim < 80 else "#ef4444" sb = f'📊 {sim}%' dw = f'
⚠️ Similar to: {matched[:120]}...
' if matched and sim >= 80 else "" parts.append(f'
{line}
{tb} {sb}
{dw}
') return f'
{"".join(parts)}
' 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"• {sim}% match: {matched[:100]}...") if warnings: return f"⚠️ {len(warnings)} fact(s) above 80% (max: {max_sim}%)", "
".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"""{title} — AgileCraft

🔨 AgileCraft v0.1

Agile Toolset · By Sai Varakala
📅 {now}📝 {subject or 'Agile Report'}⚡ sprintanalyzer.netlify.app
{html_content}
""" return full_html def sanitize_html(text): if not text: return "" text = re.sub(r"]*>.*?", "", 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"(? 2: entities[n] = r # Pattern 2: "Name as (our/the) Role" for m in re.finditer(r"(? 2: entities[n] = r # Pattern 3: "Name (Role)" for m in re.finditer(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"(? 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'
⚠️ {msg}
' 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'
{c}
' 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'
{c}
' 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'
{c}
' 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'
{c}
' 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"""
{status_color} KB: {stats.get('total_documents',0)} facts | Team: {len(team_manager.members)} members | Model: {CONFIG.GROQ_MODEL}
""") # 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()