Files
B.Tech-Project-III/thirdeye/backend/agents/jira_agent.py
2026-04-05 00:43:23 +05:30

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)}"