mirror of
https://github.com/arkorty/B.Tech-Project-III.git
synced 2026-04-19 20:51:49 +00:00
373 lines
14 KiB
Python
373 lines
14 KiB
Python
"""
|
|
Jira Signal Agent
|
|
Takes ThirdEye signals and converts them into well-formed Jira tickets.
|
|
|
|
Responsibilities:
|
|
1. Map signal type → Jira issue type + priority
|
|
2. LLM-generate a clean ticket title and structured description from signal context
|
|
3. Extract assignee names and match them to Jira account IDs (best-effort)
|
|
4. Raise the ticket via jira_client and mark the signal in ChromaDB
|
|
5. Bulk-raise: process a group's unraised high-severity signals in one call
|
|
"""
|
|
import json
|
|
import logging
|
|
from datetime import datetime
|
|
|
|
from backend.providers import call_llm
|
|
from backend.integrations.jira_client import (
|
|
create_issue, search_issues, add_comment, is_configured, search_users
|
|
)
|
|
from backend.db.chroma import store_signals, mark_signal_as_raised, get_raised_signal_ids
|
|
from backend.config import (
|
|
JIRA_DEFAULT_PROJECT, JIRA_DEFAULT_ISSUE_TYPE,
|
|
JIRA_AUTO_RAISE_SEVERITY
|
|
)
|
|
|
|
logger = logging.getLogger("thirdeye.agents.jira_agent")
|
|
|
|
|
|
# ─── Signal → Jira type mapping ──────────────────────────────────────────────
|
|
|
|
# Maps ThirdEye signal type → (Jira issue type, default priority)
|
|
# Note: Issue types must match what's available in your Jira project
|
|
# Common types: Task, Bug, Story, Epic, Workstream (project-specific)
|
|
SIGNAL_TYPE_MAP = {
|
|
# Dev signals
|
|
"tech_debt": ("Task", "Low"),
|
|
"recurring_bug": ("Task", "High"), # Changed from Bug to Task
|
|
"architecture_decision": ("Task", "Medium"),
|
|
"deployment_risk": ("Task", "High"),
|
|
"workaround": ("Task", "Medium"),
|
|
"knowledge_silo": ("Task", "Medium"),
|
|
# Product signals
|
|
"feature_request": ("Task", "Medium"), # Changed from Story to Task
|
|
"priority_conflict": ("Task", "High"),
|
|
"sentiment_shift": ("Task", "Medium"),
|
|
# Client signals
|
|
"promise": ("Task", "High"),
|
|
"scope_creep": ("Task", "High"),
|
|
"risk": ("Task", "High"),
|
|
# Meet signals
|
|
"meet_action_item": ("Task", "Medium"),
|
|
"meet_blocker": ("Task", "Highest"),
|
|
"meet_risk": ("Task", "High"),
|
|
"meet_decision": ("Task", "Medium"),
|
|
"meet_open_q": ("Task", "Low"),
|
|
# Generic
|
|
"blocker": ("Task", "Highest"),
|
|
"decision": ("Task", "Medium"),
|
|
"action_item": ("Task", "Medium"),
|
|
}
|
|
|
|
SEVERITY_TO_PRIORITY = {
|
|
"critical": "Highest",
|
|
"high": "High",
|
|
"medium": "Medium",
|
|
"low": "Low",
|
|
}
|
|
|
|
RAISEABLE_TYPES = set(SIGNAL_TYPE_MAP.keys())
|
|
|
|
|
|
# ─── Assignee resolution ─────────────────────────────────────────────────────
|
|
|
|
async def resolve_assignee_account_id(name: str) -> str | None:
|
|
"""
|
|
Resolve a person's display name (or @name) to their Jira account ID.
|
|
Uses Jira's user search API and fuzzy-matches the best result.
|
|
Returns the account ID string, or None if no confident match is found.
|
|
"""
|
|
if not name:
|
|
return None
|
|
clean = name.lstrip("@").strip()
|
|
try:
|
|
users = await search_users(clean)
|
|
if not users:
|
|
return None
|
|
clean_lower = clean.lower()
|
|
# Exact display-name match first
|
|
for u in users:
|
|
if u["display_name"].lower() == clean_lower:
|
|
return u["account_id"]
|
|
# Partial match (all search words appear in display name)
|
|
words = clean_lower.split()
|
|
for u in users:
|
|
dn = u["display_name"].lower()
|
|
if all(w in dn for w in words):
|
|
return u["account_id"]
|
|
# Last resort: first result
|
|
return users[0]["account_id"]
|
|
except Exception as e:
|
|
logger.warning(f"resolve_assignee_account_id failed for '{name}': {e}")
|
|
return None
|
|
|
|
|
|
# ─── LLM ticket generation ───────────────────────────────────────────────────
|
|
|
|
TICKET_GEN_SYSTEM_PROMPT = """You are a senior engineering manager writing Jira tickets from team intelligence signals.
|
|
|
|
Given a ThirdEye signal (a structured piece of extracted team knowledge), write a Jira ticket.
|
|
|
|
Return ONLY a valid JSON object with exactly these fields:
|
|
{
|
|
"summary": "Short, actionable ticket title (max 100 chars). Start with a verb. No jargon.",
|
|
"description": "Full ticket description. Include: what the issue is, context from the signal, why it matters, suggested next steps. Use blank lines between sections. Use '- ' for bullet points. Max 400 words.",
|
|
"labels": ["label1", "label2"],
|
|
"assignee_name": "First name or @name of the person to assign, or null if unclear"
|
|
}
|
|
|
|
Label rules:
|
|
- Always include "thirdeye" and "auto-raised"
|
|
- Add the signal type as a label (e.g. "tech-debt", "recurring-bug")
|
|
- Add "urgent" if severity is high or critical
|
|
- Labels must not have spaces (use hyphens)
|
|
|
|
Summary rules:
|
|
- Starts with a verb: "Fix", "Investigate", "Address", "Resolve", "Document", "Implement"
|
|
- Be specific — "Fix intermittent checkout timeout" NOT "Fix bug"
|
|
- Never exceed 100 characters
|
|
|
|
Description must include:
|
|
1. What: clear 1-sentence problem statement
|
|
2. Context: what was actually said / detected (cite the signal)
|
|
3. Impact: why this matters to the team or product
|
|
4. Suggested next steps (2-3 bullet points)
|
|
|
|
Return JSON only — no markdown, no preamble."""
|
|
|
|
|
|
async def generate_ticket_content(signal: dict) -> dict:
|
|
"""
|
|
Use an LLM to generate a clean, context-rich Jira ticket from a ThirdEye signal.
|
|
Returns {"summary": str, "description": str, "labels": list, "assignee_name": str|None}
|
|
"""
|
|
signal_text = (
|
|
f"Signal type: {signal.get('type', 'unknown')}\n"
|
|
f"Summary: {signal.get('summary', '')}\n"
|
|
f"Raw quote: {signal.get('raw_quote', '')[:300]}\n"
|
|
f"Severity: {signal.get('severity', 'medium')}\n"
|
|
f"Entities involved: {', '.join(signal.get('entities', []))}\n"
|
|
f"Keywords: {', '.join(signal.get('keywords', []))}\n"
|
|
f"Timestamp: {signal.get('timestamp', '')}\n"
|
|
f"Group: {signal.get('group_id', '')}\n"
|
|
f"Lens: {signal.get('lens', '')}"
|
|
)
|
|
|
|
try:
|
|
result = await call_llm(
|
|
task_type="fast_large",
|
|
messages=[
|
|
{"role": "system", "content": TICKET_GEN_SYSTEM_PROMPT},
|
|
{"role": "user", "content": signal_text},
|
|
],
|
|
temperature=0.2,
|
|
max_tokens=800,
|
|
response_format={"type": "json_object"},
|
|
)
|
|
raw = result["content"].strip()
|
|
if raw.startswith("```"):
|
|
raw = raw.split("```")[1]
|
|
if raw.startswith("json"):
|
|
raw = raw[4:]
|
|
return json.loads(raw)
|
|
|
|
except Exception as e:
|
|
logger.warning(f"Ticket generation LLM failed: {e}. Using fallback.")
|
|
# Fallback: build a basic ticket without LLM
|
|
sig_type = signal.get("type", "unknown").replace("_", " ").title()
|
|
return {
|
|
"summary": f"{sig_type}: {signal.get('summary', 'Unknown issue')[:80]}",
|
|
"description": (
|
|
f"Signal detected by ThirdEye.\n\n"
|
|
f"Type: {signal.get('type', 'unknown')}\n"
|
|
f"Summary: {signal.get('summary', '')}\n\n"
|
|
f"Raw context:\n{signal.get('raw_quote', '(none)')[:300]}\n\n"
|
|
f"Severity: {signal.get('severity', 'medium')}"
|
|
),
|
|
"labels": ["thirdeye", "auto-raised", signal.get("type", "unknown").replace("_", "-")],
|
|
"assignee_name": None,
|
|
}
|
|
|
|
|
|
# ─── Main raise function ──────────────────────────────────────────────────────
|
|
|
|
async def raise_ticket_for_signal(
|
|
signal: dict,
|
|
group_id: str,
|
|
project_key: str = None,
|
|
force: bool = False,
|
|
assignee_account_id: str = None,
|
|
) -> dict:
|
|
"""
|
|
Create a Jira ticket for a single ThirdEye signal.
|
|
|
|
Args:
|
|
signal: The signal dict from ChromaDB
|
|
group_id: The group this signal belongs to (for dedup tracking)
|
|
project_key: Override project (default: JIRA_DEFAULT_PROJECT)
|
|
force: If True, raise even if already raised before
|
|
|
|
Returns:
|
|
{"ok": True, "key": "ENG-42", "url": "...", "summary": "..."}
|
|
OR
|
|
{"ok": False, "reason": "already_raised" | "not_raiseable" | "jira_error", ...}
|
|
"""
|
|
if not is_configured():
|
|
return {"ok": False, "reason": "jira_not_configured"}
|
|
|
|
signal_id = signal.get("id", "")
|
|
signal_type = signal.get("type", "")
|
|
|
|
# Check if this signal type is raiseable
|
|
if signal_type not in RAISEABLE_TYPES:
|
|
return {"ok": False, "reason": "not_raiseable", "signal_type": signal_type}
|
|
|
|
# Check if already raised (skip if force=True)
|
|
if not force and signal_id:
|
|
already_raised = get_raised_signal_ids(group_id)
|
|
if signal_id in already_raised:
|
|
return {"ok": False, "reason": "already_raised", "signal_id": signal_id}
|
|
|
|
# Determine Jira issue type and priority from signal
|
|
default_type, default_priority = SIGNAL_TYPE_MAP.get(signal_type, (JIRA_DEFAULT_ISSUE_TYPE, "Medium"))
|
|
severity = signal.get("severity", "medium").lower()
|
|
priority = SEVERITY_TO_PRIORITY.get(severity, default_priority)
|
|
|
|
# Generate ticket content via LLM
|
|
ticket_content = await generate_ticket_content(signal)
|
|
|
|
summary = ticket_content.get("summary", signal.get("summary", "ThirdEye signal")[:100])
|
|
description = ticket_content.get("description", signal.get("summary", ""))
|
|
labels = ticket_content.get("labels", ["thirdeye", "auto-raised"])
|
|
# Always ensure thirdeye label is present
|
|
if "thirdeye" not in labels:
|
|
labels.append("thirdeye")
|
|
|
|
# Append ThirdEye metadata as a context section in the description
|
|
meta_section = (
|
|
f"\n\n---\n"
|
|
f"Raised by: ThirdEye\n"
|
|
f"Signal ID: {signal_id}\n"
|
|
f"Group: {group_id}\n"
|
|
f"Detected: {signal.get('timestamp', datetime.utcnow().isoformat())}"
|
|
)
|
|
description = description + meta_section
|
|
|
|
# Resolve assignee: explicit account_id wins, then signal override name, then LLM-extracted name
|
|
if not assignee_account_id:
|
|
name_hint = signal.get("assignee_override") or ticket_content.get("assignee_name")
|
|
if name_hint:
|
|
assignee_account_id = await resolve_assignee_account_id(name_hint)
|
|
if assignee_account_id:
|
|
logger.info(f"Resolved assignee '{name_hint}' → {assignee_account_id}")
|
|
else:
|
|
logger.warning(f"Could not resolve assignee '{name_hint}' to a Jira account")
|
|
|
|
# Create the ticket
|
|
result = await create_issue(
|
|
project_key=project_key or JIRA_DEFAULT_PROJECT,
|
|
summary=summary,
|
|
description=description,
|
|
issue_type=default_type,
|
|
priority=priority,
|
|
labels=labels,
|
|
assignee_account_id=assignee_account_id,
|
|
)
|
|
|
|
if result.get("ok"):
|
|
jira_key = result["key"]
|
|
jira_url = result["url"]
|
|
# Mark this signal as raised in ChromaDB so we never duplicate it
|
|
if signal_id:
|
|
mark_signal_as_raised(
|
|
group_id, signal_id, jira_key,
|
|
jira_url=jira_url,
|
|
jira_summary=summary,
|
|
jira_priority=priority,
|
|
)
|
|
logger.info(f"Raised Jira ticket {jira_key} for signal {signal_id} ({signal_type})")
|
|
return {
|
|
"ok": True,
|
|
"key": jira_key,
|
|
"url": jira_url,
|
|
"summary": summary,
|
|
"issue_type": default_type,
|
|
"priority": priority,
|
|
"assignee_account_id": assignee_account_id,
|
|
}
|
|
else:
|
|
logger.error(f"Jira ticket creation failed: {result}")
|
|
return {
|
|
"ok": False,
|
|
"reason": "jira_error",
|
|
"error": result.get("error"),
|
|
"details": result.get("details"),
|
|
}
|
|
|
|
|
|
async def bulk_raise_for_group(
|
|
group_id: str,
|
|
signals: list[dict],
|
|
min_severity: str = None,
|
|
project_key: str = None,
|
|
max_tickets: int = 10,
|
|
) -> list[dict]:
|
|
"""
|
|
Raise Jira tickets for multiple signals from a group in one call.
|
|
|
|
Filters:
|
|
- Only raiseable signal types
|
|
- Only signals at or above min_severity (defaults to JIRA_AUTO_RAISE_SEVERITY)
|
|
- Skips signals already raised
|
|
- Caps at max_tickets to avoid flooding Jira
|
|
|
|
Returns list of raise results.
|
|
"""
|
|
min_sev = (min_severity or JIRA_AUTO_RAISE_SEVERITY).lower()
|
|
severity_rank = {"low": 0, "medium": 1, "high": 2, "critical": 3}
|
|
min_rank = severity_rank.get(min_sev, 2) # Default: high
|
|
|
|
already_raised = get_raised_signal_ids(group_id)
|
|
candidates = []
|
|
|
|
for sig in signals:
|
|
sig_type = sig.get("type", "")
|
|
sig_id = sig.get("id", "")
|
|
severity = sig.get("severity", "low").lower()
|
|
rank = severity_rank.get(severity, 0)
|
|
|
|
if sig_type not in RAISEABLE_TYPES:
|
|
continue
|
|
if rank < min_rank:
|
|
continue
|
|
if sig_id in already_raised:
|
|
continue
|
|
candidates.append(sig)
|
|
|
|
# Sort by severity descending, then raise up to max_tickets
|
|
candidates.sort(key=lambda s: severity_rank.get(s.get("severity", "low"), 0), reverse=True)
|
|
candidates = candidates[:max_tickets]
|
|
|
|
results = []
|
|
for sig in candidates:
|
|
result = await raise_ticket_for_signal(sig, group_id, project_key=project_key)
|
|
results.append({**result, "signal_type": sig.get("type"), "signal_summary": sig.get("summary", "")[:80]})
|
|
|
|
logger.info(f"Bulk raise for group {group_id}: {len(results)} tickets from {len(signals)} signals")
|
|
return results
|
|
|
|
|
|
def format_raise_result_for_telegram(result: dict) -> str:
|
|
"""Format a single raise result as a Telegram message line."""
|
|
if result.get("ok"):
|
|
return (
|
|
f"✅ [{result['key']}]({result['url']}) — "
|
|
f"*{result.get('issue_type', 'Task')}* | {result.get('priority', 'Medium')} priority\n"
|
|
f" _{result.get('summary', '')[:90]}_"
|
|
)
|
|
reason = result.get("reason", "unknown")
|
|
if reason == "already_raised":
|
|
return f"⏭️ Already raised — skipped"
|
|
if reason == "not_raiseable":
|
|
return f"⚪ Signal type `{result.get('signal_type', '?')}` — not mapped to Jira"
|
|
return f"❌ Failed: {result.get('error', reason)}" |