mirror of
https://github.com/arkorty/B.Tech-Project-III.git
synced 2026-04-19 20:51:49 +00:00
init
This commit is contained in:
373
thirdeye/backend/agents/jira_agent.py
Normal file
373
thirdeye/backend/agents/jira_agent.py
Normal file
@@ -0,0 +1,373 @@
|
||||
"""
|
||||
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)}"
|
||||
Reference in New Issue
Block a user