devrel-origin 0.2.14__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.
- devrel_origin/__init__.py +15 -0
- devrel_origin/cli/__init__.py +92 -0
- devrel_origin/cli/_common.py +243 -0
- devrel_origin/cli/analytics.py +28 -0
- devrel_origin/cli/argus.py +497 -0
- devrel_origin/cli/auth.py +227 -0
- devrel_origin/cli/config.py +108 -0
- devrel_origin/cli/content.py +259 -0
- devrel_origin/cli/cost.py +108 -0
- devrel_origin/cli/cro.py +298 -0
- devrel_origin/cli/deliverables.py +65 -0
- devrel_origin/cli/docs.py +91 -0
- devrel_origin/cli/doctor.py +178 -0
- devrel_origin/cli/experiment.py +29 -0
- devrel_origin/cli/growth.py +97 -0
- devrel_origin/cli/init.py +472 -0
- devrel_origin/cli/intel.py +27 -0
- devrel_origin/cli/kb.py +96 -0
- devrel_origin/cli/listen.py +31 -0
- devrel_origin/cli/marketing.py +66 -0
- devrel_origin/cli/migrate.py +45 -0
- devrel_origin/cli/run.py +46 -0
- devrel_origin/cli/sales.py +57 -0
- devrel_origin/cli/schedule.py +62 -0
- devrel_origin/cli/synthesize.py +28 -0
- devrel_origin/cli/triage.py +29 -0
- devrel_origin/cli/video.py +35 -0
- devrel_origin/core/__init__.py +58 -0
- devrel_origin/core/agent_config.py +75 -0
- devrel_origin/core/argus.py +964 -0
- devrel_origin/core/atlas.py +1450 -0
- devrel_origin/core/base.py +372 -0
- devrel_origin/core/cyra.py +563 -0
- devrel_origin/core/dex.py +708 -0
- devrel_origin/core/echo.py +614 -0
- devrel_origin/core/growth/__init__.py +27 -0
- devrel_origin/core/growth/recommendations.py +219 -0
- devrel_origin/core/growth/target_kinds.py +51 -0
- devrel_origin/core/iris.py +513 -0
- devrel_origin/core/kai.py +1367 -0
- devrel_origin/core/llm.py +542 -0
- devrel_origin/core/llm_backends.py +274 -0
- devrel_origin/core/mox.py +514 -0
- devrel_origin/core/nova.py +349 -0
- devrel_origin/core/pax.py +1205 -0
- devrel_origin/core/rex.py +532 -0
- devrel_origin/core/sage.py +486 -0
- devrel_origin/core/sentinel.py +385 -0
- devrel_origin/core/types.py +98 -0
- devrel_origin/core/video/__init__.py +22 -0
- devrel_origin/core/video/assembler.py +131 -0
- devrel_origin/core/video/browser_recorder.py +118 -0
- devrel_origin/core/video/desktop_recorder.py +254 -0
- devrel_origin/core/video/overlay_renderer.py +143 -0
- devrel_origin/core/video/script_parser.py +147 -0
- devrel_origin/core/video/tts_engine.py +82 -0
- devrel_origin/core/vox.py +268 -0
- devrel_origin/core/watchdog.py +321 -0
- devrel_origin/project/__init__.py +1 -0
- devrel_origin/project/config.py +75 -0
- devrel_origin/project/cost_sink.py +61 -0
- devrel_origin/project/init.py +104 -0
- devrel_origin/project/paths.py +75 -0
- devrel_origin/project/state.py +241 -0
- devrel_origin/project/templates/__init__.py +4 -0
- devrel_origin/project/templates/config.toml +24 -0
- devrel_origin/project/templates/devrel.gitignore +10 -0
- devrel_origin/project/templates/slop-blocklist.md +45 -0
- devrel_origin/project/templates/style.md +24 -0
- devrel_origin/project/templates/voice.md +29 -0
- devrel_origin/quality/__init__.py +66 -0
- devrel_origin/quality/editorial.py +357 -0
- devrel_origin/quality/persona.py +84 -0
- devrel_origin/quality/readability.py +148 -0
- devrel_origin/quality/slop.py +167 -0
- devrel_origin/quality/style.py +110 -0
- devrel_origin/quality/voice.py +15 -0
- devrel_origin/tools/__init__.py +9 -0
- devrel_origin/tools/analytics.py +304 -0
- devrel_origin/tools/api_client.py +393 -0
- devrel_origin/tools/apollo_client.py +305 -0
- devrel_origin/tools/code_validator.py +428 -0
- devrel_origin/tools/github_tools.py +297 -0
- devrel_origin/tools/instantly_client.py +412 -0
- devrel_origin/tools/kb_harvester.py +340 -0
- devrel_origin/tools/mcp_server.py +578 -0
- devrel_origin/tools/notifications.py +245 -0
- devrel_origin/tools/run_report.py +193 -0
- devrel_origin/tools/scheduler.py +231 -0
- devrel_origin/tools/search_tools.py +321 -0
- devrel_origin/tools/self_improve.py +168 -0
- devrel_origin/tools/sheets.py +236 -0
- devrel_origin-0.2.14.dist-info/METADATA +354 -0
- devrel_origin-0.2.14.dist-info/RECORD +98 -0
- devrel_origin-0.2.14.dist-info/WHEEL +5 -0
- devrel_origin-0.2.14.dist-info/entry_points.txt +2 -0
- devrel_origin-0.2.14.dist-info/licenses/LICENSE +21 -0
- devrel_origin-0.2.14.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Rex — Competitive Intelligence Agent
|
|
3
|
+
|
|
4
|
+
Monitors the competitive landscape and produces actionable intelligence
|
|
5
|
+
that informs sales positioning, product strategy, and marketing messaging.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import TYPE_CHECKING, Any, Optional
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
from devrel_origin.tools.apollo_client import ApolloClient
|
|
18
|
+
|
|
19
|
+
from devrel_origin.core.base import (
|
|
20
|
+
STOP_WORDS,
|
|
21
|
+
get_kb_search,
|
|
22
|
+
load_agent_prompt,
|
|
23
|
+
strip_markdown_fences,
|
|
24
|
+
)
|
|
25
|
+
from devrel_origin.core.llm import LLMClient
|
|
26
|
+
from devrel_origin.tools.api_client import PostHogClient
|
|
27
|
+
from devrel_origin.tools.search_tools import SearchTools
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger(__name__)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Keywords near which capitalised words are treated as competitor names
|
|
33
|
+
COMPETITOR_KEYWORDS = {"vs", "alternative", "compared to", "competitor", "versus"}
|
|
34
|
+
|
|
35
|
+
# Extra stop words specific to Rex (competitive analysis keywords)
|
|
36
|
+
REX_STOP_WORDS = frozenset(
|
|
37
|
+
{
|
|
38
|
+
"write",
|
|
39
|
+
"technical",
|
|
40
|
+
"tutorial",
|
|
41
|
+
"addressing",
|
|
42
|
+
"developer",
|
|
43
|
+
"pain",
|
|
44
|
+
"point",
|
|
45
|
+
"analyze",
|
|
46
|
+
"analyse",
|
|
47
|
+
"competitive",
|
|
48
|
+
"landscape",
|
|
49
|
+
"report",
|
|
50
|
+
}
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
# Cap parallel web-search and Apollo enrichment fan-out so a 10-competitor
|
|
54
|
+
# task doesn't open 10 simultaneous Firecrawl/Brave/Apollo connections.
|
|
55
|
+
# 3 is conservative enough for free-tier API limits while still cutting
|
|
56
|
+
# wall-clock time roughly to ceil(N/3).
|
|
57
|
+
SEARCH_CONCURRENCY = 3
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _guess_domain(comp: str) -> str:
|
|
61
|
+
"""Best-effort domain guess for a competitor name.
|
|
62
|
+
|
|
63
|
+
Preserves an existing TLD if the input already looks like a domain
|
|
64
|
+
(e.g. ``"Pendo.io"`` → ``"pendo.io"``, ``"FullStory"`` → ``"fullstory.com"``).
|
|
65
|
+
Strips spaces; the result is lowercased so callers can pass it
|
|
66
|
+
straight to Apollo enrichment.
|
|
67
|
+
"""
|
|
68
|
+
cleaned = comp.strip().lower().replace(" ", "")
|
|
69
|
+
# If the name already contains a dot followed by 2+ alpha chars, treat
|
|
70
|
+
# it as an existing domain and don't append `.com` on top.
|
|
71
|
+
if re.search(r"\.[a-z]{2,}$", cleaned):
|
|
72
|
+
return cleaned
|
|
73
|
+
return f"{cleaned}.com"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class CompetitorProfile:
|
|
78
|
+
"""A tracked competitor and their current market position."""
|
|
79
|
+
|
|
80
|
+
name: str
|
|
81
|
+
domain: str
|
|
82
|
+
category: str # e.g., "ai-assistant", "chatbot-platform"
|
|
83
|
+
strengths: list[str]
|
|
84
|
+
weaknesses: list[str]
|
|
85
|
+
recent_moves: list[str]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class MarketPosition:
|
|
90
|
+
"""How a competitor positions themselves."""
|
|
91
|
+
|
|
92
|
+
competitor: str
|
|
93
|
+
positioning_statement: str
|
|
94
|
+
differentiators: list[str]
|
|
95
|
+
pricing_tier: str # "free", "freemium", "paid", "enterprise"
|
|
96
|
+
target_audience: str
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@dataclass
|
|
100
|
+
class Threat:
|
|
101
|
+
"""A competitive threat."""
|
|
102
|
+
|
|
103
|
+
competitor: str
|
|
104
|
+
threat: str
|
|
105
|
+
severity: str # "high", "medium", "low"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass
|
|
109
|
+
class Opportunity:
|
|
110
|
+
"""A competitive gap/opportunity."""
|
|
111
|
+
|
|
112
|
+
gap: str
|
|
113
|
+
recommendation: str
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass
|
|
117
|
+
class CompetitiveReport:
|
|
118
|
+
"""Weekly competitive intelligence output."""
|
|
119
|
+
|
|
120
|
+
profiles: list[CompetitorProfile]
|
|
121
|
+
market_positions: list[MarketPosition]
|
|
122
|
+
threats: list[Threat]
|
|
123
|
+
opportunities: list[Opportunity]
|
|
124
|
+
recommended_responses: list[str]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class Rex:
|
|
128
|
+
"""
|
|
129
|
+
Competitive Intelligence agent for monitoring the competitive landscape.
|
|
130
|
+
|
|
131
|
+
Capabilities:
|
|
132
|
+
- Discover competitors from task descriptions and the knowledge base
|
|
133
|
+
- Web-search each competitor for recent activity and positioning
|
|
134
|
+
- Produce competitor profiles with strengths/weaknesses
|
|
135
|
+
- Assess threats and opportunities with severity/impact ratings
|
|
136
|
+
- Generate actionable competitive intelligence reports
|
|
137
|
+
|
|
138
|
+
Tools:
|
|
139
|
+
1. knowledge_base_search — Retrieve competitive mentions from docs
|
|
140
|
+
2. web_search — Search the web for competitor activity
|
|
141
|
+
3. llm_generate — Generate structured competitive analysis
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
_DEFAULT_SYSTEM_PROMPT = (
|
|
145
|
+
"You are Rex, a competitive intelligence analyst for {product_name}. "
|
|
146
|
+
"Your role is to monitor the competitive landscape and produce actionable "
|
|
147
|
+
"intelligence that informs sales positioning, product strategy, and "
|
|
148
|
+
"marketing messaging.\n\n"
|
|
149
|
+
"You produce:\n"
|
|
150
|
+
"- Weekly competitive landscape reports\n"
|
|
151
|
+
"- Competitor profiles with strengths/weaknesses\n"
|
|
152
|
+
"- Threat assessments with severity ratings\n"
|
|
153
|
+
"- Opportunity identification with recommended responses\n\n"
|
|
154
|
+
"Ground all analysis in evidence: social mentions, GitHub activity, "
|
|
155
|
+
"web search results, and knowledge base comparisons. "
|
|
156
|
+
"Never speculate without data."
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
@property
|
|
160
|
+
def SYSTEM_PROMPT_TEMPLATE(self) -> str:
|
|
161
|
+
return self._system_prompt_template
|
|
162
|
+
|
|
163
|
+
@property
|
|
164
|
+
def SYSTEM_PROMPT(self) -> str:
|
|
165
|
+
return self._system_prompt
|
|
166
|
+
|
|
167
|
+
def __init__(
|
|
168
|
+
self,
|
|
169
|
+
api_client: PostHogClient,
|
|
170
|
+
knowledge_base_path: Path,
|
|
171
|
+
llm_client: Optional[LLMClient] = None,
|
|
172
|
+
search_tools: Optional[SearchTools] = None,
|
|
173
|
+
apollo_client: Optional["ApolloClient"] = None, # NEW
|
|
174
|
+
product_name: str = "the target product",
|
|
175
|
+
):
|
|
176
|
+
self.api_client = api_client
|
|
177
|
+
self.knowledge_base_path = knowledge_base_path
|
|
178
|
+
self.llm_client = llm_client
|
|
179
|
+
self.search_tools = search_tools
|
|
180
|
+
self.apollo_client = apollo_client # NEW
|
|
181
|
+
self.product_name = product_name
|
|
182
|
+
self._kb = get_kb_search(
|
|
183
|
+
knowledge_base_path,
|
|
184
|
+
extra_stop_words=REX_STOP_WORDS,
|
|
185
|
+
)
|
|
186
|
+
self._system_prompt_template = load_agent_prompt(
|
|
187
|
+
"rex", "system_prompt.txt", self._DEFAULT_SYSTEM_PROMPT
|
|
188
|
+
)
|
|
189
|
+
self._system_prompt = self._system_prompt_template.format(product_name=self.product_name)
|
|
190
|
+
|
|
191
|
+
# ------------------------------------------------------------------
|
|
192
|
+
# Competitor discovery
|
|
193
|
+
# ------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
def _discover_competitors(self, task: str) -> list[str]:
|
|
196
|
+
"""
|
|
197
|
+
Discover competitor names from the task string and the knowledge base.
|
|
198
|
+
|
|
199
|
+
Extraction sources:
|
|
200
|
+
1. Explicit "for: X, Y, Z" pattern in the task string
|
|
201
|
+
2. Capitalised words near competitor keywords in KB files
|
|
202
|
+
"""
|
|
203
|
+
competitors: set[str] = set()
|
|
204
|
+
|
|
205
|
+
# 1. Parse from task string: "for: X, Y, Z"
|
|
206
|
+
match = re.search(r"for:\s*(.+)", task, re.IGNORECASE)
|
|
207
|
+
if match:
|
|
208
|
+
names = [n.strip() for n in match.group(1).split(",") if n.strip()]
|
|
209
|
+
competitors.update(names)
|
|
210
|
+
|
|
211
|
+
# 2. Scan knowledge base for capitalised words near competitor keywords
|
|
212
|
+
for _key, path in self._kb.index.items():
|
|
213
|
+
try:
|
|
214
|
+
content = path.read_text()
|
|
215
|
+
except Exception:
|
|
216
|
+
continue
|
|
217
|
+
content_lower = content.lower()
|
|
218
|
+
for keyword in COMPETITOR_KEYWORDS:
|
|
219
|
+
if keyword in content_lower:
|
|
220
|
+
# Capitalised word AFTER keyword: "vs Mixpanel"
|
|
221
|
+
after_pattern = r"(?i:" + re.escape(keyword) + r")\s+([A-Z][a-zA-Z]+)"
|
|
222
|
+
for m in re.finditer(after_pattern, content):
|
|
223
|
+
candidate = m.group(1).strip()
|
|
224
|
+
if (
|
|
225
|
+
len(candidate) > 1
|
|
226
|
+
and candidate.lower() not in STOP_WORDS
|
|
227
|
+
and candidate[0].isupper()
|
|
228
|
+
):
|
|
229
|
+
competitors.add(candidate)
|
|
230
|
+
|
|
231
|
+
# Capitalised word BEFORE keyword: "Amplitude is an alternative"
|
|
232
|
+
before_pattern = (
|
|
233
|
+
r"([A-Z][a-zA-Z]+)\s+(?:\w+\s+)*?"
|
|
234
|
+
r"(?i:" + re.escape(keyword) + r")"
|
|
235
|
+
)
|
|
236
|
+
for m in re.finditer(before_pattern, content):
|
|
237
|
+
candidate = m.group(1).strip()
|
|
238
|
+
if (
|
|
239
|
+
len(candidate) > 1
|
|
240
|
+
and candidate.lower() not in STOP_WORDS
|
|
241
|
+
and candidate[0].isupper()
|
|
242
|
+
):
|
|
243
|
+
competitors.add(candidate)
|
|
244
|
+
|
|
245
|
+
return sorted(competitors)
|
|
246
|
+
|
|
247
|
+
# ------------------------------------------------------------------
|
|
248
|
+
# Upstream context extraction
|
|
249
|
+
# ------------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
def _extract_upstream_context(
|
|
252
|
+
self,
|
|
253
|
+
context: dict[str, Any] | None,
|
|
254
|
+
) -> dict[str, Any]:
|
|
255
|
+
"""Extract structured upstream context from SharedContext.
|
|
256
|
+
|
|
257
|
+
Looks for:
|
|
258
|
+
- echo_social.top_mentions — social chatter about competitors
|
|
259
|
+
- sage_triage.issues — community-reported issues (churn signals, comparisons)
|
|
260
|
+
"""
|
|
261
|
+
extracted: dict[str, Any] = {
|
|
262
|
+
"social_mentions": [],
|
|
263
|
+
"community_issues": [],
|
|
264
|
+
}
|
|
265
|
+
if not context:
|
|
266
|
+
return extracted
|
|
267
|
+
|
|
268
|
+
# Echo social mentions
|
|
269
|
+
if "echo_social" in context:
|
|
270
|
+
echo = context["echo_social"]
|
|
271
|
+
if isinstance(echo, dict):
|
|
272
|
+
for mention in echo.get("top_mentions", []):
|
|
273
|
+
if isinstance(mention, dict):
|
|
274
|
+
extracted["social_mentions"].append(
|
|
275
|
+
{
|
|
276
|
+
"platform": mention.get("platform", ""),
|
|
277
|
+
"title": mention.get("title", ""),
|
|
278
|
+
"sentiment": mention.get("sentiment", ""),
|
|
279
|
+
"url": mention.get("url", ""),
|
|
280
|
+
}
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
# Sage triage issues
|
|
284
|
+
if "sage_triage" in context:
|
|
285
|
+
sage = context["sage_triage"]
|
|
286
|
+
if isinstance(sage, dict):
|
|
287
|
+
for issue in sage.get("issues", [])[:10]:
|
|
288
|
+
if isinstance(issue, dict):
|
|
289
|
+
extracted["community_issues"].append(
|
|
290
|
+
{
|
|
291
|
+
"number": issue.get("number"),
|
|
292
|
+
"title": issue.get("title", ""),
|
|
293
|
+
"category": issue.get("category", ""),
|
|
294
|
+
"product_area": issue.get("product_area", ""),
|
|
295
|
+
}
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
return extracted
|
|
299
|
+
|
|
300
|
+
# ------------------------------------------------------------------
|
|
301
|
+
# Apollo enrichment
|
|
302
|
+
# ------------------------------------------------------------------
|
|
303
|
+
|
|
304
|
+
async def enrich_competitor_profile(
|
|
305
|
+
self,
|
|
306
|
+
name: str,
|
|
307
|
+
domain: str,
|
|
308
|
+
) -> dict[str, Any] | None:
|
|
309
|
+
"""Enrich a competitor with Apollo org data."""
|
|
310
|
+
if not self.apollo_client:
|
|
311
|
+
return None
|
|
312
|
+
try:
|
|
313
|
+
org = await self.apollo_client.enrich_organization(domain=domain)
|
|
314
|
+
except Exception as exc:
|
|
315
|
+
logger.warning(f"Apollo enrichment failed for {domain}: {exc}")
|
|
316
|
+
return None
|
|
317
|
+
if not org:
|
|
318
|
+
return None
|
|
319
|
+
return {
|
|
320
|
+
"name": name,
|
|
321
|
+
"domain": domain,
|
|
322
|
+
"tech_stack": org.tech_stack,
|
|
323
|
+
"estimated_headcount": org.estimated_headcount,
|
|
324
|
+
"funding_stage": org.funding_stage,
|
|
325
|
+
"funding_total": org.funding_total,
|
|
326
|
+
"industry": org.industry,
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
# ------------------------------------------------------------------
|
|
330
|
+
# Execute
|
|
331
|
+
# ------------------------------------------------------------------
|
|
332
|
+
|
|
333
|
+
async def execute(
|
|
334
|
+
self,
|
|
335
|
+
task: str,
|
|
336
|
+
context: Optional[dict[str, Any]] = None,
|
|
337
|
+
) -> dict[str, Any]:
|
|
338
|
+
"""
|
|
339
|
+
Execute a competitive intelligence task.
|
|
340
|
+
|
|
341
|
+
Steps:
|
|
342
|
+
1. Discover competitors from task string and KB
|
|
343
|
+
2. Web-search each competitor for recent activity
|
|
344
|
+
3. Search KB for competitive context
|
|
345
|
+
4. Build LLM prompt with all gathered data
|
|
346
|
+
5. Generate structured competitive report via LLM
|
|
347
|
+
|
|
348
|
+
Degrades gracefully:
|
|
349
|
+
- Without search_tools: skips web search
|
|
350
|
+
- Without llm_client: returns prompt_used instead of content
|
|
351
|
+
"""
|
|
352
|
+
logger.info(f"Rex executing: {task[:80]}...")
|
|
353
|
+
|
|
354
|
+
# 1. Discover competitors
|
|
355
|
+
competitors = self._discover_competitors(task)
|
|
356
|
+
logger.info(f"Discovered competitors: {competitors}")
|
|
357
|
+
|
|
358
|
+
# 2. Web search per competitor (semaphore-bounded parallel fan-out)
|
|
359
|
+
web_intel: dict[str, list[dict[str, str]]] = {}
|
|
360
|
+
if self.search_tools and competitors:
|
|
361
|
+
search_sem = asyncio.Semaphore(SEARCH_CONCURRENCY)
|
|
362
|
+
|
|
363
|
+
async def _search_competitor(comp: str) -> tuple[str, list[dict[str, str]]]:
|
|
364
|
+
async with search_sem:
|
|
365
|
+
try:
|
|
366
|
+
results = await self.search_tools.web_search(
|
|
367
|
+
f"{comp} vs {self.product_name}",
|
|
368
|
+
limit=5,
|
|
369
|
+
)
|
|
370
|
+
return comp, [
|
|
371
|
+
{"title": r.title, "url": r.url, "snippet": r.snippet} for r in results
|
|
372
|
+
]
|
|
373
|
+
except Exception as exc:
|
|
374
|
+
logger.warning(f"Web search failed for {comp}: {exc}")
|
|
375
|
+
return comp, []
|
|
376
|
+
|
|
377
|
+
search_results = await asyncio.gather(*[_search_competitor(c) for c in competitors])
|
|
378
|
+
web_intel = dict(search_results)
|
|
379
|
+
|
|
380
|
+
# 2b. Apollo enrichment per competitor (semaphore-bounded parallel)
|
|
381
|
+
enriched_profiles: list[dict[str, Any]] = []
|
|
382
|
+
if self.apollo_client and competitors:
|
|
383
|
+
enrich_sem = asyncio.Semaphore(SEARCH_CONCURRENCY)
|
|
384
|
+
|
|
385
|
+
async def _enrich(comp: str) -> dict[str, Any] | None:
|
|
386
|
+
async with enrich_sem:
|
|
387
|
+
return await self.enrich_competitor_profile(comp, _guess_domain(comp))
|
|
388
|
+
|
|
389
|
+
enrichment_results = await asyncio.gather(*[_enrich(c) for c in competitors])
|
|
390
|
+
enriched_profiles = [p for p in enrichment_results if p]
|
|
391
|
+
|
|
392
|
+
# 3. Search KB for competitive context
|
|
393
|
+
kb_docs = self._kb.search(task)
|
|
394
|
+
kb_context = "\n\n".join(f"[Source: {doc['source']}]\n{doc['content']}" for doc in kb_docs)
|
|
395
|
+
|
|
396
|
+
# 4. Extract upstream context
|
|
397
|
+
upstream = self._extract_upstream_context(context)
|
|
398
|
+
|
|
399
|
+
# 5. Build prompt
|
|
400
|
+
web_section = ""
|
|
401
|
+
if web_intel:
|
|
402
|
+
web_section = "## Web Intelligence\n"
|
|
403
|
+
for comp, results in web_intel.items():
|
|
404
|
+
web_section += f"\n### {comp}\n"
|
|
405
|
+
for r in results:
|
|
406
|
+
web_section += f"- [{r['title']}]({r['url']}): {r['snippet']}\n"
|
|
407
|
+
|
|
408
|
+
social_section = ""
|
|
409
|
+
if upstream["social_mentions"]:
|
|
410
|
+
social_section = "## Social Mentions (from Echo)\n"
|
|
411
|
+
for m in upstream["social_mentions"]:
|
|
412
|
+
social_section += (
|
|
413
|
+
f"- [{m['platform']}] {m['title']} (sentiment: {m['sentiment']})\n"
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
issues_section = ""
|
|
417
|
+
if upstream["community_issues"]:
|
|
418
|
+
issues_section = "## Community Issues (from Sage)\n"
|
|
419
|
+
for issue in upstream["community_issues"]:
|
|
420
|
+
issues_section += (
|
|
421
|
+
f"- #{issue['number']}: {issue['title']} [{issue.get('product_area', '')}]\n"
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
enriched_section = ""
|
|
425
|
+
if enriched_profiles:
|
|
426
|
+
enriched_section = "## Apollo Firmographic Data\n"
|
|
427
|
+
for p in enriched_profiles:
|
|
428
|
+
enriched_section += (
|
|
429
|
+
f"- {p['name']} ({p['domain']}): "
|
|
430
|
+
f"headcount={p.get('estimated_headcount', '?')}, "
|
|
431
|
+
f"funding={p.get('funding_stage', '?')}, "
|
|
432
|
+
f"tech={p.get('tech_stack', [])}\n"
|
|
433
|
+
)
|
|
434
|
+
|
|
435
|
+
user_prompt = f"""Task: {task}
|
|
436
|
+
|
|
437
|
+
## Competitors Identified
|
|
438
|
+
{", ".join(competitors) if competitors else "No competitors identified yet."}
|
|
439
|
+
|
|
440
|
+
## Knowledge Base
|
|
441
|
+
{kb_context if kb_context else "No relevant knowledge base documents found."}
|
|
442
|
+
|
|
443
|
+
{web_section}
|
|
444
|
+
|
|
445
|
+
{social_section}
|
|
446
|
+
|
|
447
|
+
{issues_section}
|
|
448
|
+
|
|
449
|
+
{enriched_section}
|
|
450
|
+
|
|
451
|
+
## Instructions
|
|
452
|
+
Produce a competitive intelligence report in JSON with this structure:
|
|
453
|
+
{{
|
|
454
|
+
"summary": "executive summary",
|
|
455
|
+
"competitors": [
|
|
456
|
+
{{
|
|
457
|
+
"name": "...",
|
|
458
|
+
"category": "direct|indirect|emerging",
|
|
459
|
+
"strengths": ["..."],
|
|
460
|
+
"weaknesses": ["..."],
|
|
461
|
+
"recent_moves": ["..."],
|
|
462
|
+
"market_position": "..."
|
|
463
|
+
}}
|
|
464
|
+
],
|
|
465
|
+
"threats": [
|
|
466
|
+
{{
|
|
467
|
+
"source": "...",
|
|
468
|
+
"description": "...",
|
|
469
|
+
"severity": "low|medium|high|critical",
|
|
470
|
+
"timeframe": "immediate|short-term|long-term",
|
|
471
|
+
"recommended_response": "..."
|
|
472
|
+
}}
|
|
473
|
+
],
|
|
474
|
+
"opportunities": [
|
|
475
|
+
{{
|
|
476
|
+
"description": "...",
|
|
477
|
+
"source": "...",
|
|
478
|
+
"impact": "low|medium|high",
|
|
479
|
+
"effort": "low|medium|high",
|
|
480
|
+
"recommended_action": "..."
|
|
481
|
+
}}
|
|
482
|
+
]
|
|
483
|
+
}}
|
|
484
|
+
|
|
485
|
+
Ground all analysis in the evidence provided above. Do not speculate.
|
|
486
|
+
Return ONLY the JSON object.
|
|
487
|
+
"""
|
|
488
|
+
|
|
489
|
+
system_prompt = self._system_prompt
|
|
490
|
+
|
|
491
|
+
base_result: dict[str, Any] = {
|
|
492
|
+
"agent": "rex",
|
|
493
|
+
"task": task,
|
|
494
|
+
"competitors_discovered": competitors,
|
|
495
|
+
"web_intel_sources": {comp: len(results) for comp, results in web_intel.items()},
|
|
496
|
+
"kb_sources": [doc["source"] for doc in kb_docs],
|
|
497
|
+
"upstream_social_mentions": len(upstream["social_mentions"]),
|
|
498
|
+
"upstream_community_issues": len(upstream["community_issues"]),
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
base_result["enriched_profiles"] = enriched_profiles
|
|
502
|
+
|
|
503
|
+
if self.llm_client:
|
|
504
|
+
try:
|
|
505
|
+
raw = await self.llm_client.generate(
|
|
506
|
+
system_prompt=system_prompt,
|
|
507
|
+
user_prompt=user_prompt,
|
|
508
|
+
temperature=0.3,
|
|
509
|
+
max_tokens=4096,
|
|
510
|
+
)
|
|
511
|
+
cleaned = strip_markdown_fences(raw)
|
|
512
|
+
try:
|
|
513
|
+
parsed = json.loads(cleaned)
|
|
514
|
+
base_result["content"] = parsed
|
|
515
|
+
base_result["status"] = "generated"
|
|
516
|
+
except json.JSONDecodeError as e:
|
|
517
|
+
logger.warning(f"Rex JSON parse failed: {e}")
|
|
518
|
+
logger.debug(f"Rex raw response head: {cleaned[:500]}")
|
|
519
|
+
base_result["status"] = "parse_error"
|
|
520
|
+
base_result["raw_content"] = cleaned
|
|
521
|
+
base_result["content"] = {}
|
|
522
|
+
base_result["error"] = f"JSON parse failed: {e}"
|
|
523
|
+
except Exception as exc:
|
|
524
|
+
logger.warning(f"LLM generation failed: {exc}")
|
|
525
|
+
base_result["status"] = "error"
|
|
526
|
+
base_result["error"] = str(exc)
|
|
527
|
+
base_result["prompt_used"] = user_prompt[:500]
|
|
528
|
+
else:
|
|
529
|
+
base_result["status"] = "generated"
|
|
530
|
+
base_result["prompt_used"] = user_prompt[:500]
|
|
531
|
+
|
|
532
|
+
return base_result
|