Spaces:
Running
Running
iDevBuddy commited on
Commit Β·
5f138d4
1
Parent(s): bd28470
feat: Add Slack Events integration, Dockerfiles, and Hugging Face deployment config
Browse files- Dockerfile +14 -0
- Dockerfile.node +15 -0
- package-lock.json +0 -0
- package.json +1 -1
- src/discovery/trigger-tasks/manual-discovery.ts +12 -3
- src/index.ts +206 -0
- src/profiling/python-service/Dockerfile.python +7 -0
- src/profiling/python-service/config.py +1 -0
- src/profiling/python-service/main.py +1 -1
- src/profiling/python-service/nvidia_client.py +12 -5
- src/profiling/python-service/profiler.py +1 -1
- src/profiling/python-service/requirements.txt +7 -8
- src/profiling/python-service/scorer.py +2 -2
- src/profiling/trigger-tasks/profiling-router.ts +72 -11
- src/shared/supabase/client.ts +0 -1
- src/slack/slack-agent.ts +319 -0
- src/slack/slack-commands.ts +4 -4
- src/slack/slack-service.ts +3 -3
- src/trigger.ts +1 -1
- trigger.config.ts +10 -0
Dockerfile
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM node:20-alpine AS builder
|
| 2 |
+
WORKDIR /app
|
| 3 |
+
COPY package*.json ./
|
| 4 |
+
RUN npm ci
|
| 5 |
+
COPY . .
|
| 6 |
+
RUN npm run build
|
| 7 |
+
|
| 8 |
+
FROM node:20-alpine
|
| 9 |
+
WORKDIR /app
|
| 10 |
+
COPY package*.json ./
|
| 11 |
+
RUN npm ci --only=production
|
| 12 |
+
COPY --from=builder /app/dist ./dist
|
| 13 |
+
EXPOSE 7860
|
| 14 |
+
CMD ["node", "dist/index.js"]
|
Dockerfile.node
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM node:20-alpine AS builder
|
| 2 |
+
WORKDIR /app
|
| 3 |
+
COPY package*.json ./
|
| 4 |
+
RUN npm ci
|
| 5 |
+
COPY . .
|
| 6 |
+
RUN npm run build
|
| 7 |
+
|
| 8 |
+
FROM node:20-alpine
|
| 9 |
+
WORKDIR /app
|
| 10 |
+
COPY package*.json ./
|
| 11 |
+
RUN npm ci --only=production
|
| 12 |
+
COPY --from=builder /app/dist ./dist
|
| 13 |
+
# If we need the .env in production, it will be loaded from env_file in docker-compose
|
| 14 |
+
EXPOSE 3000
|
| 15 |
+
CMD ["node", "dist/index.js"]
|
package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
package.json
CHANGED
|
@@ -6,7 +6,7 @@
|
|
| 6 |
"scripts": {
|
| 7 |
"build": "tsc",
|
| 8 |
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
|
| 9 |
-
"trigger:dev": "npx trigger.dev@
|
| 10 |
"typecheck": "tsc --noEmit",
|
| 11 |
"lint": "eslint . --ext .ts"
|
| 12 |
},
|
|
|
|
| 6 |
"scripts": {
|
| 7 |
"build": "tsc",
|
| 8 |
"dev": "ts-node-dev --respawn --transpile-only src/index.ts",
|
| 9 |
+
"trigger:dev": "npx trigger.dev@3.3.17 dev",
|
| 10 |
"typecheck": "tsc --noEmit",
|
| 11 |
"lint": "eslint . --ext .ts"
|
| 12 |
},
|
src/discovery/trigger-tasks/manual-discovery.ts
CHANGED
|
@@ -118,12 +118,21 @@ async function processManualCompany(
|
|
| 118 |
return "new";
|
| 119 |
}
|
| 120 |
|
| 121 |
-
const
|
| 122 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
|
| 124 |
await db.from("companies").update({ status: "profiled" }).eq("id", saved.id);
|
| 125 |
|
| 126 |
-
if (contactsSaved > 0) {
|
| 127 |
const { profilingTask } = await import("../../profiling/trigger-tasks/profiling-router");
|
| 128 |
await profilingTask.trigger({
|
| 129 |
company_id: saved.id,
|
|
|
|
| 118 |
return "new";
|
| 119 |
}
|
| 120 |
|
| 121 |
+
const contactsSaved = await enrichContacts(
|
| 122 |
+
saved.id,
|
| 123 |
+
domain,
|
| 124 |
+
normalized.name,
|
| 125 |
+
normalized.employee_count,
|
| 126 |
+
industry,
|
| 127 |
+
website.text.slice(0, 300),
|
| 128 |
+
website.html,
|
| 129 |
+
normalized.linkedin_url,
|
| 130 |
+
"manual-" + saved.id
|
| 131 |
+
);
|
| 132 |
|
| 133 |
await db.from("companies").update({ status: "profiled" }).eq("id", saved.id);
|
| 134 |
|
| 135 |
+
if (contactsSaved.length > 0) {
|
| 136 |
const { profilingTask } = await import("../../profiling/trigger-tasks/profiling-router");
|
| 137 |
await profilingTask.trigger({
|
| 138 |
company_id: saved.id,
|
src/index.ts
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import http from "http";
|
| 2 |
+
import crypto from "crypto";
|
| 3 |
+
import querystring from "querystring";
|
| 4 |
+
import { handleSlackCommand, type SlackCommand } from "./slack/slack-commands";
|
| 5 |
+
import { getEnv } from "./shared/config/env";
|
| 6 |
+
import { logger } from "./shared/utils/logger";
|
| 7 |
+
|
| 8 |
+
// Initialize environment
|
| 9 |
+
const env = getEnv();
|
| 10 |
+
const PORT = process.env.PORT ? parseInt(process.env.PORT, 10) : 3000;
|
| 11 |
+
|
| 12 |
+
/**
|
| 13 |
+
* Verify Slack request signature to ensure it comes from Slack.
|
| 14 |
+
*/
|
| 15 |
+
function verifySlackSignature(
|
| 16 |
+
timestamp: string,
|
| 17 |
+
rawBody: string,
|
| 18 |
+
signature: string,
|
| 19 |
+
signingSecret: string
|
| 20 |
+
): boolean {
|
| 21 |
+
if (!signingSecret) {
|
| 22 |
+
logger.warn("SLACK_SIGNING_SECRET is not configured. Skipping signature verification.");
|
| 23 |
+
return true;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
// Prevent replay attacks (5 minute threshold)
|
| 27 |
+
const fiveMinutesAgo = Math.floor(Date.now() / 1000) - 60 * 5;
|
| 28 |
+
if (parseInt(timestamp, 10) < fiveMinutesAgo) {
|
| 29 |
+
logger.warn("Slack request timestamp is too old. Potential replay attack.");
|
| 30 |
+
return false;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
const sigBaseString = `v0:${timestamp}:${rawBody}`;
|
| 34 |
+
const mySignature = "v0=" + crypto
|
| 35 |
+
.createHmac("sha256", signingSecret)
|
| 36 |
+
.update(sigBaseString, "utf8")
|
| 37 |
+
.digest("hex");
|
| 38 |
+
|
| 39 |
+
try {
|
| 40 |
+
return crypto.timingSafeEqual(Buffer.from(mySignature, "utf8"), Buffer.from(signature, "utf8"));
|
| 41 |
+
} catch (err) {
|
| 42 |
+
return false;
|
| 43 |
+
}
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
// Start HTTP Server
|
| 47 |
+
const server = http.createServer((req, res) => {
|
| 48 |
+
if (req.method === "POST" && req.url === "/slack/commands") {
|
| 49 |
+
let body = "";
|
| 50 |
+
|
| 51 |
+
req.on("data", (chunk) => {
|
| 52 |
+
body += chunk.toString();
|
| 53 |
+
});
|
| 54 |
+
|
| 55 |
+
req.on("end", async () => {
|
| 56 |
+
const timestamp = req.headers["x-slack-request-timestamp"] as string;
|
| 57 |
+
const signature = req.headers["x-slack-signature"] as string;
|
| 58 |
+
const signingSecret = env.SLACK_SIGNING_SECRET;
|
| 59 |
+
|
| 60 |
+
// Verify signature
|
| 61 |
+
if (signingSecret && (!timestamp || !signature || !verifySlackSignature(timestamp, body, signature, signingSecret))) {
|
| 62 |
+
logger.error("Slack signature verification failed.");
|
| 63 |
+
res.writeHead(401, { "Content-Type": "application/json" });
|
| 64 |
+
res.end(JSON.stringify({ error: "Unauthorized request signature" }));
|
| 65 |
+
return;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
try {
|
| 69 |
+
const parsed = querystring.parse(body);
|
| 70 |
+
|
| 71 |
+
const commandStr = parsed.command as string;
|
| 72 |
+
const textStr = (parsed.text as string) || "";
|
| 73 |
+
const userIdStr = parsed.user_id as string;
|
| 74 |
+
const channelIdStr = parsed.channel_id as string;
|
| 75 |
+
|
| 76 |
+
logger.info({ command: commandStr, text: textStr, userId: userIdStr }, "π¬ Received Slack command");
|
| 77 |
+
|
| 78 |
+
const cmd: SlackCommand = {
|
| 79 |
+
command: commandStr,
|
| 80 |
+
text: textStr,
|
| 81 |
+
userId: userIdStr,
|
| 82 |
+
channelId: channelIdStr,
|
| 83 |
+
};
|
| 84 |
+
|
| 85 |
+
// Call the command handler
|
| 86 |
+
const replyText = await handleSlackCommand(cmd);
|
| 87 |
+
|
| 88 |
+
// Send response back to Slack
|
| 89 |
+
res.writeHead(200, { "Content-Type": "application/json" });
|
| 90 |
+
res.end(
|
| 91 |
+
JSON.stringify({
|
| 92 |
+
response_type: "in_channel", // Make it visible in the channel
|
| 93 |
+
text: replyText,
|
| 94 |
+
})
|
| 95 |
+
);
|
| 96 |
+
} catch (err: any) {
|
| 97 |
+
logger.error({ err }, "Error processing Slack command");
|
| 98 |
+
res.writeHead(200, { "Content-Type": "application/json" });
|
| 99 |
+
res.end(
|
| 100 |
+
JSON.stringify({
|
| 101 |
+
response_type: "ephemeral",
|
| 102 |
+
text: `β Error executing command: ${err.message || err}`,
|
| 103 |
+
})
|
| 104 |
+
);
|
| 105 |
+
}
|
| 106 |
+
});
|
| 107 |
+
} else if (req.method === "POST" && req.url === "/slack/events") {
|
| 108 |
+
let body = "";
|
| 109 |
+
|
| 110 |
+
req.on("data", (chunk) => {
|
| 111 |
+
body += chunk.toString();
|
| 112 |
+
});
|
| 113 |
+
|
| 114 |
+
req.on("end", async () => {
|
| 115 |
+
const timestamp = req.headers["x-slack-request-timestamp"] as string;
|
| 116 |
+
const signature = req.headers["x-slack-signature"] as string;
|
| 117 |
+
const signingSecret = env.SLACK_SIGNING_SECRET;
|
| 118 |
+
|
| 119 |
+
// Verify signature
|
| 120 |
+
if (signingSecret && (!timestamp || !signature || !verifySlackSignature(timestamp, body, signature, signingSecret))) {
|
| 121 |
+
logger.error("Slack event signature verification failed.");
|
| 122 |
+
res.writeHead(401, { "Content-Type": "application/json" });
|
| 123 |
+
res.end(JSON.stringify({ error: "Unauthorized request signature" }));
|
| 124 |
+
return;
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
try {
|
| 128 |
+
const payload = JSON.parse(body);
|
| 129 |
+
|
| 130 |
+
// 1. Handle URL Verification Challenge from Slack
|
| 131 |
+
if (payload.type === "url_verification") {
|
| 132 |
+
logger.info("Handling Slack URL verification challenge");
|
| 133 |
+
res.writeHead(200, { "Content-Type": "application/json" });
|
| 134 |
+
res.end(JSON.stringify({ challenge: payload.challenge }));
|
| 135 |
+
return;
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
// 2. Handle Event Callback
|
| 139 |
+
if (payload.type === "event_callback") {
|
| 140 |
+
const event = payload.event;
|
| 141 |
+
if (event) {
|
| 142 |
+
// Ignore bot messages to prevent infinite loops
|
| 143 |
+
const isBot = event.bot_id || event.subtype === "bot_message" || !event.user;
|
| 144 |
+
if (isBot) {
|
| 145 |
+
res.writeHead(200);
|
| 146 |
+
res.end();
|
| 147 |
+
return;
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
const channelType = event.channel_type;
|
| 151 |
+
const eventType = event.type;
|
| 152 |
+
|
| 153 |
+
// We handle DMs (message.im or channel ID starts with 'D') or direct mentions (app_mention)
|
| 154 |
+
const isDM = eventType === "message" && (channelType === "im" || event.channel.startsWith("D"));
|
| 155 |
+
const isMention = eventType === "app_mention";
|
| 156 |
+
|
| 157 |
+
if (isDM || isMention) {
|
| 158 |
+
let text = event.text || "";
|
| 159 |
+
|
| 160 |
+
// Strip bot tag if it's a mention
|
| 161 |
+
if (isMention) {
|
| 162 |
+
text = text.replace(/<@U[A-Z0-9]+>/g, "").trim();
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
const userId = event.user;
|
| 166 |
+
const channelId = event.channel;
|
| 167 |
+
const threadTs = event.thread_ts || event.ts;
|
| 168 |
+
|
| 169 |
+
logger.info(
|
| 170 |
+
{ eventType, channelId, userId, text },
|
| 171 |
+
"π¬ Received natural language chatbot event"
|
| 172 |
+
);
|
| 173 |
+
|
| 174 |
+
// Respond HTTP 200 OK to Slack immediately to prevent retries (timeout threshold is 3s)
|
| 175 |
+
res.writeHead(200);
|
| 176 |
+
res.end();
|
| 177 |
+
|
| 178 |
+
// Route to natural language agent in background
|
| 179 |
+
const { handleSlackChat } = await import("./slack/slack-agent");
|
| 180 |
+
handleSlackChat(text, userId, channelId, threadTs).catch((err) => {
|
| 181 |
+
logger.error({ err }, "Error in handleSlackChat background worker");
|
| 182 |
+
});
|
| 183 |
+
return;
|
| 184 |
+
}
|
| 185 |
+
}
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
// Default response for unhandled events
|
| 189 |
+
res.writeHead(200);
|
| 190 |
+
res.end();
|
| 191 |
+
} catch (err: any) {
|
| 192 |
+
logger.error({ err }, "Error processing Slack event");
|
| 193 |
+
res.writeHead(500, { "Content-Type": "application/json" });
|
| 194 |
+
res.end(JSON.stringify({ error: err.message || err }));
|
| 195 |
+
}
|
| 196 |
+
});
|
| 197 |
+
} else {
|
| 198 |
+
// Healthcheck or default route
|
| 199 |
+
res.writeHead(200, { "Content-Type": "application/json" });
|
| 200 |
+
res.end(JSON.stringify({ status: "healthy", service: "slack-command-listener" }));
|
| 201 |
+
}
|
| 202 |
+
});
|
| 203 |
+
|
| 204 |
+
server.listen(PORT, "0.0.0.0", () => {
|
| 205 |
+
logger.info(`π Slack Command Listener server running on port ${PORT}`);
|
| 206 |
+
});
|
src/profiling/python-service/Dockerfile.python
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
WORKDIR /app
|
| 3 |
+
COPY requirements.txt .
|
| 4 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 5 |
+
COPY . .
|
| 6 |
+
EXPOSE 8000
|
| 7 |
+
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
src/profiling/python-service/config.py
CHANGED
|
@@ -21,5 +21,6 @@ class Settings(BaseSettings):
|
|
| 21 |
|
| 22 |
class Config:
|
| 23 |
env_file = "../../../.env"
|
|
|
|
| 24 |
|
| 25 |
settings = Settings()
|
|
|
|
| 21 |
|
| 22 |
class Config:
|
| 23 |
env_file = "../../../.env"
|
| 24 |
+
extra = "ignore"
|
| 25 |
|
| 26 |
settings = Settings()
|
src/profiling/python-service/main.py
CHANGED
|
@@ -145,4 +145,4 @@ async def profile_company(request: ProfileRequest, _auth: bool = Depends(verify_
|
|
| 145 |
|
| 146 |
if __name__ == "__main__":
|
| 147 |
import uvicorn
|
| 148 |
-
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=
|
|
|
|
| 145 |
|
| 146 |
if __name__ == "__main__":
|
| 147 |
import uvicorn
|
| 148 |
+
uvicorn.run("main:app", host="0.0.0.0", port=8000, reload=False)
|
src/profiling/python-service/nvidia_client.py
CHANGED
|
@@ -229,10 +229,15 @@ def _safe_parse_json(text: str) -> Optional[dict]:
|
|
| 229 |
|
| 230 |
async def _log_trace(trace_id, operation, model, result, success, company_id):
|
| 231 |
try:
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
"trace_id": trace_id,
|
| 237 |
"operation": operation,
|
| 238 |
"model": model,
|
|
@@ -244,7 +249,9 @@ async def _log_trace(trace_id, operation, model, result, success, company_id):
|
|
| 244 |
"success": success,
|
| 245 |
"fallback_used": result.get("fallback_used", True) if result else True,
|
| 246 |
"company_id": company_id,
|
| 247 |
-
}
|
|
|
|
|
|
|
| 248 |
except Exception as e:
|
| 249 |
logger.debug(f"Trace log failed (non-critical): {e}")
|
| 250 |
|
|
|
|
| 229 |
|
| 230 |
async def _log_trace(trace_id, operation, model, result, success, company_id):
|
| 231 |
try:
|
| 232 |
+
import httpx
|
| 233 |
+
url = f"{settings.SUPABASE_URL}/rest/v1/llm_traces"
|
| 234 |
+
headers = {
|
| 235 |
+
"apikey": settings.SUPABASE_SERVICE_ROLE_KEY,
|
| 236 |
+
"Authorization": f"Bearer {settings.SUPABASE_SERVICE_ROLE_KEY}",
|
| 237 |
+
"Content-Type": "application/json",
|
| 238 |
+
"Prefer": "return=minimal"
|
| 239 |
+
}
|
| 240 |
+
payload = {
|
| 241 |
"trace_id": trace_id,
|
| 242 |
"operation": operation,
|
| 243 |
"model": model,
|
|
|
|
| 249 |
"success": success,
|
| 250 |
"fallback_used": result.get("fallback_used", True) if result else True,
|
| 251 |
"company_id": company_id,
|
| 252 |
+
}
|
| 253 |
+
async with httpx.AsyncClient() as client:
|
| 254 |
+
await client.post(url, json=payload, headers=headers)
|
| 255 |
except Exception as e:
|
| 256 |
logger.debug(f"Trace log failed (non-critical): {e}")
|
| 257 |
|
src/profiling/python-service/profiler.py
CHANGED
|
@@ -10,7 +10,7 @@ Key differences from v1:
|
|
| 10 |
"""
|
| 11 |
|
| 12 |
import logging
|
| 13 |
-
from nvidia_client import call_with_consistency
|
| 14 |
from hallucination_guard import validate_profile_grounded
|
| 15 |
|
| 16 |
logger = logging.getLogger(__name__)
|
|
|
|
| 10 |
"""
|
| 11 |
|
| 12 |
import logging
|
| 13 |
+
from nvidia_client import call_with_consistency
|
| 14 |
from hallucination_guard import validate_profile_grounded
|
| 15 |
|
| 16 |
logger = logging.getLogger(__name__)
|
src/profiling/python-service/requirements.txt
CHANGED
|
@@ -1,8 +1,7 @@
|
|
| 1 |
-
fastapi
|
| 2 |
-
uvicorn[standard]
|
| 3 |
-
httpx
|
| 4 |
-
pydantic
|
| 5 |
-
pydantic-settings
|
| 6 |
-
python-dotenv
|
| 7 |
-
openai
|
| 8 |
-
supabase==2.4.0
|
|
|
|
| 1 |
+
fastapi
|
| 2 |
+
uvicorn[standard]
|
| 3 |
+
httpx
|
| 4 |
+
pydantic
|
| 5 |
+
pydantic-settings
|
| 6 |
+
python-dotenv
|
| 7 |
+
openai
|
|
|
src/profiling/python-service/scorer.py
CHANGED
|
@@ -12,7 +12,7 @@ So: LLM extracts signals, code does math.
|
|
| 12 |
"""
|
| 13 |
|
| 14 |
import logging
|
| 15 |
-
from nvidia_client import call_llm
|
| 16 |
|
| 17 |
logger = logging.getLogger(__name__)
|
| 18 |
|
|
@@ -113,7 +113,7 @@ async def _extract_signals(data, profile, contacts, trace_id) -> dict:
|
|
| 113 |
operation="score",
|
| 114 |
system_prompt=SYSTEM_PROMPT,
|
| 115 |
user_prompt=prompt,
|
| 116 |
-
|
| 117 |
temperature=0.1,
|
| 118 |
max_tokens=400,
|
| 119 |
json_mode=True,
|
|
|
|
| 12 |
"""
|
| 13 |
|
| 14 |
import logging
|
| 15 |
+
from nvidia_client import call_llm
|
| 16 |
|
| 17 |
logger = logging.getLogger(__name__)
|
| 18 |
|
|
|
|
| 113 |
operation="score",
|
| 114 |
system_prompt=SYSTEM_PROMPT,
|
| 115 |
user_prompt=prompt,
|
| 116 |
+
model_index=2, # 8B model β signal extraction is simple
|
| 117 |
temperature=0.1,
|
| 118 |
max_tokens=400,
|
| 119 |
json_mode=True,
|
src/profiling/trigger-tasks/profiling-router.ts
CHANGED
|
@@ -23,38 +23,96 @@ export const profilingTask = task({
|
|
| 23 |
logger.info({ company_id, domain }, "π§ Profiling started");
|
| 24 |
|
| 25 |
try {
|
| 26 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
const response = await axios.post(
|
| 28 |
`${env.PYTHON_AI_SERVICE_URL}/profile`,
|
| 29 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
{
|
| 31 |
headers: {
|
| 32 |
"Content-Type": "application/json",
|
| 33 |
-
"
|
| 34 |
},
|
| 35 |
timeout: 120_000, // 2 min timeout for LLM
|
| 36 |
}
|
| 37 |
);
|
| 38 |
|
| 39 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
logger.info(
|
| 41 |
-
{ company_id, score:
|
| 42 |
"β
Profiling complete"
|
| 43 |
);
|
| 44 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
// ββ Route based on score tier βββββββββββββββββββββββββββ
|
| 46 |
-
await routeByTier(company_id,
|
| 47 |
|
| 48 |
// ββ Audit log βββββββββββββββββββββββββββββββββββββββββββ
|
| 49 |
auditLog("lead_profiled", "company", {
|
| 50 |
company_id,
|
| 51 |
domain,
|
| 52 |
-
score:
|
| 53 |
-
tier
|
| 54 |
-
is_fallback:
|
| 55 |
});
|
| 56 |
|
| 57 |
-
return
|
| 58 |
} catch (err: unknown) {
|
| 59 |
// ββ Python service unavailable β fallback βββββββββββββββ
|
| 60 |
if (axios.isAxiosError(err) && !err.response) {
|
|
@@ -146,12 +204,15 @@ async function notifySlack(
|
|
| 146 |
|
| 147 |
const channelId = type === "review" ? env.SLACK_REVIEW_CHANNEL_ID : env.SLACK_ALERT_CHANNEL_ID;
|
| 148 |
|
| 149 |
-
await axios.post("https://slack.com/api/chat.postMessage", {
|
| 150 |
channel: channelId,
|
| 151 |
...message,
|
| 152 |
}, {
|
| 153 |
headers: { Authorization: `Bearer ${env.SLACK_BOT_TOKEN}` },
|
| 154 |
});
|
|
|
|
|
|
|
|
|
|
| 155 |
} catch (err) {
|
| 156 |
logger.warn({ err }, "Slack notification failed β non-critical");
|
| 157 |
}
|
|
|
|
| 23 |
logger.info({ company_id, domain }, "π§ Profiling started");
|
| 24 |
|
| 25 |
try {
|
| 26 |
+
// 1. Fetch company data from Supabase
|
| 27 |
+
const { data: company, error: companyErr } = await db
|
| 28 |
+
.from("companies")
|
| 29 |
+
.select("*")
|
| 30 |
+
.eq("id", company_id)
|
| 31 |
+
.single();
|
| 32 |
+
|
| 33 |
+
if (companyErr || !company) {
|
| 34 |
+
throw new Error(`Company not found: ${companyErr?.message}`);
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
// 2. Fetch contacts from Supabase
|
| 38 |
+
const { data: contacts, error: contactsErr } = await db
|
| 39 |
+
.from("contacts")
|
| 40 |
+
.select("*")
|
| 41 |
+
.eq("company_id", company_id);
|
| 42 |
+
|
| 43 |
+
if (contactsErr) {
|
| 44 |
+
throw new Error(`Failed to fetch contacts: ${contactsErr.message}`);
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
// 3. Structure payload and call Python AI Service
|
| 48 |
const response = await axios.post(
|
| 49 |
`${env.PYTHON_AI_SERVICE_URL}/profile`,
|
| 50 |
+
{
|
| 51 |
+
company: {
|
| 52 |
+
id: company.id,
|
| 53 |
+
name: company.name,
|
| 54 |
+
industry: company.industry || "",
|
| 55 |
+
employee_count: company.employee_count,
|
| 56 |
+
description: company.description || "",
|
| 57 |
+
website_text: company.description || "", // Using description as website excerpt fallback
|
| 58 |
+
linkedin_description: "",
|
| 59 |
+
tech_stack: company.tech_stack || [],
|
| 60 |
+
ai_job_count: (company.growth_signals || []).filter((s: any) => s.type === "job_post").length,
|
| 61 |
+
pain_signals: company.pain_signals || [],
|
| 62 |
+
service_match: company.service_match,
|
| 63 |
+
},
|
| 64 |
+
contacts: (contacts || []).map((c: any) => ({
|
| 65 |
+
full_name: c.full_name,
|
| 66 |
+
email: c.email,
|
| 67 |
+
email_verified: c.email_verified,
|
| 68 |
+
linkedin_personal_url: c.linkedin_personal_url || c.linkedin_url,
|
| 69 |
+
social_profiles: c.social_profiles || {},
|
| 70 |
+
})),
|
| 71 |
+
trace_id: company.trace_id || `trace-${company_id.slice(0, 8)}`,
|
| 72 |
+
},
|
| 73 |
{
|
| 74 |
headers: {
|
| 75 |
"Content-Type": "application/json",
|
| 76 |
+
"Authorization": `Bearer ${env.PYTHON_AI_SERVICE_SECRET}`,
|
| 77 |
},
|
| 78 |
timeout: 120_000, // 2 min timeout for LLM
|
| 79 |
}
|
| 80 |
);
|
| 81 |
|
| 82 |
+
const { profile, score, validation, meta } = response.data;
|
| 83 |
+
const totalScore = score?.total_score ?? 0;
|
| 84 |
+
const tier = score?.tier ?? "archive";
|
| 85 |
+
const needs_human_review = !(validation?.score_valid ?? true);
|
| 86 |
+
|
| 87 |
logger.info(
|
| 88 |
+
{ company_id, score: totalScore, tier },
|
| 89 |
"β
Profiling complete"
|
| 90 |
);
|
| 91 |
|
| 92 |
+
// Save profile and score in DB
|
| 93 |
+
await db.from("lead_profiles").upsert({
|
| 94 |
+
company_id,
|
| 95 |
+
...profile,
|
| 96 |
+
}, { onConflict: "company_id" });
|
| 97 |
+
|
| 98 |
+
await db.from("lead_scores").upsert({
|
| 99 |
+
company_id,
|
| 100 |
+
...score,
|
| 101 |
+
}, { onConflict: "company_id" });
|
| 102 |
+
|
| 103 |
// ββ Route based on score tier βββββββββββββββββββββββββββ
|
| 104 |
+
await routeByTier(company_id, { total_score: totalScore, tier, needs_human_review }, db, env);
|
| 105 |
|
| 106 |
// ββ Audit log βββββββββββββββββββββββββββββββββββββββββββ
|
| 107 |
auditLog("lead_profiled", "company", {
|
| 108 |
company_id,
|
| 109 |
domain,
|
| 110 |
+
score: totalScore,
|
| 111 |
+
tier,
|
| 112 |
+
is_fallback: meta?.is_fallback ?? false,
|
| 113 |
});
|
| 114 |
|
| 115 |
+
return response.data;
|
| 116 |
} catch (err: unknown) {
|
| 117 |
// ββ Python service unavailable β fallback βββββββββββββββ
|
| 118 |
if (axios.isAxiosError(err) && !err.response) {
|
|
|
|
| 204 |
|
| 205 |
const channelId = type === "review" ? env.SLACK_REVIEW_CHANNEL_ID : env.SLACK_ALERT_CHANNEL_ID;
|
| 206 |
|
| 207 |
+
const res = await axios.post("https://slack.com/api/chat.postMessage", {
|
| 208 |
channel: channelId,
|
| 209 |
...message,
|
| 210 |
}, {
|
| 211 |
headers: { Authorization: `Bearer ${env.SLACK_BOT_TOKEN}` },
|
| 212 |
});
|
| 213 |
+
if (res.data && res.data.ok === false) {
|
| 214 |
+
logger.warn({ error: res.data.error }, "Slack API responded with error. Make sure to invite the bot to the channel!");
|
| 215 |
+
}
|
| 216 |
} catch (err) {
|
| 217 |
logger.warn({ err }, "Slack notification failed β non-critical");
|
| 218 |
}
|
src/shared/supabase/client.ts
CHANGED
|
@@ -8,7 +8,6 @@ export function getSupabaseClient() {
|
|
| 8 |
const env = getEnv();
|
| 9 |
_client = createClient(env.SUPABASE_URL, env.SUPABASE_SERVICE_ROLE_KEY, {
|
| 10 |
auth: { persistSession: false },
|
| 11 |
-
db: { schema: "public" },
|
| 12 |
});
|
| 13 |
}
|
| 14 |
return _client;
|
|
|
|
| 8 |
const env = getEnv();
|
| 9 |
_client = createClient(env.SUPABASE_URL, env.SUPABASE_SERVICE_ROLE_KEY, {
|
| 10 |
auth: { persistSession: false },
|
|
|
|
| 11 |
});
|
| 12 |
}
|
| 13 |
return _client;
|
src/slack/slack-agent.ts
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { callLLM, MODELS } from "../shared/llm/nvidia-client";
|
| 2 |
+
import { getSupabaseClient } from "../shared/supabase/client";
|
| 3 |
+
import { manualDiscoveryTask } from "../discovery/trigger-tasks/manual-discovery";
|
| 4 |
+
import { logger } from "../shared/utils/logger";
|
| 5 |
+
import axios from "axios";
|
| 6 |
+
import { getEnv } from "../shared/config/env";
|
| 7 |
+
|
| 8 |
+
const env = getEnv();
|
| 9 |
+
|
| 10 |
+
// Helper to post messages back to Slack
|
| 11 |
+
async function replyToSlack(channelId: string, text: string, threadTs?: string): Promise<void> {
|
| 12 |
+
try {
|
| 13 |
+
await axios.post(
|
| 14 |
+
"https://slack.com/api/chat.postMessage",
|
| 15 |
+
{
|
| 16 |
+
channel: channelId,
|
| 17 |
+
text,
|
| 18 |
+
thread_ts: threadTs,
|
| 19 |
+
},
|
| 20 |
+
{
|
| 21 |
+
headers: { Authorization: `Bearer ${env.SLACK_BOT_TOKEN}` },
|
| 22 |
+
timeout: 5000,
|
| 23 |
+
}
|
| 24 |
+
);
|
| 25 |
+
} catch (err) {
|
| 26 |
+
logger.error({ err }, "Failed to reply to Slack");
|
| 27 |
+
}
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
// βββ Intent System Prompt ββββββββββββββββββββββββββββββββββββββ
|
| 31 |
+
|
| 32 |
+
const AGENT_SYSTEM_PROMPT = `You are "Lead Finder AI", an intelligent Slack Chatbot assistant.
|
| 33 |
+
Your job is to parse the user's natural language request (in English, Urdu, or Roman Urdu) and map it to a structured action.
|
| 34 |
+
|
| 35 |
+
You must respond ONLY with a valid JSON object matching this schema:
|
| 36 |
+
{
|
| 37 |
+
"intent": "discover" | "leads" | "lead_detail" | "status" | "pause" | "resume" | "quota" | "chat",
|
| 38 |
+
"params": {
|
| 39 |
+
"region": "US" | "UK" | "AU" | "UAE" | "SA" | "SG" (optional),
|
| 40 |
+
"industry": string (optional),
|
| 41 |
+
"maxCompanies": number (optional),
|
| 42 |
+
"companyName": string (optional),
|
| 43 |
+
"quotaAmount": number (optional),
|
| 44 |
+
"quotaPermanent": boolean (optional)
|
| 45 |
+
},
|
| 46 |
+
"explanation": "A very brief, friendly sentence in Roman Urdu explaining what you understood and what you are doing (e.g. 'Ji bilkul! Main abhi US ke dental leads dhoondta hoon.' or 'Bilkul, ye rahi aaj ki leads summary:')"
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
Intents mapping rules:
|
| 50 |
+
1. "discover": Manual trigger of search/enrichment/scoring.
|
| 51 |
+
- Example: "aj US me SaaS leads dhoondo", "manual run UK dental", "dental leads US", "UK clinical leads nikal do", "discover dental UAE"
|
| 52 |
+
- Default region to US if not specified. Default maxCompanies to 10 if not specified.
|
| 53 |
+
2. "leads": Today's qualified leads list.
|
| 54 |
+
- Example: "aj ki leads dikhao", "show today's leads", "aj kya mila?", "today leads summary", "leads"
|
| 55 |
+
3. "lead_detail": Profile/score details about a specific company.
|
| 56 |
+
- Example: "clickup ki details do", "show lead clickup", "clickup ka batao", "lead detail of Google"
|
| 57 |
+
- Extract company name into companyName.
|
| 58 |
+
4. "status": System config status (quota, pause/run mode, coordinates).
|
| 59 |
+
- Example: "status batao", "system kaisa chal raha?", "running status", "check status"
|
| 60 |
+
5. "pause": Pauses automatic CRON daily runs.
|
| 61 |
+
- Example: "pause system", "automatic run rok do", "pause auto mode", "stop runs"
|
| 62 |
+
6. "resume": Resumes automatic CRON daily runs.
|
| 63 |
+
- Example: "resume system", "auto runs start kar do", "resume run"
|
| 64 |
+
7. "quota": Changes daily lead quota.
|
| 65 |
+
- Example: "quota 15 kar do", "set today's quota to 20", "set permanent quota to 50"
|
| 66 |
+
- Extract quotaAmount and quotaPermanent.
|
| 67 |
+
8. "chat": General greetings, small talk, questions about how you work, or how to use you.
|
| 68 |
+
- Example: "hello", "hi", "tum kon ho?", "how do you work?", "help me"
|
| 69 |
+
|
| 70 |
+
Respond ONLY with raw JSON. Do not include markdown code block syntax or explanation outside JSON.`;
|
| 71 |
+
|
| 72 |
+
// βββ Main Chatbot handler ββββββββββββββββββββββββββββββββββββββ
|
| 73 |
+
|
| 74 |
+
export async function handleSlackChat(
|
| 75 |
+
userText: string,
|
| 76 |
+
userId: string,
|
| 77 |
+
channelId: string,
|
| 78 |
+
threadTs?: string
|
| 79 |
+
): Promise<void> {
|
| 80 |
+
const cleanText = userText.trim();
|
| 81 |
+
logger.info({ userId, cleanText }, "π€ AI Chatbot processing message");
|
| 82 |
+
|
| 83 |
+
// Call the LLM to parse the intent
|
| 84 |
+
const llmRes = await callLLM({
|
| 85 |
+
operation: "slack_intent_classification",
|
| 86 |
+
modelIndex: MODELS.LLAMA_70B, // Use LLaMA 70B for highly accurate intent mapping
|
| 87 |
+
systemPrompt: AGENT_SYSTEM_PROMPT,
|
| 88 |
+
userPrompt: cleanText,
|
| 89 |
+
jsonMode: true,
|
| 90 |
+
traceId: `slack-chat-${Date.now()}`,
|
| 91 |
+
});
|
| 92 |
+
|
| 93 |
+
if (!llmRes.parsed) {
|
| 94 |
+
await replyToSlack(
|
| 95 |
+
channelId,
|
| 96 |
+
"Maaf kijiye ga, mujhe aap ki baat samajh nahi aayi. Kya aap dobara keh sakte hain? (Greetings, /discover, /leads waghaira ke liye pooch sakte hain)",
|
| 97 |
+
threadTs
|
| 98 |
+
);
|
| 99 |
+
return;
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
const { intent, params, explanation } = llmRes.parsed as {
|
| 103 |
+
intent: string;
|
| 104 |
+
params: any;
|
| 105 |
+
explanation: string;
|
| 106 |
+
};
|
| 107 |
+
|
| 108 |
+
logger.info({ intent, params }, "π― Decoded intent");
|
| 109 |
+
|
| 110 |
+
// Respond with explanation first so user knows we are on it
|
| 111 |
+
if (explanation && intent !== "chat") {
|
| 112 |
+
await replyToSlack(channelId, `π¬ *Lead Finder AI:* ${explanation}`, threadTs);
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
const db = getSupabaseClient();
|
| 116 |
+
|
| 117 |
+
try {
|
| 118 |
+
switch (intent) {
|
| 119 |
+
case "discover": {
|
| 120 |
+
const region = params?.region || "US";
|
| 121 |
+
const industry = params?.industry || "SaaS";
|
| 122 |
+
const maxCompanies = params?.maxCompanies || 10;
|
| 123 |
+
|
| 124 |
+
await manualDiscoveryTask.trigger({
|
| 125 |
+
region: region.toUpperCase(),
|
| 126 |
+
industry,
|
| 127 |
+
maxCompanies,
|
| 128 |
+
triggeredBy: `slack-chat:${userId}`,
|
| 129 |
+
});
|
| 130 |
+
|
| 131 |
+
await replyToSlack(
|
| 132 |
+
channelId,
|
| 133 |
+
`π **Manual Discovery Triggered!**\nβ’ *Region:* ${region.toUpperCase()}\nβ’ *Industry:* ${industry}\nβ’ *Max Leads:* ${maxCompanies}\n\nJaise hi leads ready honge, main isi channel me card deliver kar dunga!`,
|
| 134 |
+
threadTs
|
| 135 |
+
);
|
| 136 |
+
break;
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
case "leads": {
|
| 140 |
+
const today = new Date();
|
| 141 |
+
today.setHours(0, 0, 0, 0);
|
| 142 |
+
|
| 143 |
+
const { data: leads } = await db
|
| 144 |
+
.from("lead_scores")
|
| 145 |
+
.select(`
|
| 146 |
+
total_score, tier,
|
| 147 |
+
companies (name, domain, industry, city, service_match),
|
| 148 |
+
contacts (full_name, email, email_verified, linkedin_personal_url)
|
| 149 |
+
`)
|
| 150 |
+
.gte("created_at", today.toISOString())
|
| 151 |
+
.order("total_score", { ascending: false });
|
| 152 |
+
|
| 153 |
+
if (!leads?.length) {
|
| 154 |
+
await replyToSlack(channelId, "π Aaj ke din abhi tak koi leads qualified nahi huin.", threadTs);
|
| 155 |
+
break;
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
const lines = leads.map((l: any, i: number) => {
|
| 159 |
+
const emoji = l.tier === "hot" ? "π₯" : l.tier === "warm" ? "β
" : "π";
|
| 160 |
+
const email = l.contacts?.email_verified ? "π§β" : l.contacts?.email ? "π§" : "β";
|
| 161 |
+
const li = l.contacts?.linkedin_personal_url ? "πΌ" : "β";
|
| 162 |
+
return `${emoji} *${l.total_score}* | *${l.companies?.name ?? "?"}* | ${l.companies?.industry ?? "?"} | ${l.companies?.city ?? "?"} | ${email} ${li}`;
|
| 163 |
+
});
|
| 164 |
+
|
| 165 |
+
const reply = `*Today's Leads Summary (${leads.length}):*\n\n` +
|
| 166 |
+
`Score | Company | Industry | City | Channels\n` +
|
| 167 |
+
`β`.repeat(40) + `\n` +
|
| 168 |
+
lines.join("\n") +
|
| 169 |
+
`\n\nAap kisi bhi company ki details pooch sakte hain (e.g. "ClickUp ki details do").`;
|
| 170 |
+
|
| 171 |
+
await replyToSlack(channelId, reply, threadTs);
|
| 172 |
+
break;
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
case "lead_detail": {
|
| 176 |
+
const companySearch = params?.companyName || "";
|
| 177 |
+
if (!companySearch) {
|
| 178 |
+
await replyToSlack(channelId, "Mujhe company ka naam batayein taake main detail nikal sakoon.", threadTs);
|
| 179 |
+
break;
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
const { data: companies } = await db
|
| 183 |
+
.from("companies")
|
| 184 |
+
.select("*")
|
| 185 |
+
.ilike("name", `%${companySearch.trim()}%`)
|
| 186 |
+
.limit(1);
|
| 187 |
+
|
| 188 |
+
if (!companies?.length) {
|
| 189 |
+
await replyToSlack(channelId, `β Mujhe "${companySearch}" naam ki koi company database me nahi mili.`, threadTs);
|
| 190 |
+
break;
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
const company = companies[0];
|
| 194 |
+
const { data: contacts } = await db.from("contacts").select("*").eq("company_id", company.id);
|
| 195 |
+
const { data: scores } = await db.from("lead_scores").select("*").eq("company_id", company.id).limit(1);
|
| 196 |
+
const { data: profiles } = await db.from("lead_profiles").select("*").eq("company_id", company.id).limit(1);
|
| 197 |
+
|
| 198 |
+
const score = scores?.[0];
|
| 199 |
+
const profile = profiles?.[0];
|
| 200 |
+
const contact = contacts?.[0];
|
| 201 |
+
|
| 202 |
+
const channels: string[] = [];
|
| 203 |
+
if (contact?.email) channels.push(`π§ ${contact.email} ${contact.email_verified ? "β" : "(unverified)"}`);
|
| 204 |
+
if (contact?.linkedin_personal_url) channels.push(`πΌ <${contact.linkedin_personal_url}|LinkedIn>`);
|
| 205 |
+
|
| 206 |
+
const responseText = `*π’ ${company.name}* (Domain: ${company.domain})\n` +
|
| 207 |
+
`β’ *Location:* ${company.city ?? "?"}, ${company.country ?? "?"}\n` +
|
| 208 |
+
`β’ *Industry:* ${company.industry ?? "?"} Β· *Employees:* ${company.employee_count ?? "?"}\n` +
|
| 209 |
+
`β’ *Service Match:* ${company.service_match ?? "β"}\n\n` +
|
| 210 |
+
`*π AI Scoring: ${score?.total_score ?? "?"}/100 β ${score?.tier?.toUpperCase() ?? "?"}*\n` +
|
| 211 |
+
` - Fit: ${score?.company_fit ?? "?"}/25 Β· AI Readiness: ${score?.ai_readiness ?? "?"}/20\n` +
|
| 212 |
+
` - Service Fit: ${score?.service_match_score ?? "?"}/20 Β· Contact: ${score?.decision_maker ?? "?"}/20\n\n` +
|
| 213 |
+
`*π§ Profile Summary:*\n_${profile?.profile_summary ?? "No profile summary available."}_\n\n` +
|
| 214 |
+
`*π― Personalized Outreach Angle:*\n_"${profile?.outreach_angle ?? "β"}"_\n\n` +
|
| 215 |
+
`*π€ Decision Maker:* ${contact?.full_name ?? "?"} (${contact?.title ?? "?"})\n` +
|
| 216 |
+
` - Channels: ${channels.join(" | ") || "None found"}`;
|
| 217 |
+
|
| 218 |
+
await replyToSlack(channelId, responseText, threadTs);
|
| 219 |
+
break;
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
case "status": {
|
| 223 |
+
const { data: autoConfig } = await db.from("system_config").select("value").eq("key", "auto_mode").single();
|
| 224 |
+
const paused = autoConfig?.value?.paused ?? false;
|
| 225 |
+
|
| 226 |
+
const { data: quotaConfig } = await db.from("system_config").select("value").eq("key", "daily_quota").single();
|
| 227 |
+
const quota = quotaConfig?.value;
|
| 228 |
+
|
| 229 |
+
const { data: territory } = await db.from("system_config").select("value").eq("key", "current_territory").single();
|
| 230 |
+
const pos = territory?.value;
|
| 231 |
+
|
| 232 |
+
const { data: todayRuns } = await db
|
| 233 |
+
.from("discovery_runs")
|
| 234 |
+
.select("status, leads_qualified")
|
| 235 |
+
.gte("ran_at", new Date(new Date().setHours(0, 0, 0, 0)).toISOString());
|
| 236 |
+
|
| 237 |
+
const todayLeads = todayRuns?.reduce((sum: number, r: any) => sum + (r.leads_qualified ?? 0), 0) ?? 0;
|
| 238 |
+
|
| 239 |
+
const reply = `βοΈ **System Status Report**\n` +
|
| 240 |
+
`β’ *Auto Runs Status:* ${paused ? "βΈοΈ PAUSED" : "βΆοΈ RUNNING"}\n` +
|
| 241 |
+
`β’ *Daily Quota:* ${(quota as any)?.today_override ?? (quota as any)?.default ?? 10} leads/day\n` +
|
| 242 |
+
`β’ *Qualified Today:* ${todayLeads} leads\n` +
|
| 243 |
+
`β’ *Active Territory:* ${(pos as any)?.countryCode ?? "?"} city#${(pos as any)?.cityIndex ?? 0}\n` +
|
| 244 |
+
`β’ *Runs Executed Today:* ${todayRuns?.length ?? 0}`;
|
| 245 |
+
|
| 246 |
+
await replyToSlack(channelId, reply, threadTs);
|
| 247 |
+
break;
|
| 248 |
+
}
|
| 249 |
+
|
| 250 |
+
case "pause": {
|
| 251 |
+
await db.from("system_config").update({
|
| 252 |
+
value: { enabled: true, paused: true, paused_by: "slack-chat" },
|
| 253 |
+
updated_at: new Date().toISOString(),
|
| 254 |
+
}).eq("key", "auto_mode");
|
| 255 |
+
|
| 256 |
+
await replyToSlack(channelId, "βΈοΈ **System Paused!** Daily automatic runs ko rok diya gaya hai. Jab tak aap resume nahi karenge, automatic process nahi chale ga.", threadTs);
|
| 257 |
+
break;
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
case "resume": {
|
| 261 |
+
await db.from("system_config").update({
|
| 262 |
+
value: { enabled: true, paused: false, paused_by: null },
|
| 263 |
+
updated_at: new Date().toISOString(),
|
| 264 |
+
}).eq("key", "auto_mode");
|
| 265 |
+
|
| 266 |
+
await replyToSlack(channelId, "βΆοΈ **System Resumed!** Automatic runs dubara schedule par start ho gayi hain.", threadTs);
|
| 267 |
+
break;
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
case "quota": {
|
| 271 |
+
const num = params?.quotaAmount;
|
| 272 |
+
if (!num || isNaN(num) || num < 1 || num > 100) {
|
| 273 |
+
await replyToSlack(channelId, "Usage: 'quota 15 kar do' (max 100)", threadTs);
|
| 274 |
+
break;
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
const permanent = !!params?.quotaPermanent;
|
| 278 |
+
const key = "daily_quota";
|
| 279 |
+
const { data: config } = await db.from("system_config").select("value").eq("key", key).single();
|
| 280 |
+
const val = config?.value || { default: 10, today_override: null };
|
| 281 |
+
|
| 282 |
+
if (permanent) {
|
| 283 |
+
val.default = num;
|
| 284 |
+
val.today_override = null;
|
| 285 |
+
} else {
|
| 286 |
+
val.today_override = num;
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
await db.from("system_config").update({
|
| 290 |
+
value: val,
|
| 291 |
+
updated_at: new Date().toISOString()
|
| 292 |
+
}).eq("key", key);
|
| 293 |
+
|
| 294 |
+
await replyToSlack(
|
| 295 |
+
channelId,
|
| 296 |
+
permanent
|
| 297 |
+
? `β
**Daily Quota permanently set to ${num} leads/day!**`
|
| 298 |
+
: `β
**Today's Quota set to ${num} leads!** Kal automatic default par wapas chala jaye ga.`,
|
| 299 |
+
threadTs
|
| 300 |
+
);
|
| 301 |
+
break;
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
case "chat": {
|
| 305 |
+
// Chatbot replies using LLM in Roman Urdu directly
|
| 306 |
+
await replyToSlack(channelId, `π¬ ${explanation}`, threadTs);
|
| 307 |
+
break;
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
default: {
|
| 311 |
+
await replyToSlack(channelId, "Mujhe aap ka naya command samajh nahi aaya. Kya aap explain kar sakte hain?", threadTs);
|
| 312 |
+
}
|
| 313 |
+
}
|
| 314 |
+
logger.info({ intent }, "π€ Chatbot action completed successfully");
|
| 315 |
+
} catch (err: any) {
|
| 316 |
+
logger.error({ err, intent }, "Error executing Slack AI Chatbot action");
|
| 317 |
+
await replyToSlack(channelId, `β Action fail ho gaya: ${err.message || err}`, threadTs);
|
| 318 |
+
}
|
| 319 |
+
}
|
src/slack/slack-commands.ts
CHANGED
|
@@ -16,10 +16,10 @@
|
|
| 16 |
* /quota [number] always β permanent change
|
| 17 |
*/
|
| 18 |
|
| 19 |
-
import { getSupabaseClient } from "../
|
| 20 |
-
import { setQuotaOverride, isSystemPaused } from "../
|
| 21 |
import { sendClarifyingQuestions } from "./slack-service";
|
| 22 |
-
import { logger } from "../
|
| 23 |
|
| 24 |
export interface SlackCommand {
|
| 25 |
command: string;
|
|
@@ -63,7 +63,7 @@ async function handleDiscover(args: string, cmd: SlackCommand): Promise<string>
|
|
| 63 |
|
| 64 |
if (params.region && params.industry) {
|
| 65 |
// Direct run β no questions needed
|
| 66 |
-
const { manualDiscoveryTask } = await import("../
|
| 67 |
await manualDiscoveryTask.trigger({
|
| 68 |
region: params.region.toUpperCase(),
|
| 69 |
industry: params.industry,
|
|
|
|
| 16 |
* /quota [number] always β permanent change
|
| 17 |
*/
|
| 18 |
|
| 19 |
+
import { getSupabaseClient } from "../shared/supabase/client";
|
| 20 |
+
import { setQuotaOverride, isSystemPaused } from "../discovery/lib/territory-manager";
|
| 21 |
import { sendClarifyingQuestions } from "./slack-service";
|
| 22 |
+
import { logger } from "../shared/utils/logger";
|
| 23 |
|
| 24 |
export interface SlackCommand {
|
| 25 |
command: string;
|
|
|
|
| 63 |
|
| 64 |
if (params.region && params.industry) {
|
| 65 |
// Direct run β no questions needed
|
| 66 |
+
const { manualDiscoveryTask } = await import("../discovery/trigger-tasks/manual-discovery");
|
| 67 |
await manualDiscoveryTask.trigger({
|
| 68 |
region: params.region.toUpperCase(),
|
| 69 |
industry: params.industry,
|
src/slack/slack-service.ts
CHANGED
|
@@ -9,9 +9,9 @@
|
|
| 9 |
*/
|
| 10 |
|
| 11 |
import axios from "axios";
|
| 12 |
-
import { getEnv } from "../
|
| 13 |
-
import { getSupabaseClient } from "../
|
| 14 |
-
import { logger } from "../
|
| 15 |
|
| 16 |
// βββ Slack API helper ββββββββββββββββββββββββββββββββββββββββ
|
| 17 |
|
|
|
|
| 9 |
*/
|
| 10 |
|
| 11 |
import axios from "axios";
|
| 12 |
+
import { getEnv } from "../shared/config/env";
|
| 13 |
+
import { getSupabaseClient } from "../shared/supabase/client";
|
| 14 |
+
import { logger } from "../shared/utils/logger";
|
| 15 |
|
| 16 |
// βββ Slack API helper ββββββββββββββββββββββββββββββββββββββββ
|
| 17 |
|
src/trigger.ts
CHANGED
|
@@ -3,6 +3,6 @@
|
|
| 3 |
* This file must export all tasks for Trigger.dev to discover them.
|
| 4 |
*/
|
| 5 |
|
| 6 |
-
export {
|
| 7 |
export { manualDiscoveryTask } from "./discovery/trigger-tasks/manual-discovery";
|
| 8 |
export { profilingTask } from "./profiling/trigger-tasks/profiling-router";
|
|
|
|
| 3 |
* This file must export all tasks for Trigger.dev to discover them.
|
| 4 |
*/
|
| 5 |
|
| 6 |
+
export { dailyScheduler, dailyDigestTask } from "./discovery/trigger-tasks/auto-discovery";
|
| 7 |
export { manualDiscoveryTask } from "./discovery/trigger-tasks/manual-discovery";
|
| 8 |
export { profilingTask } from "./profiling/trigger-tasks/profiling-router";
|
trigger.config.ts
CHANGED
|
@@ -1,7 +1,15 @@
|
|
| 1 |
import type { TriggerConfig } from "@trigger.dev/sdk/v3";
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
export const config: TriggerConfig = {
|
| 4 |
project: process.env.TRIGGER_DEV_PROJECT_ID!,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
retries: {
|
| 6 |
enabledInDev: true,
|
| 7 |
default: {
|
|
@@ -13,3 +21,5 @@ export const config: TriggerConfig = {
|
|
| 13 |
},
|
| 14 |
dirs: ["./src/discovery/trigger-tasks"],
|
| 15 |
};
|
|
|
|
|
|
|
|
|
| 1 |
import type { TriggerConfig } from "@trigger.dev/sdk/v3";
|
| 2 |
+
import dotenv from "dotenv";
|
| 3 |
+
|
| 4 |
+
// Load environment variables from .env
|
| 5 |
+
dotenv.config();
|
| 6 |
|
| 7 |
export const config: TriggerConfig = {
|
| 8 |
project: process.env.TRIGGER_DEV_PROJECT_ID!,
|
| 9 |
+
maxDuration: 3600, // 1 hour max default duration for tasks
|
| 10 |
+
build: {
|
| 11 |
+
external: ["playwright", "playwright-core"],
|
| 12 |
+
},
|
| 13 |
retries: {
|
| 14 |
enabledInDev: true,
|
| 15 |
default: {
|
|
|
|
| 21 |
},
|
| 22 |
dirs: ["./src/discovery/trigger-tasks"],
|
| 23 |
};
|
| 24 |
+
|
| 25 |
+
|