siggy-memory 0.5.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agents/__init__.py +0 -0
- agents/incident_agent.py +90 -0
- agents/investigator.py +117 -0
- agents/normalizer.py +64 -0
- agents/recommender.py +81 -0
- cli/__init__.py +1 -0
- cli/__main__.py +3 -0
- cli/config.py +83 -0
- cli/main.py +1448 -0
- experience/__init__.py +1 -0
- experience/aggregator.py +87 -0
- experience/models.py +127 -0
- experience/patterns.py +29 -0
- experience/ranking.py +216 -0
- experience/statistics.py +64 -0
- experience/store.py +199 -0
- graph/__init__.py +1 -0
- graph/builder.py +193 -0
- graph/client.py +237 -0
- graph/context_builder.py +97 -0
- graph/queries.py +114 -0
- graph/schema.py +70 -0
- incident/__init__.py +1 -0
- incident/processor.py +448 -0
- knowledge/__init__.py +0 -0
- knowledge/normalization_v2.py +564 -0
- knowledge/normalizer.py +154 -0
- knowledge/pipeline.py +240 -0
- knowledge/schema.py +78 -0
- knowledge/taxonomy.py +538 -0
- llm/__init__.py +0 -0
- llm/prompt.py +123 -0
- memory/__init__.py +0 -0
- memory/embeddings.py +50 -0
- memory/experience.py +97 -0
- memory/provider.py +23 -0
- memory/search.py +53 -0
- memory/vector_store.py +241 -0
- models/__init__.py +0 -0
- models/incident.py +116 -0
- otel/__init__.py +1 -0
- otel/instrument.py +80 -0
- rules/__init__.py +0 -0
- rules/engine.py +141 -0
- siggy_memory-0.5.0.dist-info/METADATA +25 -0
- siggy_memory-0.5.0.dist-info/RECORD +56 -0
- siggy_memory-0.5.0.dist-info/WHEEL +5 -0
- siggy_memory-0.5.0.dist-info/entry_points.txt +2 -0
- siggy_memory-0.5.0.dist-info/top_level.txt +13 -0
- telemetry/__init__.py +2 -0
- telemetry/mcp_http.py +257 -0
- telemetry/provider.py +51 -0
- telemetry/signoz_mcp.py +177 -0
- telemetry/summarizer.py +119 -0
- utils/__init__.py +1 -0
- utils/fallbacks.py +376 -0
agents/__init__.py
ADDED
|
File without changes
|
agents/incident_agent.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
from groq import Groq
|
|
4
|
+
from dotenv import load_dotenv
|
|
5
|
+
|
|
6
|
+
from memory.embeddings import get_embedding
|
|
7
|
+
from memory.search import get_search
|
|
8
|
+
from agents.normalizer import normalize_incident, build_embedding_text
|
|
9
|
+
from llm.prompt import build_analyze_prompt, build_search_hint_prompt
|
|
10
|
+
from utils.fallbacks import analyze_incident_fallback
|
|
11
|
+
|
|
12
|
+
load_dotenv()
|
|
13
|
+
|
|
14
|
+
_client = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _get_client():
|
|
18
|
+
global _client
|
|
19
|
+
if _client is None:
|
|
20
|
+
_client = Groq(api_key=os.getenv("GROQ_API_KEY", ""))
|
|
21
|
+
return _client
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class IncidentAgent:
|
|
25
|
+
def __init__(self):
|
|
26
|
+
self.search = get_search()
|
|
27
|
+
|
|
28
|
+
def analyze(self, title: str, summary: str) -> dict:
|
|
29
|
+
# Step 1: Normalize
|
|
30
|
+
normalized = normalize_incident(title, summary)
|
|
31
|
+
|
|
32
|
+
# Step 2: Embed
|
|
33
|
+
embedding_text = build_embedding_text(normalized)
|
|
34
|
+
query_embedding = get_embedding(embedding_text)
|
|
35
|
+
|
|
36
|
+
# Step 3: Search with metadata hints
|
|
37
|
+
hints = build_search_hint_prompt(normalized.model_dump())
|
|
38
|
+
similar_incidents = self.search.retrieve(
|
|
39
|
+
query_embedding=query_embedding,
|
|
40
|
+
top_k=5,
|
|
41
|
+
filters=hints if hints else None,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# Step 4: If metadata filter too strict, fallback to pure vector search
|
|
45
|
+
if len(similar_incidents) < 2:
|
|
46
|
+
similar_incidents = self.search.retrieve(
|
|
47
|
+
query_embedding=query_embedding,
|
|
48
|
+
top_k=5,
|
|
49
|
+
filters=None,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# Step 5: Build prompt and call LLM
|
|
53
|
+
prompt = build_analyze_prompt(
|
|
54
|
+
current_incident={"title": title, "summary": summary},
|
|
55
|
+
similar_incidents=similar_incidents,
|
|
56
|
+
normalized=normalized.model_dump(),
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
response = _get_client().chat.completions.create(
|
|
61
|
+
model="llama-3.3-70b-versatile",
|
|
62
|
+
messages=[
|
|
63
|
+
{
|
|
64
|
+
"role": "system",
|
|
65
|
+
"content": (
|
|
66
|
+
"You are a senior SRE engineer. "
|
|
67
|
+
"Always return valid JSON. "
|
|
68
|
+
"Show your reasoning chain step by step."
|
|
69
|
+
),
|
|
70
|
+
},
|
|
71
|
+
{"role": "user", "content": prompt},
|
|
72
|
+
],
|
|
73
|
+
temperature=0,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
raw = response.choices[0].message.content.strip()
|
|
77
|
+
if raw.startswith("```"):
|
|
78
|
+
raw = raw.split("\n", 1)[1].rsplit("```", 1)[0].strip()
|
|
79
|
+
|
|
80
|
+
analysis = json.loads(raw)
|
|
81
|
+
except Exception:
|
|
82
|
+
analysis = analyze_incident_fallback(title, summary, normalized, similar_incidents)
|
|
83
|
+
|
|
84
|
+
# Step 6: Build final response
|
|
85
|
+
return {
|
|
86
|
+
"current_incident": {"title": title, "summary": summary},
|
|
87
|
+
"normalized": normalized.model_dump(),
|
|
88
|
+
"similar_incidents": similar_incidents,
|
|
89
|
+
"analysis": analysis,
|
|
90
|
+
}
|
agents/investigator.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
import asyncio
|
|
4
|
+
from datetime import datetime, timedelta
|
|
5
|
+
from groq import Groq
|
|
6
|
+
from dotenv import load_dotenv
|
|
7
|
+
|
|
8
|
+
from telemetry.signoz_mcp import get_telemetry_provider
|
|
9
|
+
from telemetry.summarizer import summarize_telemetry
|
|
10
|
+
from utils.fallbacks import infer_service
|
|
11
|
+
|
|
12
|
+
load_dotenv()
|
|
13
|
+
|
|
14
|
+
_client = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _get_client():
|
|
18
|
+
global _client
|
|
19
|
+
if _client is None:
|
|
20
|
+
_client = Groq(api_key=os.getenv("GROQ_API_KEY", ""))
|
|
21
|
+
return _client
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class InvestigatorAgent:
|
|
25
|
+
def __init__(self):
|
|
26
|
+
self.telemetry = get_telemetry_provider()
|
|
27
|
+
|
|
28
|
+
async def investigate(self, query: str) -> dict:
|
|
29
|
+
now = datetime.utcnow()
|
|
30
|
+
start_time = (now - timedelta(hours=1)).isoformat() + "Z"
|
|
31
|
+
end_time = now.isoformat() + "Z"
|
|
32
|
+
|
|
33
|
+
try:
|
|
34
|
+
services = await self.telemetry.list_services(start_time, end_time)
|
|
35
|
+
except Exception:
|
|
36
|
+
services = []
|
|
37
|
+
relevant_service = self._identify_service(query, services)
|
|
38
|
+
|
|
39
|
+
logs_task = self.telemetry.search_logs(
|
|
40
|
+
service=relevant_service,
|
|
41
|
+
query="level:error OR level:warn OR timeout OR exception OR error",
|
|
42
|
+
limit=30,
|
|
43
|
+
start_time=start_time,
|
|
44
|
+
end_time=end_time,
|
|
45
|
+
)
|
|
46
|
+
traces_task = self.telemetry.search_traces(
|
|
47
|
+
service=relevant_service,
|
|
48
|
+
min_duration_ms=1000,
|
|
49
|
+
limit=20,
|
|
50
|
+
start_time=start_time,
|
|
51
|
+
end_time=end_time,
|
|
52
|
+
)
|
|
53
|
+
latency_task = self.telemetry.query_metrics(
|
|
54
|
+
service=relevant_service,
|
|
55
|
+
metric_name="http_request_duration_milliseconds",
|
|
56
|
+
aggregation="p99",
|
|
57
|
+
start_time=start_time,
|
|
58
|
+
end_time=end_time,
|
|
59
|
+
)
|
|
60
|
+
error_task = self.telemetry.query_metrics(
|
|
61
|
+
service=relevant_service,
|
|
62
|
+
metric_name="http_requests_total",
|
|
63
|
+
aggregation="rate",
|
|
64
|
+
start_time=start_time,
|
|
65
|
+
end_time=end_time,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
results = await asyncio.gather(
|
|
69
|
+
logs_task, traces_task, latency_task, error_task,
|
|
70
|
+
return_exceptions=True,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
logs = results[0] if not isinstance(results[0], Exception) else []
|
|
74
|
+
traces = results[1] if not isinstance(results[1], Exception) else []
|
|
75
|
+
latency_metric = results[2] if not isinstance(results[2], Exception) else {}
|
|
76
|
+
error_metric = results[3] if not isinstance(results[3], Exception) else {}
|
|
77
|
+
|
|
78
|
+
raw_data = {
|
|
79
|
+
"query": query,
|
|
80
|
+
"service": relevant_service,
|
|
81
|
+
"time_range": {"start": start_time, "end": end_time},
|
|
82
|
+
"logs": logs,
|
|
83
|
+
"traces": traces,
|
|
84
|
+
"latency_metric": latency_metric,
|
|
85
|
+
"error_metric": error_metric,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
summary = summarize_telemetry(raw_data)
|
|
89
|
+
return summary
|
|
90
|
+
|
|
91
|
+
def _identify_service(self, query: str, services: list[dict]) -> str:
|
|
92
|
+
query_lower = query.lower()
|
|
93
|
+
|
|
94
|
+
for svc in services:
|
|
95
|
+
svc_name = svc.get("serviceName", "").lower()
|
|
96
|
+
if svc_name in query_lower or query_lower in svc_name:
|
|
97
|
+
return svc.get("serviceName", "unknown")
|
|
98
|
+
|
|
99
|
+
if not services:
|
|
100
|
+
return infer_service(query, "unknown")
|
|
101
|
+
|
|
102
|
+
service_names = [s.get("serviceName", "unknown") for s in services]
|
|
103
|
+
heuristic = infer_service(query, "")
|
|
104
|
+
if heuristic and any(heuristic == svc.lower() for svc in service_names):
|
|
105
|
+
return heuristic
|
|
106
|
+
try:
|
|
107
|
+
response = _get_client().chat.completions.create(
|
|
108
|
+
model="llama-3.3-70b-versatile",
|
|
109
|
+
messages=[
|
|
110
|
+
{"role": "system", "content": "You are a service classifier. Return ONLY the service name."},
|
|
111
|
+
{"role": "user", "content": f"User query: '{query}'\nAvailable services: {service_names}\nWhich service is the user talking about? Return ONLY the service name."},
|
|
112
|
+
],
|
|
113
|
+
temperature=0,
|
|
114
|
+
)
|
|
115
|
+
return response.choices[0].message.content.strip().strip('"').strip("'")
|
|
116
|
+
except Exception:
|
|
117
|
+
return service_names[0] if service_names else "unknown"
|
agents/normalizer.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
from groq import Groq
|
|
4
|
+
from dotenv import load_dotenv
|
|
5
|
+
|
|
6
|
+
from models.incident import NormalizedIncident
|
|
7
|
+
from utils.fallbacks import normalize_incident_fallback
|
|
8
|
+
|
|
9
|
+
load_dotenv()
|
|
10
|
+
|
|
11
|
+
_client = None
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _get_client():
|
|
15
|
+
global _client
|
|
16
|
+
if _client is None:
|
|
17
|
+
_client = Groq(api_key=os.getenv("GROQ_API_KEY", ""))
|
|
18
|
+
return _client
|
|
19
|
+
|
|
20
|
+
NORMALIZER_PROMPT = """You are an incident classifier. Convert the raw incident description into a structured JSON object.
|
|
21
|
+
|
|
22
|
+
Rules:
|
|
23
|
+
- service: the affected service name (lowercase, use "unknown" if unclear)
|
|
24
|
+
- component: the infrastructure component that failed (e.g., redis, kafka, postgresql, cpu, memory, disk, network)
|
|
25
|
+
- failure: the failure type in snake_case (e.g., connection_pool_exhaustion, broker_unavailable, infinite_loop, timeout, memory_leak)
|
|
26
|
+
- symptom: the observable symptom in snake_case (e.g., high_latency, request_timeout, cpu_spike, oom_killed, message_backup)
|
|
27
|
+
- root_cause: one-line technical root cause
|
|
28
|
+
- fix: one-line recommended fix
|
|
29
|
+
|
|
30
|
+
Return ONLY valid JSON. No markdown, no explanation.
|
|
31
|
+
|
|
32
|
+
Incident title: {title}
|
|
33
|
+
Incident summary: {summary}"""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def normalize_incident(title: str, summary: str) -> NormalizedIncident:
|
|
37
|
+
try:
|
|
38
|
+
response = _get_client().chat.completions.create(
|
|
39
|
+
model="llama-3.3-70b-versatile",
|
|
40
|
+
messages=[
|
|
41
|
+
{
|
|
42
|
+
"role": "system",
|
|
43
|
+
"content": "You are an incident classifier. Return only valid JSON.",
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"role": "user",
|
|
47
|
+
"content": NORMALIZER_PROMPT.format(title=title, summary=summary),
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
temperature=0,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
raw = response.choices[0].message.content.strip()
|
|
54
|
+
if raw.startswith("```"):
|
|
55
|
+
raw = raw.split("\n", 1)[1].rsplit("```", 1)[0].strip()
|
|
56
|
+
|
|
57
|
+
data = json.loads(raw)
|
|
58
|
+
return NormalizedIncident(**data)
|
|
59
|
+
except Exception:
|
|
60
|
+
return normalize_incident_fallback(title, summary)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def build_embedding_text(normalized: NormalizedIncident) -> str:
|
|
64
|
+
return f"{normalized.service} {normalized.component} {normalized.failure} {normalized.symptom} {normalized.root_cause}"
|
agents/recommender.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
from groq import Groq
|
|
4
|
+
from dotenv import load_dotenv
|
|
5
|
+
|
|
6
|
+
load_dotenv()
|
|
7
|
+
|
|
8
|
+
_client = None
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _get_client():
|
|
12
|
+
global _client
|
|
13
|
+
if _client is None:
|
|
14
|
+
_client = Groq(api_key=os.getenv("GROQ_API_KEY", ""))
|
|
15
|
+
return _client
|
|
16
|
+
|
|
17
|
+
RECOMMENDER_PROMPT = """You are an SRE incident resolution expert. A new incident has occurred. Below are similar incidents from the past with their root causes and fixes.
|
|
18
|
+
|
|
19
|
+
Use these past incidents to recommend the best fix for the new incident.
|
|
20
|
+
|
|
21
|
+
IMPORTANT: Return your response as valid JSON with this exact structure:
|
|
22
|
+
{{
|
|
23
|
+
"recommended_fix": "detailed fix recommendation",
|
|
24
|
+
"confidence": 0.0 to 1.0,
|
|
25
|
+
"explanation": "brief explanation of why this fix is recommended"
|
|
26
|
+
}}
|
|
27
|
+
|
|
28
|
+
--- NEW INCIDENT ---
|
|
29
|
+
Title: {title}
|
|
30
|
+
Summary: {summary}
|
|
31
|
+
Normalized: {normalized}
|
|
32
|
+
|
|
33
|
+
--- SIMILAR PAST INCIDENTS ---
|
|
34
|
+
{similar_incidents}
|
|
35
|
+
|
|
36
|
+
--- END ---
|
|
37
|
+
|
|
38
|
+
Based on the above similar incidents, recommend the best fix for the new incident. Return ONLY valid JSON."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def recommend_fix(
|
|
42
|
+
title: str,
|
|
43
|
+
summary: str,
|
|
44
|
+
normalized: dict,
|
|
45
|
+
similar_incidents: list[dict],
|
|
46
|
+
) -> dict:
|
|
47
|
+
incidents_text = ""
|
|
48
|
+
for i, inc in enumerate(similar_incidents, 1):
|
|
49
|
+
incidents_text += f"""
|
|
50
|
+
Incident {i}:
|
|
51
|
+
Title: {inc['title']}
|
|
52
|
+
Root Cause: {inc['root_cause']}
|
|
53
|
+
Fix: {inc['fix']}
|
|
54
|
+
Similarity: {inc['similarity']}
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
response = _get_client().chat.completions.create(
|
|
58
|
+
model="llama-3.3-70b-versatile",
|
|
59
|
+
messages=[
|
|
60
|
+
{
|
|
61
|
+
"role": "system",
|
|
62
|
+
"content": "You are an SRE expert. Return only valid JSON.",
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"role": "user",
|
|
66
|
+
"content": RECOMMENDER_PROMPT.format(
|
|
67
|
+
title=title,
|
|
68
|
+
summary=summary,
|
|
69
|
+
normalized=str(normalized),
|
|
70
|
+
similar_incidents=incidents_text.strip(),
|
|
71
|
+
),
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
temperature=0,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
raw = response.choices[0].message.content.strip()
|
|
78
|
+
if raw.startswith("```"):
|
|
79
|
+
raw = raw.split("\n", 1)[1].rsplit("```", 1)[0].strip()
|
|
80
|
+
|
|
81
|
+
return json.loads(raw)
|
cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Siggy CLI — production observability with memory."""
|
cli/__main__.py
ADDED
cli/config.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Siggy configuration — reads and writes .siggy.yaml."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import yaml
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
CONFIG_FILENAME = ".siggy.yaml"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class SigNozConfig:
|
|
17
|
+
url: str = "http://localhost:8080"
|
|
18
|
+
otlp_endpoint: str = "http://localhost:4317"
|
|
19
|
+
mcp_url: str = "http://localhost:8000/mcp"
|
|
20
|
+
api_key: str = ""
|
|
21
|
+
dashboard_url: str = ""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class ServiceConfig:
|
|
26
|
+
default_name: str = "my-service"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class MemoryConfig:
|
|
31
|
+
backend_url: str = "http://localhost:8010"
|
|
32
|
+
qdrant_url: str = "http://localhost:6333"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class SiggyConfig:
|
|
37
|
+
signoz: SigNozConfig = field(default_factory=SigNozConfig)
|
|
38
|
+
service: ServiceConfig = field(default_factory=ServiceConfig)
|
|
39
|
+
memory: MemoryConfig = field(default_factory=MemoryConfig)
|
|
40
|
+
|
|
41
|
+
@classmethod
|
|
42
|
+
def load(cls, path: Path | None = None) -> "SiggyConfig":
|
|
43
|
+
path = path or _find_config()
|
|
44
|
+
if path is None:
|
|
45
|
+
return cls()
|
|
46
|
+
|
|
47
|
+
with open(path, encoding="utf-8") as f:
|
|
48
|
+
raw = yaml.safe_load(f) or {}
|
|
49
|
+
|
|
50
|
+
return cls(
|
|
51
|
+
signoz=SigNozConfig(**raw.get("signoz", {})),
|
|
52
|
+
service=ServiceConfig(**raw.get("service", {})),
|
|
53
|
+
memory=MemoryConfig(**raw.get("memory", {})),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def save(self, path: Path | None = None) -> Path:
|
|
57
|
+
path = path or Path.cwd() / CONFIG_FILENAME
|
|
58
|
+
data = {
|
|
59
|
+
"signoz": {
|
|
60
|
+
"url": self.signoz.url,
|
|
61
|
+
"otlp_endpoint": self.signoz.otlp_endpoint,
|
|
62
|
+
"mcp_url": self.signoz.mcp_url,
|
|
63
|
+
"api_key": self.signoz.api_key or os.getenv("SIGNOZ_API_KEY", ""),
|
|
64
|
+
"dashboard_url": self.signoz.dashboard_url,
|
|
65
|
+
},
|
|
66
|
+
"service": {"default_name": self.service.default_name},
|
|
67
|
+
"memory": {
|
|
68
|
+
"backend_url": self.memory.backend_url,
|
|
69
|
+
"qdrant_url": self.memory.qdrant_url,
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
73
|
+
yaml.dump(data, f, default_flow_style=False, sort_keys=False)
|
|
74
|
+
return path
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _find_config() -> Path | None:
|
|
78
|
+
current = Path.cwd()
|
|
79
|
+
for parent in [current, *current.parents]:
|
|
80
|
+
candidate = parent / CONFIG_FILENAME
|
|
81
|
+
if candidate.exists():
|
|
82
|
+
return candidate
|
|
83
|
+
return None
|