Spaces:
Sleeping
Sleeping
Commit ·
5bcd5ab
0
Parent(s):
Replace main with mobile-friendly ProspectIQ codebase
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +13 -0
- .gitattributes +1 -0
- .github/workflows/deploy-azure.yml +60 -0
- .gitignore +15 -0
- ASHWIN.txt +666 -0
- CONFLICT_ANALYSIS_REPORT.txt +331 -0
- DEPLOYMENT_AZURE.md +274 -0
- DEPLOYMENT_HUGGINGFACE.md +82 -0
- Dockerfile +37 -0
- JAI.txt +207 -0
- README.md +26 -0
- app.py +54 -0
- celery_app.py +21 -0
- db.py +689 -0
- docker-compose.yml +50 -0
- documentation.md +666 -0
- learning_data/collections_meta.json +1 -0
- migrate.py +214 -0
- render.yaml +90 -0
- requirements.txt +147 -0
- routes/__init__.py +1 -0
- routes/ai.py +912 -0
- routes/auth.py +615 -0
- routes/command.py +1246 -0
- routes/companies.py +942 -0
- routes/contacts.py +135 -0
- routes/learning.py +224 -0
- routes/stt.py +24 -0
- routes/system.py +518 -0
- schema.sql +187 -0
- scratch_check.py +25 -0
- scratch_check_app.py +26 -0
- scripts/deploy-azure.ps1 +49 -0
- services/__init__.py +1 -0
- services/ai_service.py +998 -0
- services/export_service.py +1317 -0
- services/geo_search.py +811 -0
- services/ocr.py +180 -0
- services/rag_service.py +975 -0
- services/research/__init__.py +1 -0
- services/research/analyzer.py +568 -0
- services/research/browser.py +275 -0
- services/research/company.md +27 -0
- services/research/pipeline.py +286 -0
- services/research/query_generator.py +358 -0
- services/research/questionlist.md +20 -0
- services/tool_router.py +199 -0
- settings/base_company_context.md +26 -0
- start-hf-space.sh +26 -0
- static/js/api.js +109 -0
.dockerignore
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.git
|
| 2 |
+
.agents
|
| 3 |
+
.venv
|
| 4 |
+
__pycache__
|
| 5 |
+
*.py[cod]
|
| 6 |
+
.env
|
| 7 |
+
.env.*
|
| 8 |
+
*.db
|
| 9 |
+
*.db.bak
|
| 10 |
+
learning_data/uploads
|
| 11 |
+
learning_data/converted
|
| 12 |
+
services/research/pipe_output
|
| 13 |
+
docs/images
|
.gitattributes
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
*.sh text eol=lf
|
.github/workflows/deploy-azure.yml
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Deploy to Azure Container Apps
|
| 2 |
+
|
| 3 |
+
on:
|
| 4 |
+
workflow_dispatch:
|
| 5 |
+
|
| 6 |
+
env:
|
| 7 |
+
AZURE_RESOURCE_GROUP: ProspectIQ_centralindia
|
| 8 |
+
AZURE_CONTAINER_APP: prospectiq
|
| 9 |
+
ACR_LOGIN_SERVER: prospectiqacr57421.azurecr.io
|
| 10 |
+
IMAGE_NAME: prospectiq
|
| 11 |
+
|
| 12 |
+
jobs:
|
| 13 |
+
deploy:
|
| 14 |
+
runs-on: ubuntu-latest
|
| 15 |
+
|
| 16 |
+
steps:
|
| 17 |
+
- name: Check out repository
|
| 18 |
+
uses: actions/checkout@v4
|
| 19 |
+
|
| 20 |
+
- name: Set image tags
|
| 21 |
+
id: image
|
| 22 |
+
shell: bash
|
| 23 |
+
run: |
|
| 24 |
+
echo "latest=${ACR_LOGIN_SERVER}/${IMAGE_NAME}:latest" >> "$GITHUB_OUTPUT"
|
| 25 |
+
echo "sha=${ACR_LOGIN_SERVER}/${IMAGE_NAME}:${GITHUB_SHA}" >> "$GITHUB_OUTPUT"
|
| 26 |
+
|
| 27 |
+
- name: Log in to Azure Container Registry
|
| 28 |
+
uses: azure/docker-login@v2
|
| 29 |
+
with:
|
| 30 |
+
login-server: ${{ env.ACR_LOGIN_SERVER }}
|
| 31 |
+
username: ${{ secrets.ACR_USERNAME }}
|
| 32 |
+
password: ${{ secrets.ACR_PASSWORD }}
|
| 33 |
+
|
| 34 |
+
- name: Set up Docker Buildx
|
| 35 |
+
uses: docker/setup-buildx-action@v3
|
| 36 |
+
|
| 37 |
+
- name: Build and push image
|
| 38 |
+
uses: docker/build-push-action@v6
|
| 39 |
+
with:
|
| 40 |
+
context: .
|
| 41 |
+
push: true
|
| 42 |
+
tags: |
|
| 43 |
+
${{ steps.image.outputs.latest }}
|
| 44 |
+
${{ steps.image.outputs.sha }}
|
| 45 |
+
cache-from: type=gha
|
| 46 |
+
cache-to: type=gha,mode=max
|
| 47 |
+
|
| 48 |
+
- name: Log in to Azure
|
| 49 |
+
uses: azure/login@v2
|
| 50 |
+
with:
|
| 51 |
+
creds: ${{ secrets.AZURE_CREDENTIALS }}
|
| 52 |
+
|
| 53 |
+
- name: Deploy image to Container App
|
| 54 |
+
uses: azure/cli@v2
|
| 55 |
+
with:
|
| 56 |
+
inlineScript: |
|
| 57 |
+
az containerapp update \
|
| 58 |
+
--name "${AZURE_CONTAINER_APP}" \
|
| 59 |
+
--resource-group "${AZURE_RESOURCE_GROUP}" \
|
| 60 |
+
--image "${{ steps.image.outputs.sha }}"
|
.gitignore
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.env
|
| 2 |
+
.env.*
|
| 3 |
+
!.env.production.example
|
| 4 |
+
.venv/
|
| 5 |
+
__pycache__/
|
| 6 |
+
*.py[cod]
|
| 7 |
+
*.db
|
| 8 |
+
*.db.bak
|
| 9 |
+
learning_data/uploads/
|
| 10 |
+
learning_data/converted/
|
| 11 |
+
services/research/pipe_output/
|
| 12 |
+
docs/images/
|
| 13 |
+
.azure-acr-info.txt
|
| 14 |
+
.azure-github-secrets.json
|
| 15 |
+
.CHANGES_FROM_GITHUB_MAIN.txt
|
ASHWIN.txt
ADDED
|
@@ -0,0 +1,666 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Codebase Changes Summary
|
| 2 |
+
========================
|
| 3 |
+
|
| 4 |
+
This file documents the current uncommitted changes found in the working tree.
|
| 5 |
+
It includes what changed, what the change does, and the main line numbers involved.
|
| 6 |
+
|
| 7 |
+
Important note:
|
| 8 |
+
- A very large part of this working tree diff is formatting / blank-line / CRLF-LF line-ending churn.
|
| 9 |
+
- Where a file only appears to have formatting or blank-line cleanup, this summary says so.
|
| 10 |
+
- Feature-level notes are based on the whitespace-filtered diff and current line numbers.
|
| 11 |
+
|
| 12 |
+
Overall diff summary
|
| 13 |
+
--------------------
|
| 14 |
+
- Deleted tracked files: 3
|
| 15 |
+
- Modified tracked files shown by git status: 50
|
| 16 |
+
- Untracked files: ASHWIN.txt and prospectiq_history_corrupt.bak
|
| 17 |
+
- Large frontend files such as static/styles.css, static/js/views/prospecting.js, static/js/app.js, and templates/index.html show very high deletion counts mostly because blank lines were removed.
|
| 18 |
+
|
| 19 |
+
1. .env.production.example
|
| 20 |
+
--------------------------
|
| 21 |
+
Lines involved: 1-20, entire deleted file
|
| 22 |
+
|
| 23 |
+
Changes made:
|
| 24 |
+
- Deleted the production environment example file.
|
| 25 |
+
|
| 26 |
+
What it does:
|
| 27 |
+
- Removes the sample production environment configuration from the tracked codebase.
|
| 28 |
+
|
| 29 |
+
2. CHANGES_FROM_GITHUB_MAIN.txt
|
| 30 |
+
-------------------------------
|
| 31 |
+
Lines involved: 1-229, entire deleted file
|
| 32 |
+
|
| 33 |
+
Changes made:
|
| 34 |
+
- Deleted the previous GitHub-main change note.
|
| 35 |
+
|
| 36 |
+
What it does:
|
| 37 |
+
- Removes an older/manual changelog artifact from the repository.
|
| 38 |
+
|
| 39 |
+
3. claude_fallback_changelog.txt
|
| 40 |
+
--------------------------------
|
| 41 |
+
Lines involved: 1-84, entire deleted file
|
| 42 |
+
|
| 43 |
+
Changes made:
|
| 44 |
+
- Deleted the Claude fallback changelog note.
|
| 45 |
+
|
| 46 |
+
What it does:
|
| 47 |
+
- Removes an older/manual AI fallback changelog artifact from the repository.
|
| 48 |
+
|
| 49 |
+
4. schema.sql
|
| 50 |
+
-------------
|
| 51 |
+
Lines involved: 11-46
|
| 52 |
+
|
| 53 |
+
Changes made:
|
| 54 |
+
- Added permissions table at lines 11-15.
|
| 55 |
+
- Added roles table at lines 17-25.
|
| 56 |
+
- Added role_permissions join table at lines 27-34.
|
| 57 |
+
- Added password_hash to users at line 40.
|
| 58 |
+
- Added role_id to users at line 41.
|
| 59 |
+
- Added a users.role_id foreign key to roles at line 46.
|
| 60 |
+
|
| 61 |
+
What it does:
|
| 62 |
+
- Adds database support for role-based access control.
|
| 63 |
+
- Lets users be connected to tenant-specific roles.
|
| 64 |
+
- Prepares the local SQLite schema for password-backed auth and permission checks.
|
| 65 |
+
|
| 66 |
+
5. supabase_schema.sql
|
| 67 |
+
----------------------
|
| 68 |
+
Lines involved: 16-43
|
| 69 |
+
|
| 70 |
+
Changes made:
|
| 71 |
+
- Added permissions table at lines 16-20.
|
| 72 |
+
- Added roles table at lines 22-29.
|
| 73 |
+
- Added role_permissions table at lines 31-35.
|
| 74 |
+
- Added password_hash to users at line 42.
|
| 75 |
+
- Added role_id with a roles foreign key at line 43.
|
| 76 |
+
|
| 77 |
+
What it does:
|
| 78 |
+
- Mirrors the role/permission changes for Supabase/Postgres deployments.
|
| 79 |
+
- Keeps local and hosted schema definitions aligned.
|
| 80 |
+
|
| 81 |
+
6. migrate.py
|
| 82 |
+
-------------
|
| 83 |
+
Lines involved: 25-204
|
| 84 |
+
|
| 85 |
+
Changes made:
|
| 86 |
+
- Includes migration logic for roles table creation at lines 25-49.
|
| 87 |
+
- Includes migration logic for permissions table creation at lines 51-68.
|
| 88 |
+
- Includes migration logic for role_permissions table creation at lines 70-89.
|
| 89 |
+
- Adds password_hash and role_id columns to existing users tables at lines 96-111.
|
| 90 |
+
- Seeds default permissions at lines 115-133.
|
| 91 |
+
- Creates/ensures standard tenant roles at lines 141-148.
|
| 92 |
+
- Grants permissions to Admin, BD Rep, and Viewer roles at lines 149-181.
|
| 93 |
+
- Maps existing users to role IDs and default password hashes at lines 184-204.
|
| 94 |
+
|
| 95 |
+
What it does:
|
| 96 |
+
- Upgrades existing databases to the new role/permission model without requiring a fresh database.
|
| 97 |
+
- Gives Admin all permissions, BD Rep non-admin operational permissions, and Viewer read permissions.
|
| 98 |
+
|
| 99 |
+
7. routes/auth.py
|
| 100 |
+
-----------------
|
| 101 |
+
Lines involved: formatting/line-ending change in git diff; current relevant auth code is around 47-584
|
| 102 |
+
|
| 103 |
+
Changes made:
|
| 104 |
+
- The file is marked modified, but the whitespace-filtered diff does not show clear content changes.
|
| 105 |
+
- Current file contains role seeding, permission lookup, admin dashboard, user management, role creation, and role-permission update logic.
|
| 106 |
+
|
| 107 |
+
What it does:
|
| 108 |
+
- No definite behavior change is attributable to this working-tree diff after ignoring formatting.
|
| 109 |
+
- The existing auth code supports tenant roles and permissions used by the schema/migration changes.
|
| 110 |
+
|
| 111 |
+
8. routes/ai.py
|
| 112 |
+
---------------
|
| 113 |
+
Lines involved: 377-394
|
| 114 |
+
|
| 115 |
+
Changes made:
|
| 116 |
+
- Added agentspace_system_prompt at line 377.
|
| 117 |
+
- The prompt tells Agent Space to:
|
| 118 |
+
- Be conversational and helpful.
|
| 119 |
+
- Ask clarifying questions instead of returning blunt errors.
|
| 120 |
+
- Suggest MCQ-style options for ambiguous requests.
|
| 121 |
+
- Distinguish internal users/reps/roles from external CRM contacts.
|
| 122 |
+
- Treat saved CRM/database queries differently from external Regional Prospecting.
|
| 123 |
+
- Extract decision makers into a contacts array when creating companies.
|
| 124 |
+
- Existing system messages are prefixed with this prompt at line 392.
|
| 125 |
+
- If no system message exists, the prompt is inserted at line 394.
|
| 126 |
+
|
| 127 |
+
What it does:
|
| 128 |
+
- Makes Agent Space safer and more precise.
|
| 129 |
+
- Reduces cases where "add user" becomes an external contact.
|
| 130 |
+
- Reduces cases where "show companies in CRM" accidentally triggers external prospecting.
|
| 131 |
+
- Helps AI-created companies include leadership contacts automatically.
|
| 132 |
+
|
| 133 |
+
9. routes/command.py
|
| 134 |
+
--------------------
|
| 135 |
+
Lines involved: 318, 410-427, 458-513, 528-564, 619-650, 710-762, 775-786, 833-841, 917-1218
|
| 136 |
+
|
| 137 |
+
Changes made:
|
| 138 |
+
- Expanded company-name cleanup at line 318 so descriptive phrases, location words, staff-size words, leadership titles, punctuation, and clauses are removed from the company name.
|
| 139 |
+
- Contact extraction now treats linkedin as a field boundary at line 410.
|
| 140 |
+
- Added LinkedIn parsing into contact_data at lines 425-427.
|
| 141 |
+
- Added unscheduled/unassigned handling for task dates at lines 458-459.
|
| 142 |
+
- Added unscheduled/unassigned handling for task times at lines 492-493.
|
| 143 |
+
- Removed "unscheduled" and "unassigned" from generated task titles at line 513.
|
| 144 |
+
- Expanded list intent keywords at line 528, including typo support for "sow" and "shwo".
|
| 145 |
+
- Added rep/user/role/admin/viewer detection at lines 539-540.
|
| 146 |
+
- Prevented internal rep/user/role prompts from being interpreted as external contacts at lines 541-556.
|
| 147 |
+
- Changed company filtering intent so stage/pipeline requests route to CRM filtering at line 564.
|
| 148 |
+
- Replaced representative parsing with a broader add_rep_match at lines 619-635.
|
| 149 |
+
- Added a friendly clarification response when adding an internal team member without enough information at lines 636-642.
|
| 150 |
+
- Added explicit CRM-database detection at lines 710-714.
|
| 151 |
+
- Added auto-routing to Regional Prospecting only for clear external discovery/prospecting queries at lines 711-712.
|
| 152 |
+
- Added local CRM filtering and top-N reply generation at lines 730-758.
|
| 153 |
+
- Returned filter_companies responses with a generated reply at lines 759-762.
|
| 154 |
+
- Added guardrails to avoid deterministic add_company parsing when a descriptive prompt is too complex at lines 775-776.
|
| 155 |
+
- Improved industry parsing from "doing/do/in ... business" phrasing at line 781.
|
| 156 |
+
- Improved staff-size parsing, including ranges like 50-70 using the midpoint, at lines 784-786.
|
| 157 |
+
- Added LinkedIn as an editable contact field at lines 833-841.
|
| 158 |
+
- Updated the LLM command-routing prompt at lines 937-1004 with:
|
| 159 |
+
- CRM database vs Regional Prospecting rules.
|
| 160 |
+
- Clean company-name extraction rules.
|
| 161 |
+
- Company notes/current_supplier_notes rules.
|
| 162 |
+
- Contact extraction from executives and decision makers.
|
| 163 |
+
- LinkedIn support for contacts.
|
| 164 |
+
- Unscheduled task behavior.
|
| 165 |
+
- Internal team member vs external CRM contact clarification.
|
| 166 |
+
- Removed an unconditional fallback_tool_route return near lines 1211-1213 so weak fallback routes do not override parsed AI results too early.
|
| 167 |
+
|
| 168 |
+
What it does:
|
| 169 |
+
- Makes the command bar much better at understanding natural-language CRM requests.
|
| 170 |
+
- Adds support for LinkedIn when adding/editing contacts.
|
| 171 |
+
- Allows tasks to be saved as unscheduled duties instead of forcing them into the calendar.
|
| 172 |
+
- Makes "top 3 companies in database" answer from saved CRM data instead of launching geo search.
|
| 173 |
+
- Makes external discovery queries still route to Regional Prospecting when appropriate.
|
| 174 |
+
- Makes internal user/rep creation safer and less likely to be confused with customer contacts.
|
| 175 |
+
|
| 176 |
+
10. services/tool_router.py
|
| 177 |
+
---------------------------
|
| 178 |
+
Lines involved: 56-89
|
| 179 |
+
|
| 180 |
+
Changes made:
|
| 181 |
+
- Added is_explicit_crm detection at lines 56-58.
|
| 182 |
+
- If a query clearly mentions CRM/database/stages/users/contacts/reps, the heuristic router now returns None instead of forcing an external tool route.
|
| 183 |
+
- Expanded CRM company filter language and typo handling at lines 60-63.
|
| 184 |
+
- Narrowed geo prospecting patterns so they require clearer location/discovery wording at lines 70-86.
|
| 185 |
+
- Removed broad trigger text such as "companies in" and "find companies" that could accidentally route CRM queries into geo search.
|
| 186 |
+
|
| 187 |
+
What it does:
|
| 188 |
+
- Prevents CRM/database commands from being misrouted to Regional Prospecting.
|
| 189 |
+
- Keeps geo routing for true prospect discovery such as nearby/map-search/discover-new requests.
|
| 190 |
+
|
| 191 |
+
11. routes/companies.py
|
| 192 |
+
-----------------------
|
| 193 |
+
Lines involved: 3, 193-235, 381-428, 437-475, 487-618
|
| 194 |
+
|
| 195 |
+
Changes made:
|
| 196 |
+
- Imported get_request_tenant_id at line 3.
|
| 197 |
+
- Added extract_contacts_from_descriptive_notes() at lines 193-235.
|
| 198 |
+
- add_company now reads the current tenant ID at line 381.
|
| 199 |
+
- add_company now normalizes notes/current_supplier_notes at lines 389-401.
|
| 200 |
+
- Replaced hard-coded tenant_id 1 with request tenant_id at line 411.
|
| 201 |
+
- Inserts notes_text into current_supplier_notes at line 428.
|
| 202 |
+
- Adds contacts from data.contacts or parsed notes at lines 437-466.
|
| 203 |
+
- Writes notes_text into activities and notes at lines 467-475.
|
| 204 |
+
- add_company_from_extractor now uses request tenant_id at lines 487 and 514.
|
| 205 |
+
- add_company_from_ocr now uses request tenant_id at lines 583 and 618.
|
| 206 |
+
|
| 207 |
+
What it does:
|
| 208 |
+
- New companies are created under the correct tenant instead of always tenant 1.
|
| 209 |
+
- Descriptive AI company prompts can create company contacts automatically.
|
| 210 |
+
- Leadership and meeting/background notes are preserved in activity/notes history.
|
| 211 |
+
|
| 212 |
+
12. static/js/components/inline_assistant.js
|
| 213 |
+
--------------------------------------------
|
| 214 |
+
Lines involved: 225-262, 283-401, 410-444, 453-504
|
| 215 |
+
|
| 216 |
+
Changes made:
|
| 217 |
+
- Company profile actions now use switchWorkspaceAndScrollTo() when available at lines 225-226.
|
| 218 |
+
- Company filtering now scrolls to the company table at lines 250-251.
|
| 219 |
+
- Company filtering replies are more specific and can use the server-provided reply at lines 255-264.
|
| 220 |
+
- Add/edit/delete company and contact flows now route and scroll to the correct table sections at lines 283-401.
|
| 221 |
+
- Task creation now checks whether the date is explicit at line 410.
|
| 222 |
+
- Unscheduled/unassigned tasks now set date/time to null at lines 410-412.
|
| 223 |
+
- Notifications distinguish scheduled tasks from unscheduled task-list items at lines 427-430.
|
| 224 |
+
- Task creation scrolls to calendar-section or tasks-section depending on whether there is a date at lines 434-436.
|
| 225 |
+
- Task replies distinguish scheduled tasks from unassigned duties at lines 441-444.
|
| 226 |
+
- Task edits normalize null/unscheduled date and time values at lines 453-454.
|
| 227 |
+
- Task edit/complete/delete actions scroll to calendar or tasks based on the task's date at lines 463-504.
|
| 228 |
+
|
| 229 |
+
What it does:
|
| 230 |
+
- Inline AI actions now take the user to the right workspace and section.
|
| 231 |
+
- Unscheduled duties stay in the task list instead of being forced into the calendar.
|
| 232 |
+
- Assistant replies are clearer after filtering, scheduling, and task actions.
|
| 233 |
+
|
| 234 |
+
13. static/js/app.js
|
| 235 |
+
--------------------
|
| 236 |
+
Lines involved: 618, 1018-1050, 1144-1422, 1488 onward
|
| 237 |
+
|
| 238 |
+
Changes made:
|
| 239 |
+
- Global command input lookup still uses global-command-bar at line 618, now backed by a textarea in the template.
|
| 240 |
+
- Added switchWorkspaceAndScrollTo() at lines 1018-1050.
|
| 241 |
+
- Command result handling now uses switchWorkspaceAndScrollTo() for company profile, company table, contact table, calendar, and task-list destinations at lines 1144-1422.
|
| 242 |
+
- Command input event handling near line 1488 supports textarea behavior.
|
| 243 |
+
- Large parts of the file also had blank-line cleanup, creating a very large diff that is mostly formatting.
|
| 244 |
+
|
| 245 |
+
What it does:
|
| 246 |
+
- Command results can move admins from portal mode into workspace mode and scroll directly to the relevant UI area.
|
| 247 |
+
- The global command bar can behave more like a multi-line command textarea.
|
| 248 |
+
|
| 249 |
+
14. templates/index.html
|
| 250 |
+
------------------------
|
| 251 |
+
Lines involved: 140, 334-352
|
| 252 |
+
|
| 253 |
+
Changes made:
|
| 254 |
+
- Replaced the global command bar input with a textarea at line 140.
|
| 255 |
+
- Wrapped the password input in an input group at lines 334-338.
|
| 256 |
+
- Added a toggle-password button and Bootstrap eye icon at lines 337-338.
|
| 257 |
+
- Added DOMContentLoaded password visibility toggle script at lines 343-352.
|
| 258 |
+
- The file also has a large amount of blank-line cleanup.
|
| 259 |
+
|
| 260 |
+
What it does:
|
| 261 |
+
- Users can type longer commands more comfortably.
|
| 262 |
+
- Users can show/hide the login password field.
|
| 263 |
+
|
| 264 |
+
15. static/js/views/profile.js
|
| 265 |
+
------------------------------
|
| 266 |
+
Lines involved: 29-30, 330, 351, 412, 430, old media tab/pane blocks around previous 448-473 and 1284-1296
|
| 267 |
+
|
| 268 |
+
Changes made:
|
| 269 |
+
- Removed media from validTabs at line 29.
|
| 270 |
+
- Added id="company-profile-header" to the profile header at line 330.
|
| 271 |
+
- Cleaned Agent Space tab text at lines 351 and 412.
|
| 272 |
+
- Kept the Agent Space pane at line 430.
|
| 273 |
+
- Removed the Media & Videos tab/pane.
|
| 274 |
+
- Removed the old drawVideosTab() placeholder function.
|
| 275 |
+
- Removed old video watched checkbox/localStorage handling.
|
| 276 |
+
|
| 277 |
+
What it does:
|
| 278 |
+
- Company profiles no longer show the unused Media & Videos area.
|
| 279 |
+
- Other code can scroll directly to the company profile header.
|
| 280 |
+
|
| 281 |
+
16. static/js/views/mockups.js
|
| 282 |
+
------------------------------
|
| 283 |
+
Lines involved: 20, 24-28, 278-360, 365-581
|
| 284 |
+
|
| 285 |
+
Changes made:
|
| 286 |
+
- Added isAdmin check based on users:manage permission at line 20.
|
| 287 |
+
- Renamed the page heading to "Settings & Configuration" at line 24.
|
| 288 |
+
- Updated settings description text at lines 24-28.
|
| 289 |
+
- Redesigned command memory loading/display at lines 278-360.
|
| 290 |
+
- Command memory items now include "View Checkpoint" buttons at line 322.
|
| 291 |
+
- Admin console layout/copy changed around lines 365-581.
|
| 292 |
+
|
| 293 |
+
What it does:
|
| 294 |
+
- Settings now better separates AI memory/checkpoints from admin controls.
|
| 295 |
+
- Admin-only controls are tied to the actual users:manage permission.
|
| 296 |
+
|
| 297 |
+
17. static/js/views/prospecting.js
|
| 298 |
+
----------------------------------
|
| 299 |
+
Lines involved: 1-75, 81-123 and many later sections; major formatting cleanup throughout
|
| 300 |
+
|
| 301 |
+
Changes made:
|
| 302 |
+
- Prospecting state contains active OCR, geo, and research job fields at lines 1-18.
|
| 303 |
+
- Added sessionStorage keys for active geo/research jobs at lines 19-20.
|
| 304 |
+
- Restores active geo job state from sessionStorage at lines 23-29.
|
| 305 |
+
- Restores active research job state from sessionStorage at lines 35-42.
|
| 306 |
+
- Added saveGeoJobSession() at lines 44-56.
|
| 307 |
+
- Added saveResearchJobSession() at lines 58-73.
|
| 308 |
+
- Added prospecting status helpers and metadata around lines 81-123.
|
| 309 |
+
- The file shows extremely large deletions because many blank lines were removed.
|
| 310 |
+
|
| 311 |
+
What it does:
|
| 312 |
+
- Prospecting job state can survive navigation/refresh better.
|
| 313 |
+
- Regional Prospecting and Company Research status can be tracked through shared helper state.
|
| 314 |
+
- Most of the huge diff is formatting cleanup rather than isolated feature changes.
|
| 315 |
+
|
| 316 |
+
18. static/styles.css
|
| 317 |
+
---------------------
|
| 318 |
+
Lines involved: 352-411, 1955-1984, 2258-3218, 3273, 4818-4842, 5482 onward
|
| 319 |
+
|
| 320 |
+
Changes made:
|
| 321 |
+
- Updated command input/action styling at lines 352-411.
|
| 322 |
+
- Profile tab styles remain around lines 1955-1984 after Media tab cleanup.
|
| 323 |
+
- Large Prospecting/Geo Finder style section exists at lines 2258-3218.
|
| 324 |
+
- Responsive command-bar styling appears at lines 3273 and 4818-4842.
|
| 325 |
+
- Auth styles appear at line 5482 onward.
|
| 326 |
+
- The file has the largest diff because many blank lines and formatting were removed.
|
| 327 |
+
|
| 328 |
+
What it does:
|
| 329 |
+
- Supports the command textarea and command action layout.
|
| 330 |
+
- Supports the Prospecting/Geo Finder UI styles.
|
| 331 |
+
- Supports the login/auth UI including the updated password input area.
|
| 332 |
+
|
| 333 |
+
19. static/js/views/companies.js
|
| 334 |
+
--------------------------------
|
| 335 |
+
Lines involved: line 1
|
| 336 |
+
|
| 337 |
+
Changes made:
|
| 338 |
+
- One-line content/formatting change at the start of the file.
|
| 339 |
+
|
| 340 |
+
What it does:
|
| 341 |
+
- No clear feature-level behavior change is visible from the whitespace-filtered diff.
|
| 342 |
+
|
| 343 |
+
20. static/js/views/contacts.js
|
| 344 |
+
-------------------------------
|
| 345 |
+
Lines involved: line 1
|
| 346 |
+
|
| 347 |
+
Changes made:
|
| 348 |
+
- One-line content/formatting change at the start of the file.
|
| 349 |
+
|
| 350 |
+
What it does:
|
| 351 |
+
- No clear feature-level behavior change is visible from the whitespace-filtered diff.
|
| 352 |
+
|
| 353 |
+
21. static/js/views/home.js
|
| 354 |
+
---------------------------
|
| 355 |
+
Lines involved: lines 1-4
|
| 356 |
+
|
| 357 |
+
Changes made:
|
| 358 |
+
- Small content/formatting change near the start of the file.
|
| 359 |
+
|
| 360 |
+
What it does:
|
| 361 |
+
- No clear feature-level behavior change is visible from the whitespace-filtered diff.
|
| 362 |
+
|
| 363 |
+
22. requirements.txt
|
| 364 |
+
--------------------
|
| 365 |
+
Lines involved: around line 143
|
| 366 |
+
|
| 367 |
+
Changes made:
|
| 368 |
+
- Removed an extra blank line around the anthropic dependency.
|
| 369 |
+
|
| 370 |
+
What it does:
|
| 371 |
+
- No dependency was clearly added or removed in this working-tree diff.
|
| 372 |
+
- This appears to be formatting cleanup only.
|
| 373 |
+
|
| 374 |
+
23. db.py
|
| 375 |
+
---------
|
| 376 |
+
Lines involved: around 288, 572-657
|
| 377 |
+
|
| 378 |
+
Changes made:
|
| 379 |
+
- Removed blank lines around get_db_connection(), dict_from_row(), Redis cache helpers, and cache_get/cache_set.
|
| 380 |
+
|
| 381 |
+
What it does:
|
| 382 |
+
- Formatting cleanup only.
|
| 383 |
+
- No clear behavior change is visible from the whitespace-filtered diff.
|
| 384 |
+
|
| 385 |
+
24. routes/learning.py
|
| 386 |
+
----------------------
|
| 387 |
+
Lines involved: formatting/blank-line cleanup
|
| 388 |
+
|
| 389 |
+
Changes made:
|
| 390 |
+
- Removed blank lines only in the whitespace-filtered diff.
|
| 391 |
+
|
| 392 |
+
What it does:
|
| 393 |
+
- No clear behavior change.
|
| 394 |
+
|
| 395 |
+
25. routes/stt.py
|
| 396 |
+
-----------------
|
| 397 |
+
Lines involved: formatting/blank-line cleanup
|
| 398 |
+
|
| 399 |
+
Changes made:
|
| 400 |
+
- Removed one blank/formatting line.
|
| 401 |
+
|
| 402 |
+
What it does:
|
| 403 |
+
- No clear behavior change.
|
| 404 |
+
|
| 405 |
+
26. routes/system.py
|
| 406 |
+
--------------------
|
| 407 |
+
Lines involved: formatting/blank-line cleanup
|
| 408 |
+
|
| 409 |
+
Changes made:
|
| 410 |
+
- Removed blank/formatting lines only.
|
| 411 |
+
|
| 412 |
+
What it does:
|
| 413 |
+
- No clear behavior change.
|
| 414 |
+
|
| 415 |
+
27. services/ai_service.py
|
| 416 |
+
--------------------------
|
| 417 |
+
Lines involved: formatting/blank-line cleanup throughout
|
| 418 |
+
|
| 419 |
+
Changes made:
|
| 420 |
+
- Removed blank lines around many helper functions and job-management functions.
|
| 421 |
+
|
| 422 |
+
What it does:
|
| 423 |
+
- Formatting cleanup only in this working-tree diff.
|
| 424 |
+
- No clear service behavior change is visible after ignoring whitespace.
|
| 425 |
+
|
| 426 |
+
28. services/export_service.py
|
| 427 |
+
------------------------------
|
| 428 |
+
Lines involved: formatting/blank-line cleanup
|
| 429 |
+
|
| 430 |
+
Changes made:
|
| 431 |
+
- Removed blank lines only in the whitespace-filtered diff.
|
| 432 |
+
|
| 433 |
+
What it does:
|
| 434 |
+
- No clear export behavior change.
|
| 435 |
+
|
| 436 |
+
29. services/geo_search.py
|
| 437 |
+
--------------------------
|
| 438 |
+
Lines involved: formatting/blank-line cleanup
|
| 439 |
+
|
| 440 |
+
Changes made:
|
| 441 |
+
- Removed blank lines only in the whitespace-filtered diff.
|
| 442 |
+
|
| 443 |
+
What it does:
|
| 444 |
+
- No clear geo-search behavior change in this working-tree diff.
|
| 445 |
+
|
| 446 |
+
30. services/ocr.py
|
| 447 |
+
-------------------
|
| 448 |
+
Lines involved: formatting/blank-line cleanup
|
| 449 |
+
|
| 450 |
+
Changes made:
|
| 451 |
+
- Removed blank lines only in the whitespace-filtered diff.
|
| 452 |
+
|
| 453 |
+
What it does:
|
| 454 |
+
- No clear OCR behavior change.
|
| 455 |
+
|
| 456 |
+
31. services/rag_service.py
|
| 457 |
+
---------------------------
|
| 458 |
+
Lines involved: formatting/blank-line cleanup
|
| 459 |
+
|
| 460 |
+
Changes made:
|
| 461 |
+
- Removed blank lines only in the whitespace-filtered diff.
|
| 462 |
+
|
| 463 |
+
What it does:
|
| 464 |
+
- No clear RAG behavior change.
|
| 465 |
+
|
| 466 |
+
32. services/research/analyzer.py
|
| 467 |
+
---------------------------------
|
| 468 |
+
Lines involved: formatting/blank-line cleanup
|
| 469 |
+
|
| 470 |
+
Changes made:
|
| 471 |
+
- Removed blank lines only in the whitespace-filtered diff.
|
| 472 |
+
|
| 473 |
+
What it does:
|
| 474 |
+
- No clear research analyzer behavior change.
|
| 475 |
+
|
| 476 |
+
33. services/research/browser.py
|
| 477 |
+
--------------------------------
|
| 478 |
+
Lines involved: formatting/blank-line cleanup
|
| 479 |
+
|
| 480 |
+
Changes made:
|
| 481 |
+
- Removed blank lines only in the whitespace-filtered diff.
|
| 482 |
+
|
| 483 |
+
What it does:
|
| 484 |
+
- No clear browser/research behavior change.
|
| 485 |
+
|
| 486 |
+
34. services/research/pipeline.py
|
| 487 |
+
---------------------------------
|
| 488 |
+
Lines involved: formatting/blank-line cleanup
|
| 489 |
+
|
| 490 |
+
Changes made:
|
| 491 |
+
- Removed blank lines only in the whitespace-filtered diff.
|
| 492 |
+
|
| 493 |
+
What it does:
|
| 494 |
+
- No clear pipeline behavior change.
|
| 495 |
+
|
| 496 |
+
35. services/research/query_generator.py
|
| 497 |
+
----------------------------------------
|
| 498 |
+
Lines involved: formatting/blank-line cleanup
|
| 499 |
+
|
| 500 |
+
Changes made:
|
| 501 |
+
- Removed blank lines only in the whitespace-filtered diff.
|
| 502 |
+
|
| 503 |
+
What it does:
|
| 504 |
+
- No clear query-generation behavior change.
|
| 505 |
+
|
| 506 |
+
36. services/research/questionlist.md
|
| 507 |
+
-------------------------------------
|
| 508 |
+
Lines involved: formatting/line-ending change
|
| 509 |
+
|
| 510 |
+
Changes made:
|
| 511 |
+
- File is marked modified, but no clear content change was visible after whitespace filtering.
|
| 512 |
+
|
| 513 |
+
What it does:
|
| 514 |
+
- No clear documentation/content behavior change.
|
| 515 |
+
|
| 516 |
+
37. static/js/components/contact_drawer.js
|
| 517 |
+
------------------------------------------
|
| 518 |
+
Lines involved: formatting/blank-line cleanup
|
| 519 |
+
|
| 520 |
+
Changes made:
|
| 521 |
+
- Removed one blank/formatting line.
|
| 522 |
+
|
| 523 |
+
What it does:
|
| 524 |
+
- No clear behavior change.
|
| 525 |
+
|
| 526 |
+
38. static/js/components/activity_logger.js
|
| 527 |
+
-------------------------------------------
|
| 528 |
+
Lines involved: formatting/line-ending change
|
| 529 |
+
|
| 530 |
+
Changes made:
|
| 531 |
+
- File is marked modified, but no clear content change was visible after whitespace filtering.
|
| 532 |
+
|
| 533 |
+
What it does:
|
| 534 |
+
- No clear behavior change.
|
| 535 |
+
|
| 536 |
+
39. static/js/components/agent_drawer.js
|
| 537 |
+
----------------------------------------
|
| 538 |
+
Lines involved: formatting/line-ending change
|
| 539 |
+
|
| 540 |
+
Changes made:
|
| 541 |
+
- File is marked modified, but no clear content change was visible after whitespace filtering.
|
| 542 |
+
|
| 543 |
+
What it does:
|
| 544 |
+
- No clear behavior change.
|
| 545 |
+
|
| 546 |
+
40. static/js/components/buttons.js
|
| 547 |
+
-----------------------------------
|
| 548 |
+
Lines involved: formatting/line-ending change
|
| 549 |
+
|
| 550 |
+
Changes made:
|
| 551 |
+
- File is marked modified, but no clear content change was visible after whitespace filtering.
|
| 552 |
+
|
| 553 |
+
What it does:
|
| 554 |
+
- No clear behavior change.
|
| 555 |
+
|
| 556 |
+
41. static/js/components/columns.js
|
| 557 |
+
-----------------------------------
|
| 558 |
+
Lines involved: formatting/line-ending change
|
| 559 |
+
|
| 560 |
+
Changes made:
|
| 561 |
+
- File is marked modified, but no clear content change was visible after whitespace filtering.
|
| 562 |
+
|
| 563 |
+
What it does:
|
| 564 |
+
- No clear behavior change.
|
| 565 |
+
|
| 566 |
+
42. static/js/components/notifications.js
|
| 567 |
+
-----------------------------------------
|
| 568 |
+
Lines involved: formatting/line-ending change
|
| 569 |
+
|
| 570 |
+
Changes made:
|
| 571 |
+
- File is marked modified, but no clear content change was visible after whitespace filtering.
|
| 572 |
+
|
| 573 |
+
What it does:
|
| 574 |
+
- No clear behavior change.
|
| 575 |
+
|
| 576 |
+
43. static/js/components/tasks.js
|
| 577 |
+
---------------------------------
|
| 578 |
+
Lines involved: formatting/line-ending change
|
| 579 |
+
|
| 580 |
+
Changes made:
|
| 581 |
+
- File is marked modified, but no clear content change was visible after whitespace filtering.
|
| 582 |
+
|
| 583 |
+
What it does:
|
| 584 |
+
- No clear behavior change.
|
| 585 |
+
|
| 586 |
+
44. static/js/router.js
|
| 587 |
+
-----------------------
|
| 588 |
+
Lines involved: formatting/line-ending change
|
| 589 |
+
|
| 590 |
+
Changes made:
|
| 591 |
+
- File is marked modified, but no clear content change was visible after whitespace filtering.
|
| 592 |
+
|
| 593 |
+
What it does:
|
| 594 |
+
- No clear behavior change.
|
| 595 |
+
|
| 596 |
+
45. static/js/utils.js
|
| 597 |
+
----------------------
|
| 598 |
+
Lines involved: formatting/line-ending change
|
| 599 |
+
|
| 600 |
+
Changes made:
|
| 601 |
+
- File is marked modified, but no clear content change was visible after whitespace filtering.
|
| 602 |
+
|
| 603 |
+
What it does:
|
| 604 |
+
- No clear behavior change.
|
| 605 |
+
|
| 606 |
+
46. static/js/views/admin_portal.js
|
| 607 |
+
-----------------------------------
|
| 608 |
+
Lines involved: formatting/line-ending change
|
| 609 |
+
|
| 610 |
+
Changes made:
|
| 611 |
+
- File is marked modified, but no clear content change was visible after whitespace filtering.
|
| 612 |
+
|
| 613 |
+
What it does:
|
| 614 |
+
- No clear behavior change.
|
| 615 |
+
|
| 616 |
+
47. static/js/views/learning.js
|
| 617 |
+
-------------------------------
|
| 618 |
+
Lines involved: formatting/line-ending change
|
| 619 |
+
|
| 620 |
+
Changes made:
|
| 621 |
+
- File is marked modified, but no clear content change was visible after whitespace filtering.
|
| 622 |
+
|
| 623 |
+
What it does:
|
| 624 |
+
- No clear Learning-page behavior change in this working-tree diff.
|
| 625 |
+
|
| 626 |
+
48. learning_data/collections_meta.json
|
| 627 |
+
---------------------------------------
|
| 628 |
+
Lines involved: formatting/line-ending change
|
| 629 |
+
|
| 630 |
+
Changes made:
|
| 631 |
+
- File is marked modified, but no clear content change was visible after whitespace filtering.
|
| 632 |
+
|
| 633 |
+
What it does:
|
| 634 |
+
- No clear data/content change.
|
| 635 |
+
|
| 636 |
+
49. tasks.py
|
| 637 |
+
------------
|
| 638 |
+
Lines involved: formatting/blank-line cleanup
|
| 639 |
+
|
| 640 |
+
Changes made:
|
| 641 |
+
- Removed blank lines only in the whitespace-filtered diff.
|
| 642 |
+
|
| 643 |
+
What it does:
|
| 644 |
+
- No clear task behavior change.
|
| 645 |
+
|
| 646 |
+
50. ASHWIN.txt
|
| 647 |
+
--------------
|
| 648 |
+
Lines involved: entire file
|
| 649 |
+
|
| 650 |
+
Changes made:
|
| 651 |
+
- Added/rewrote this codebase change summary document.
|
| 652 |
+
- Expanded it to match the attached example style: numbered file sections, line ranges, changes made, and what each change does.
|
| 653 |
+
|
| 654 |
+
What it does:
|
| 655 |
+
- Documents the current working tree changes for easier review.
|
| 656 |
+
|
| 657 |
+
51. prospectiq_history_corrupt.bak
|
| 658 |
+
----------------------------------
|
| 659 |
+
Lines involved: untracked file
|
| 660 |
+
|
| 661 |
+
Changes made:
|
| 662 |
+
- An untracked backup file exists in the repository root.
|
| 663 |
+
|
| 664 |
+
What it does:
|
| 665 |
+
- Likely preserves a corrupted or old ProspectIQ history artifact.
|
| 666 |
+
- It is not currently part of the tracked codebase unless intentionally added.
|
CONFLICT_ANALYSIS_REPORT.txt
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Codebase Change Conflict Analysis
|
| 2 |
+
==================================
|
| 3 |
+
|
| 4 |
+
Source documents compared:
|
| 5 |
+
- ASHWIN.txt (Ashwin's uncommitted working-tree change summary)
|
| 6 |
+
- JAI.txt (Jai's uncommitted working-tree change summary)
|
| 7 |
+
|
| 8 |
+
Purpose:
|
| 9 |
+
This report identifies overlaps, contradictions, and merge risks between the
|
| 10 |
+
two change summaries, which describe changes made to the same codebase by
|
| 11 |
+
two different people.
|
| 12 |
+
|
| 13 |
+
==================================================================
|
| 14 |
+
SECTION 1: CRITICAL CONFLICT - CONTRADICTORY REPORTS ON SAME FILES
|
| 15 |
+
==================================================================
|
| 16 |
+
|
| 17 |
+
The following files are described very differently by each report. In every
|
| 18 |
+
case, JAI's summary describes real functional/feature changes with specific
|
| 19 |
+
line numbers, while ASHWIN's summary describes the SAME file as pure
|
| 20 |
+
whitespace/blank-line/line-ending cleanup with "no clear behavior change."
|
| 21 |
+
|
| 22 |
+
Since both documents claim to summarize the current working tree, this
|
| 23 |
+
contradiction means one of two things:
|
| 24 |
+
(a) Ashwin's working tree does not currently contain Jai's changes
|
| 25 |
+
(different branch, different machine, or changes not yet pulled), or
|
| 26 |
+
(b) Ashwin's diffing method (whitespace-filtered diff) failed to detect
|
| 27 |
+
real content changes in these files.
|
| 28 |
+
|
| 29 |
+
Either way, if Ashwin's version of these files is merged/pushed as the
|
| 30 |
+
source of truth, Jai's work in each of these files is at risk of being
|
| 31 |
+
silently lost or overwritten.
|
| 32 |
+
|
| 33 |
+
--------------------------------------------------------------
|
| 34 |
+
1.1 services/ai_service.py
|
| 35 |
+
--------------------------------------------------------------
|
| 36 |
+
JAI reports (lines 771-780):
|
| 37 |
+
- Replaced the generic geo pipeline log message with friendly stage
|
| 38 |
+
labels: Input parsed, Fetching maps, Fetching internet, Verifying,
|
| 39 |
+
Verifying URLs, Ranking.
|
| 40 |
+
|
| 41 |
+
ASHWIN reports (same file):
|
| 42 |
+
- "Formatting/blank-line cleanup throughout... no clear service
|
| 43 |
+
behavior change is visible after ignoring whitespace."
|
| 44 |
+
|
| 45 |
+
Risk: HIGH - functional UX change vs. "nothing changed."
|
| 46 |
+
|
| 47 |
+
--------------------------------------------------------------
|
| 48 |
+
1.2 services/export_service.py
|
| 49 |
+
--------------------------------------------------------------
|
| 50 |
+
JAI reports (lines 211-238, 244, 247-248, 253-267, 334, 691-710):
|
| 51 |
+
- Added standardize_na() and get_geo_contact() helper functions.
|
| 52 |
+
- Website export normalizes invalid/missing values to "N/A."
|
| 53 |
+
- Phone/Email export columns use cleaned contact extraction, including
|
| 54 |
+
splitting emails mistakenly placed in phone fields.
|
| 55 |
+
- Removed Match Score, Operating Hours, Operational Status, Legitimacy
|
| 56 |
+
Label, and All Available Details columns from geo export.
|
| 57 |
+
- Added logic to prevent dropped/mapped fields from duplicating as
|
| 58 |
+
extra columns.
|
| 59 |
+
- Added Unicode replacement/cleanup before profile PDF generation to
|
| 60 |
+
avoid black square glyphs.
|
| 61 |
+
|
| 62 |
+
ASHWIN reports (same file):
|
| 63 |
+
- "Formatting/blank-line cleanup only... no clear export behavior
|
| 64 |
+
change."
|
| 65 |
+
|
| 66 |
+
Risk: HIGH - multiple new functions and export-format changes vs.
|
| 67 |
+
"nothing changed."
|
| 68 |
+
|
| 69 |
+
--------------------------------------------------------------
|
| 70 |
+
1.3 services/geo_search.py
|
| 71 |
+
--------------------------------------------------------------
|
| 72 |
+
JAI reports (lines 42-43, 68-69, 87, 119-130, 285-286, 473-474, 624-647):
|
| 73 |
+
- Prompt instructions now separately request Phone number and Email
|
| 74 |
+
address.
|
| 75 |
+
- Removed operating hours/operational status from requested output.
|
| 76 |
+
- Loosened filtering so companies aren't dropped just because HQ is
|
| 77 |
+
outside the searched city.
|
| 78 |
+
- Removed Match score from ranking output; added Full address, Phone
|
| 79 |
+
number, Email, Services/products to required output fields.
|
| 80 |
+
- Added stricter N/A rules for missing website/phone/email.
|
| 81 |
+
- Switched some Claude calls from Sonnet to claude-haiku-4-5.
|
| 82 |
+
- Increased max_tokens to 4096 in several places.
|
| 83 |
+
- Added continuation/retry handling when judge output is truncated by
|
| 84 |
+
max_tokens, plus a fallback message for empty judge output.
|
| 85 |
+
|
| 86 |
+
ASHWIN reports (same file):
|
| 87 |
+
- "Formatting/blank-line cleanup only... no clear geo-search behavior
|
| 88 |
+
change in this working-tree diff."
|
| 89 |
+
|
| 90 |
+
Risk: VERY HIGH - this is the single largest functional change in
|
| 91 |
+
either report (model swap, prompt rewrite, retry logic), and Ashwin's
|
| 92 |
+
version shows none of it.
|
| 93 |
+
|
| 94 |
+
--------------------------------------------------------------
|
| 95 |
+
1.4 services/ocr.py
|
| 96 |
+
--------------------------------------------------------------
|
| 97 |
+
JAI reports (lines 146-147):
|
| 98 |
+
- Changed OCR model from claude-3-5-sonnet-20241022 to
|
| 99 |
+
claude-haiku-4-5.
|
| 100 |
+
- Increased max_tokens from 1024 to 4096.
|
| 101 |
+
|
| 102 |
+
ASHWIN reports (same file):
|
| 103 |
+
- "Formatting/blank-line cleanup only... no clear OCR behavior
|
| 104 |
+
change."
|
| 105 |
+
|
| 106 |
+
Risk: HIGH - a model swap is a meaningful behavioral/cost change that
|
| 107 |
+
Ashwin's report does not reflect at all.
|
| 108 |
+
|
| 109 |
+
--------------------------------------------------------------
|
| 110 |
+
1.5 static/js/views/learning.js
|
| 111 |
+
--------------------------------------------------------------
|
| 112 |
+
JAI reports (lines 14-15, 103-105, 110-153, 268-354, 390-408, 421-436,
|
| 113 |
+
440-456, 487-505, 683-730, 755-760):
|
| 114 |
+
- Added background task polling (processingTaskIds, pollingTimer,
|
| 115 |
+
checkTaskStatus, startPollingTasks).
|
| 116 |
+
- Added custom Bootstrap confirm/error modals replacing native
|
| 117 |
+
browser confirm() dialogs for collection/document/chat deletion.
|
| 118 |
+
- Documents with 0 pages/chunks now show "Processing..." with a
|
| 119 |
+
spinner instead of stale counts.
|
| 120 |
+
- Upload flow now tracks background task IDs for documents and URL
|
| 121 |
+
uploads.
|
| 122 |
+
|
| 123 |
+
ASHWIN reports (same file):
|
| 124 |
+
- "Formatting/line-ending change... no clear Learning-page behavior
|
| 125 |
+
change in this working-tree diff."
|
| 126 |
+
|
| 127 |
+
Risk: HIGH - an entire polling/notification subsystem is missing from
|
| 128 |
+
Ashwin's version of this file.
|
| 129 |
+
|
| 130 |
+
==================================================================
|
| 131 |
+
SECTION 2: DEPENDENCY FILE CONFLICT
|
| 132 |
+
==================================================================
|
| 133 |
+
|
| 134 |
+
--------------------------------------------------------------
|
| 135 |
+
2.1 requirements.txt
|
| 136 |
+
--------------------------------------------------------------
|
| 137 |
+
JAI reports (lines 142-147):
|
| 138 |
+
- Added six new dependencies: unstructured[xlsx], pandas, networkx,
|
| 139 |
+
supabase, ddgs, langchain-excel-loader.
|
| 140 |
+
|
| 141 |
+
ASHWIN reports (same file, ~line 143):
|
| 142 |
+
- "Removed an extra blank line around the anthropic dependency...
|
| 143 |
+
no dependency was clearly added or removed. Formatting cleanup
|
| 144 |
+
only."
|
| 145 |
+
|
| 146 |
+
Risk: HIGH - same contradiction pattern as Section 1, but flagged
|
| 147 |
+
separately because a missed dependency file means the app may fail to
|
| 148 |
+
import modules (pandas, networkx, supabase, etc.) that Jai's code in
|
| 149 |
+
other files (geo_search.py, export_service.py, learning.js backend
|
| 150 |
+
support) likely now depends on. This compounds the Section 1 risk:
|
| 151 |
+
losing requirements.txt changes could break the very features that
|
| 152 |
+
would otherwise be preserved.
|
| 153 |
+
|
| 154 |
+
==================================================================
|
| 155 |
+
SECTION 3: OVERLAPPING FILES - LINE-LEVEL MERGE COLLISION RISK
|
| 156 |
+
==================================================================
|
| 157 |
+
|
| 158 |
+
These files are edited by both people in ways that are not directly
|
| 159 |
+
contradictory in content, but occupy overlapping or adjacent line
|
| 160 |
+
ranges, creating a high risk of literal merge conflicts or line-number
|
| 161 |
+
drift.
|
| 162 |
+
|
| 163 |
+
--------------------------------------------------------------
|
| 164 |
+
3.1 routes/command.py
|
| 165 |
+
--------------------------------------------------------------
|
| 166 |
+
JAI reports (line 1019):
|
| 167 |
+
- Increased max_tokens from 1000 to 4096.
|
| 168 |
+
|
| 169 |
+
ASHWIN reports (lines 318, 410-427, 458-513, 528-564, 619-650,
|
| 170 |
+
710-762, 775-786, 833-841, 917-1218):
|
| 171 |
+
- Large rewrite: company-name cleanup, LinkedIn contact parsing,
|
| 172 |
+
unscheduled task handling, CRM vs. Regional Prospecting routing
|
| 173 |
+
logic, LLM command-routing prompt rewrite (937-1004), fallback
|
| 174 |
+
route removal (~1211-1213).
|
| 175 |
+
|
| 176 |
+
Risk: HIGH - Jai's single-line change at 1019 falls squarely inside
|
| 177 |
+
Ashwin's heavily rewritten block (917-1218). This is very likely to
|
| 178 |
+
produce a direct line-level merge conflict, and even if resolved
|
| 179 |
+
automatically, the exact line 1019 target needs to be re-verified
|
| 180 |
+
against Ashwin's rewritten version of the file.
|
| 181 |
+
|
| 182 |
+
--------------------------------------------------------------
|
| 183 |
+
3.2 routes/ai.py
|
| 184 |
+
--------------------------------------------------------------
|
| 185 |
+
JAI reports (lines 254, 264, 288, 292, 508):
|
| 186 |
+
- Added pdf_display_name logic for company research PDF export
|
| 187 |
+
filenames.
|
| 188 |
+
- Increased agentspace chat max_tokens from 1500 to 4096 (line 508).
|
| 189 |
+
|
| 190 |
+
ASHWIN reports (lines 377-394):
|
| 191 |
+
- Added agentspace_system_prompt with conversational/clarifying
|
| 192 |
+
behavior instructions, inserted before/into existing system
|
| 193 |
+
messages.
|
| 194 |
+
|
| 195 |
+
Risk: MEDIUM - the two changes are logically independent (filename
|
| 196 |
+
generation vs. system prompt), but Ashwin's insertion at lines 377-394
|
| 197 |
+
(roughly 17+ lines) occurs before Jai's line 508 change, meaning Jai's
|
| 198 |
+
"line 508" max_tokens edit will shift to a different line number once
|
| 199 |
+
both changes are combined. Not a content conflict, but requires
|
| 200 |
+
re-locating the edit after merge rather than assuming line 508 is
|
| 201 |
+
still correct.
|
| 202 |
+
|
| 203 |
+
--------------------------------------------------------------
|
| 204 |
+
3.3 static/js/views/prospecting.js
|
| 205 |
+
--------------------------------------------------------------
|
| 206 |
+
JAI reports (lines 317-328, 1345-1402, 3763-3766, ~3839, 4143-4153,
|
| 207 |
+
4227, 4269, 4489-4543, 4682, 4887, 5082-5086, 5206-5278, 5302-5304,
|
| 208 |
+
5372-5389, ~5492, 5677-5685, ~6142-6170, ~6894):
|
| 209 |
+
- Added prospectNormalizeUrl().
|
| 210 |
+
- Removed Hours/Status/Match Score/Fit Rating fields from geo result
|
| 211 |
+
cards and copied details.
|
| 212 |
+
- Replaced raw log text area with structured step-by-step progress
|
| 213 |
+
UI (updateGeoProgress with icons).
|
| 214 |
+
- Added job-status-aware rendering (applyGeoJobToUi,
|
| 215 |
+
renderGeoResultsTable now takes jobStatus).
|
| 216 |
+
- Added running-job warning before allowing CRM add.
|
| 217 |
+
- Website/phone/email display cleanup and normalization.
|
| 218 |
+
- Removed "Complete live details" research log panel.
|
| 219 |
+
|
| 220 |
+
ASHWIN reports (lines 1-75, 81-123, plus broad formatting cleanup
|
| 221 |
+
across the file):
|
| 222 |
+
- Added prospecting job-state fields (active OCR/geo/research jobs).
|
| 223 |
+
- Added sessionStorage keys and restore logic for active geo/research
|
| 224 |
+
jobs across navigation/refresh.
|
| 225 |
+
- Added saveGeoJobSession() and saveResearchJobSession().
|
| 226 |
+
- Notes that most of the file's large diff is blank-line/formatting
|
| 227 |
+
cleanup rather than isolated features.
|
| 228 |
+
|
| 229 |
+
Risk: MEDIUM-HIGH - both make substantial, non-trivial changes to the
|
| 230 |
+
same very large file, in different functional areas (Jai: UI display/
|
| 231 |
+
cleanup of geo results and progress; Ashwin: job-state persistence via
|
| 232 |
+
sessionStorage). These could plausibly coexist, but given the size and
|
| 233 |
+
density of both diffs (and Ashwin's own note that formatting churn
|
| 234 |
+
makes the diff hard to read), this file needs a careful manual/guided
|
| 235 |
+
merge rather than a blind combination of both diffs.
|
| 236 |
+
|
| 237 |
+
==================================================================
|
| 238 |
+
SECTION 4: NO CONFLICT - SAFE, INDEPENDENT ADDITIONS
|
| 239 |
+
==================================================================
|
| 240 |
+
|
| 241 |
+
4.1 Debug/scratch files
|
| 242 |
+
- Jai added: scratch_check.py, scratch_check_app.py (untracked
|
| 243 |
+
developer debug helpers for inspecting geo interactions).
|
| 244 |
+
- Ashwin added: prospectiq_history_corrupt.bak (untracked backup
|
| 245 |
+
file).
|
| 246 |
+
- These are different untracked files; no collision.
|
| 247 |
+
|
| 248 |
+
4.2 Ashwin-only work (not touched by Jai's report at all)
|
| 249 |
+
- schema.sql / supabase_schema.sql: new permissions, roles, and
|
| 250 |
+
role_permissions tables; password_hash and role_id on users.
|
| 251 |
+
- migrate.py: migration logic for the above RBAC model.
|
| 252 |
+
- routes/auth.py: role seeding, permission lookups, admin dashboard
|
| 253 |
+
(flagged by Ashwin as formatting-only after filtering, but
|
| 254 |
+
functionally supports the schema/migration work).
|
| 255 |
+
- routes/companies.py: tenant-ID correctness fixes, automatic
|
| 256 |
+
contact extraction from descriptive notes, notes/activity history
|
| 257 |
+
preservation.
|
| 258 |
+
- services/tool_router.py: CRM-vs-Regional-Prospecting routing
|
| 259 |
+
heuristics.
|
| 260 |
+
- static/js/components/inline_assistant.js, static/js/app.js,
|
| 261 |
+
templates/index.html, static/js/views/profile.js,
|
| 262 |
+
static/js/views/mockups.js, static/styles.css: workspace
|
| 263 |
+
navigation/scrolling, command textarea, password visibility
|
| 264 |
+
toggle, removal of unused Media & Videos tab, Settings/Admin page
|
| 265 |
+
redesign.
|
| 266 |
+
|
| 267 |
+
4.3 Jai-only work (not touched by Ashwin's report at all)
|
| 268 |
+
- The specific stage-label wording, N/A normalization logic, and
|
| 269 |
+
haiku model swaps described in Section 1 are functionally unique
|
| 270 |
+
to Jai's report; Ashwin's report contains no equivalent feature
|
| 271 |
+
work for these areas.
|
| 272 |
+
|
| 273 |
+
4.4 Self-referential documentation files
|
| 274 |
+
- JAI.txt (Jai's own summary) and ASHWIN.txt (Ashwin's own summary)
|
| 275 |
+
are each just the authoring document itself; not a code conflict.
|
| 276 |
+
|
| 277 |
+
==================================================================
|
| 278 |
+
SECTION 5: SUMMARY TABLE
|
| 279 |
+
==================================================================
|
| 280 |
+
|
| 281 |
+
File | Conflict Level | Type
|
| 282 |
+
---------------------------------------------------------------
|
| 283 |
+
services/ai_service.py | CRITICAL | Contradictory
|
| 284 |
+
services/export_service.py | CRITICAL | Contradictory
|
| 285 |
+
services/geo_search.py | CRITICAL | Contradictory
|
| 286 |
+
services/ocr.py | CRITICAL | Contradictory
|
| 287 |
+
static/js/views/learning.js | CRITICAL | Contradictory
|
| 288 |
+
requirements.txt | HIGH | Contradictory
|
| 289 |
+
routes/command.py | HIGH | Line collision
|
| 290 |
+
routes/ai.py | MEDIUM | Line drift
|
| 291 |
+
static/js/views/prospecting.js | MEDIUM-HIGH | Overlapping features
|
| 292 |
+
Debug/scratch files | NONE | Independent
|
| 293 |
+
Ashwin-only files (auth/RBAC, UI nav) | NONE | Independent
|
| 294 |
+
Jai-only feature wording | NONE | Independent
|
| 295 |
+
|
| 296 |
+
==================================================================
|
| 297 |
+
SECTION 6: RECOMMENDATIONS
|
| 298 |
+
==================================================================
|
| 299 |
+
|
| 300 |
+
1. Before merging, confirm which of the two working trees is actually
|
| 301 |
+
up to date. The contradictory reports in Section 1 strongly suggest
|
| 302 |
+
Ashwin's tree does not include Jai's changes to ai_service.py,
|
| 303 |
+
export_service.py, geo_search.py, ocr.py, and learning.js (and the
|
| 304 |
+
requirements.txt dependencies those changes rely on).
|
| 305 |
+
|
| 306 |
+
2. Do NOT merge/overwrite using Ashwin's version of the five Section 1
|
| 307 |
+
files and requirements.txt without first checking for Jai's changes
|
| 308 |
+
- doing so would silently delete real feature work (geo search
|
| 309 |
+
prompt/model changes, export cleanup, OCR model upgrade, learning
|
| 310 |
+
page polling/modals).
|
| 311 |
+
|
| 312 |
+
3. For routes/command.py, apply Ashwin's larger rewrite first, then
|
| 313 |
+
manually re-locate and re-apply Jai's max_tokens change (originally
|
| 314 |
+
at line 1019) inside the updated file, since the surrounding code
|
| 315 |
+
will have shifted.
|
| 316 |
+
|
| 317 |
+
4. For routes/ai.py, apply both changes, but re-verify the exact line
|
| 318 |
+
for the max_tokens increase after Ashwin's system-prompt insertion
|
| 319 |
+
shifts the file.
|
| 320 |
+
|
| 321 |
+
5. For static/js/views/prospecting.js, perform a manual side-by-side
|
| 322 |
+
merge rather than a blind diff-apply, since both contributors made
|
| 323 |
+
large, non-overlapping-in-purpose but overlapping-in-location
|
| 324 |
+
changes to the same file.
|
| 325 |
+
|
| 326 |
+
6. Once merged, re-run the whitespace-filtered diff process Ashwin used
|
| 327 |
+
against the final combined tree to confirm all of Jai's functional
|
| 328 |
+
changes are still present and were not filtered out as "formatting
|
| 329 |
+
only."
|
| 330 |
+
|
| 331 |
+
End of report.
|
DEPLOYMENT_AZURE.md
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Deploy ProspectIQ on Azure
|
| 2 |
+
|
| 3 |
+
ProspectIQ should be deployed as a Docker container on Azure because the app needs Flask, Celery, Redis, Chromium, and ChromeDriver in the runtime.
|
| 4 |
+
|
| 5 |
+
Recommended Azure service: **Azure Container Apps**.
|
| 6 |
+
|
| 7 |
+
This repo's `Dockerfile` already installs Chromium/ChromeDriver and starts:
|
| 8 |
+
|
| 9 |
+
- Redis, when `REDIS_URL` points to local Redis
|
| 10 |
+
- Celery worker
|
| 11 |
+
- Gunicorn on port `7860`
|
| 12 |
+
|
| 13 |
+
## Important Production Notes
|
| 14 |
+
|
| 15 |
+
- Keep replicas at `1` for this current Docker setup. Redis runs inside the container, so multiple replicas would not share queued job state.
|
| 16 |
+
- Use Supabase/Postgres for persistent database state. Do not rely on local SQLite on Azure.
|
| 17 |
+
- Do not commit `.env` files or API keys. Add secrets in Azure.
|
| 18 |
+
- Use at least `2 CPU / 4 GiB` memory because Chromium and Gemini jobs can be heavy.
|
| 19 |
+
|
| 20 |
+
## Required Environment Variables
|
| 21 |
+
|
| 22 |
+
Add these in Azure as secrets or environment variables:
|
| 23 |
+
|
| 24 |
+
```text
|
| 25 |
+
SUPABASE_DB_HOST
|
| 26 |
+
SUPABASE_DB_NAME
|
| 27 |
+
SUPABASE_DB_USER
|
| 28 |
+
SUPABASE_DB_PASSWORD
|
| 29 |
+
SUPABASE_DB_PORT
|
| 30 |
+
GEMINI_API_KEY_prime_1
|
| 31 |
+
GEMINI_API_KEY_prime_2
|
| 32 |
+
GEMINI_API_KEY_prime_3
|
| 33 |
+
OPENROUTER_API_KEY
|
| 34 |
+
PORT=7860
|
| 35 |
+
WEBSITES_PORT=7860
|
| 36 |
+
REDIS_URL=redis://127.0.0.1:6379/0
|
| 37 |
+
CELERY_BROKER_URL=redis://127.0.0.1:6379/0
|
| 38 |
+
CELERY_RESULT_BACKEND=redis://127.0.0.1:6379/0
|
| 39 |
+
WEB_CONCURRENCY=1
|
| 40 |
+
WEB_TIMEOUT=300
|
| 41 |
+
CELERY_WORKER_POOL=solo
|
| 42 |
+
CELERY_WORKER_CONCURRENCY=1
|
| 43 |
+
COMPANY_EXTRACTOR_MAX_PARALLEL_SEARCHES=2
|
| 44 |
+
```
|
| 45 |
+
|
| 46 |
+
## Option A: Azure Container Apps
|
| 47 |
+
|
| 48 |
+
Install prerequisites:
|
| 49 |
+
|
| 50 |
+
- Docker Desktop
|
| 51 |
+
- Azure CLI
|
| 52 |
+
|
| 53 |
+
Login:
|
| 54 |
+
|
| 55 |
+
```powershell
|
| 56 |
+
az login
|
| 57 |
+
az extension add --name containerapp --upgrade
|
| 58 |
+
```
|
| 59 |
+
|
| 60 |
+
Set names. Azure Container Registry names must be globally unique and use only letters/numbers.
|
| 61 |
+
|
| 62 |
+
```powershell
|
| 63 |
+
$RG="prospectiq-rg"
|
| 64 |
+
$LOC="eastus"
|
| 65 |
+
$APP="prospectiq"
|
| 66 |
+
$ENV="prospectiq-env"
|
| 67 |
+
$ACR="prospectiqacr$((Get-Random -Minimum 10000 -Maximum 99999))"
|
| 68 |
+
```
|
| 69 |
+
|
| 70 |
+
Create Azure resources:
|
| 71 |
+
|
| 72 |
+
```powershell
|
| 73 |
+
az group create --name $RG --location $LOC
|
| 74 |
+
az acr create --resource-group $RG --name $ACR --sku Basic --admin-enabled true
|
| 75 |
+
az containerapp env create --name $ENV --resource-group $RG --location $LOC
|
| 76 |
+
```
|
| 77 |
+
|
| 78 |
+
Build and push the image:
|
| 79 |
+
|
| 80 |
+
```powershell
|
| 81 |
+
$IMAGE="$ACR.azurecr.io/prospectiq:latest"
|
| 82 |
+
az acr login --name $ACR
|
| 83 |
+
docker build -t $IMAGE .
|
| 84 |
+
docker push $IMAGE
|
| 85 |
+
```
|
| 86 |
+
|
| 87 |
+
Get registry credentials:
|
| 88 |
+
|
| 89 |
+
```powershell
|
| 90 |
+
$ACR_USER=$(az acr credential show --name $ACR --query username -o tsv)
|
| 91 |
+
$ACR_PASS=$(az acr credential show --name $ACR --query "passwords[0].value" -o tsv)
|
| 92 |
+
```
|
| 93 |
+
|
| 94 |
+
Create the Container App. Replace the placeholder values before running:
|
| 95 |
+
|
| 96 |
+
```powershell
|
| 97 |
+
az containerapp create `
|
| 98 |
+
--name $APP `
|
| 99 |
+
--resource-group $RG `
|
| 100 |
+
--environment $ENV `
|
| 101 |
+
--image $IMAGE `
|
| 102 |
+
--target-port 7860 `
|
| 103 |
+
--ingress external `
|
| 104 |
+
--cpu 2 `
|
| 105 |
+
--memory 4Gi `
|
| 106 |
+
--min-replicas 1 `
|
| 107 |
+
--max-replicas 1 `
|
| 108 |
+
--registry-server "$ACR.azurecr.io" `
|
| 109 |
+
--registry-username $ACR_USER `
|
| 110 |
+
--registry-password $ACR_PASS `
|
| 111 |
+
--secrets `
|
| 112 |
+
supabase-db-password="YOUR_SUPABASE_PASSWORD" `
|
| 113 |
+
gemini-api-key-prime-1="YOUR_GEMINI_API_KEY_prime_1" `
|
| 114 |
+
gemini-api-key-prime-2="YOUR_GEMINI_API_KEY_prime_2" `
|
| 115 |
+
gemini-api-key-prime-3="YOUR_GEMINI_API_KEY_prime_3" `
|
| 116 |
+
openrouter-api-key="YOUR_OPENROUTER_API_KEY" `
|
| 117 |
+
--env-vars `
|
| 118 |
+
SUPABASE_DB_HOST="YOUR_SUPABASE_HOST" `
|
| 119 |
+
SUPABASE_DB_NAME="postgres" `
|
| 120 |
+
SUPABASE_DB_USER="postgres" `
|
| 121 |
+
SUPABASE_DB_PASSWORD=secretref:supabase-db-password `
|
| 122 |
+
SUPABASE_DB_PORT="6543" `
|
| 123 |
+
GEMINI_API_KEY_prime_1=secretref:gemini-api-key-prime-1 `
|
| 124 |
+
GEMINI_API_KEY_prime_2=secretref:gemini-api-key-prime-2 `
|
| 125 |
+
GEMINI_API_KEY_prime_3=secretref:gemini-api-key-prime-3 `
|
| 126 |
+
OPENROUTER_API_KEY=secretref:openrouter-api-key `
|
| 127 |
+
PORT="7860" `
|
| 128 |
+
WEBSITES_PORT="7860" `
|
| 129 |
+
REDIS_URL="redis://127.0.0.1:6379/0" `
|
| 130 |
+
CELERY_BROKER_URL="redis://127.0.0.1:6379/0" `
|
| 131 |
+
CELERY_RESULT_BACKEND="redis://127.0.0.1:6379/0" `
|
| 132 |
+
WEB_CONCURRENCY="1" `
|
| 133 |
+
WEB_TIMEOUT="300" `
|
| 134 |
+
CELERY_WORKER_POOL="solo" `
|
| 135 |
+
CELERY_WORKER_CONCURRENCY="1" `
|
| 136 |
+
COMPANY_EXTRACTOR_MAX_PARALLEL_SEARCHES="2"
|
| 137 |
+
```
|
| 138 |
+
|
| 139 |
+
Get the public URL:
|
| 140 |
+
|
| 141 |
+
```powershell
|
| 142 |
+
az containerapp show --name $APP --resource-group $RG --query properties.configuration.ingress.fqdn -o tsv
|
| 143 |
+
```
|
| 144 |
+
|
| 145 |
+
View logs:
|
| 146 |
+
|
| 147 |
+
```powershell
|
| 148 |
+
az containerapp logs show --name $APP --resource-group $RG --follow
|
| 149 |
+
```
|
| 150 |
+
|
| 151 |
+
Update after future code changes:
|
| 152 |
+
|
| 153 |
+
```powershell
|
| 154 |
+
docker build -t $IMAGE .
|
| 155 |
+
docker push $IMAGE
|
| 156 |
+
az containerapp update --name $APP --resource-group $RG --image $IMAGE
|
| 157 |
+
```
|
| 158 |
+
|
| 159 |
+
## Option B: Azure App Service for Containers
|
| 160 |
+
|
| 161 |
+
Use this only if you specifically want App Service. Container Apps is usually better for this app.
|
| 162 |
+
|
| 163 |
+
Create an App Service plan and Web App:
|
| 164 |
+
|
| 165 |
+
```powershell
|
| 166 |
+
$RG="prospectiq-rg"
|
| 167 |
+
$LOC="eastus"
|
| 168 |
+
$PLAN="prospectiq-plan"
|
| 169 |
+
$WEBAPP="prospectiq-$((Get-Random -Minimum 10000 -Maximum 99999))"
|
| 170 |
+
$ACR="YOUR_ACR_NAME"
|
| 171 |
+
$IMAGE="$ACR.azurecr.io/prospectiq:latest"
|
| 172 |
+
|
| 173 |
+
az appservice plan create --name $PLAN --resource-group $RG --location $LOC --is-linux --sku B2
|
| 174 |
+
az webapp create --resource-group $RG --plan $PLAN --name $WEBAPP --deployment-container-image-name $IMAGE
|
| 175 |
+
az webapp config appsettings set --resource-group $RG --name $WEBAPP --settings WEBSITES_PORT=7860 PORT=7860 WEB_CONCURRENCY=1 WEB_TIMEOUT=300
|
| 176 |
+
```
|
| 177 |
+
|
| 178 |
+
Then add the same Supabase/API key settings in:
|
| 179 |
+
|
| 180 |
+
Azure Portal -> App Service -> Settings -> Environment variables.
|
| 181 |
+
|
| 182 |
+
## Health Check
|
| 183 |
+
|
| 184 |
+
After deployment, test:
|
| 185 |
+
|
| 186 |
+
- Open the Azure URL.
|
| 187 |
+
- Run OCR with a small image.
|
| 188 |
+
- Run Geo Finder with a simple query.
|
| 189 |
+
- Run Company Extractor with a known company.
|
| 190 |
+
- Check logs if any job remains queued.
|
| 191 |
+
|
| 192 |
+
## Scaling Later
|
| 193 |
+
|
| 194 |
+
For heavier production usage, split the app into separate services:
|
| 195 |
+
|
| 196 |
+
- Web container: Flask/Gunicorn
|
| 197 |
+
- Worker container: Celery
|
| 198 |
+
- Azure Cache for Redis: shared broker/result backend
|
| 199 |
+
- Supabase/Postgres: database
|
| 200 |
+
|
| 201 |
+
Then you can safely increase replicas.
|
| 202 |
+
|
| 203 |
+
## GitHub Actions CI/CD
|
| 204 |
+
|
| 205 |
+
This repository includes `.github/workflows/deploy-azure.yml`.
|
| 206 |
+
|
| 207 |
+
When run manually, the workflow:
|
| 208 |
+
|
| 209 |
+
1. Builds the Docker image from `Dockerfile`.
|
| 210 |
+
2. Pushes the image to Azure Container Registry.
|
| 211 |
+
3. Updates the Azure Container App to a new revision.
|
| 212 |
+
|
| 213 |
+
Current Azure resources used by the workflow:
|
| 214 |
+
|
| 215 |
+
```text
|
| 216 |
+
Resource group: ProspectIQ_centralindia
|
| 217 |
+
Container app: prospectiq
|
| 218 |
+
Container registry: prospectiqacr57421.azurecr.io
|
| 219 |
+
Image: prospectiq
|
| 220 |
+
```
|
| 221 |
+
|
| 222 |
+
Add these GitHub repository secrets:
|
| 223 |
+
|
| 224 |
+
```text
|
| 225 |
+
ACR_USERNAME
|
| 226 |
+
ACR_PASSWORD
|
| 227 |
+
AZURE_CREDENTIALS
|
| 228 |
+
```
|
| 229 |
+
|
| 230 |
+
Get ACR credentials from Azure CLI:
|
| 231 |
+
|
| 232 |
+
```powershell
|
| 233 |
+
az acr credential show --name prospectiqacr57421 --query username -o tsv
|
| 234 |
+
az acr credential show --name prospectiqacr57421 --query "passwords[0].value" -o tsv
|
| 235 |
+
```
|
| 236 |
+
|
| 237 |
+
`AZURE_CREDENTIALS` must be the JSON output from a service principal with Contributor access to the resource group:
|
| 238 |
+
|
| 239 |
+
```powershell
|
| 240 |
+
$scope = az group show --name ProspectIQ_centralindia --query id -o tsv
|
| 241 |
+
az ad sp create-for-rbac `
|
| 242 |
+
--name prospectiq-github-actions `
|
| 243 |
+
--role Contributor `
|
| 244 |
+
--scopes $scope `
|
| 245 |
+
--json-auth
|
| 246 |
+
```
|
| 247 |
+
|
| 248 |
+
If Azure returns `Insufficient privileges to complete the operation`, your tenant does not allow your account to create service principals. Ask an Azure/Entra admin to either:
|
| 249 |
+
|
| 250 |
+
- create the service principal and give you the JSON for `AZURE_CREDENTIALS`, or
|
| 251 |
+
- temporarily grant your user permission to register applications.
|
| 252 |
+
|
| 253 |
+
After secrets are set, run the workflow manually from:
|
| 254 |
+
|
| 255 |
+
```text
|
| 256 |
+
GitHub -> Actions -> Deploy to Azure Container Apps -> Run workflow
|
| 257 |
+
```
|
| 258 |
+
|
| 259 |
+
### No-Admin Deployment Path
|
| 260 |
+
|
| 261 |
+
If your Azure tenant blocks service-principal creation, you cannot fully automate Azure updates from GitHub Actions because GitHub has no Azure identity. Use the local deployment script instead:
|
| 262 |
+
|
| 263 |
+
```powershell
|
| 264 |
+
git pull origin main
|
| 265 |
+
.\scripts\deploy-azure.ps1
|
| 266 |
+
```
|
| 267 |
+
|
| 268 |
+
This uses your local `az login` session and Docker Desktop to:
|
| 269 |
+
|
| 270 |
+
1. Build the image.
|
| 271 |
+
2. Push it to `prospectiqacr57421.azurecr.io`.
|
| 272 |
+
3. Update the Azure Container App.
|
| 273 |
+
|
| 274 |
+
This is not fully automatic, but it works without admin help.
|
DEPLOYMENT_HUGGINGFACE.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Hugging Face Spaces Docker Deployment
|
| 2 |
+
|
| 3 |
+
This app is configured for a Hugging Face Docker Space.
|
| 4 |
+
|
| 5 |
+
Hugging Face Docker Spaces expose port `7860` by default. The Space metadata is in `README.md`.
|
| 6 |
+
|
| 7 |
+
## Important Limitations
|
| 8 |
+
|
| 9 |
+
- Spaces run a single Docker container, so Redis, Celery, and Flask run as processes inside one container.
|
| 10 |
+
- Free Spaces storage is not a production database. Use Supabase/Postgres for real data.
|
| 11 |
+
- Headless Chromium is installed in the Docker image for Company Extractor.
|
| 12 |
+
- Long-running browser/search jobs can still hit platform resource limits on free hardware.
|
| 13 |
+
|
| 14 |
+
## Create The Space
|
| 15 |
+
|
| 16 |
+
1. Go to Hugging Face.
|
| 17 |
+
2. Create a new Space.
|
| 18 |
+
3. Choose SDK: `Docker`.
|
| 19 |
+
4. Choose visibility.
|
| 20 |
+
5. Clone the Space repository locally.
|
| 21 |
+
|
| 22 |
+
## Push This Project To The Space
|
| 23 |
+
|
| 24 |
+
From this project folder:
|
| 25 |
+
|
| 26 |
+
```bash
|
| 27 |
+
git remote add hf https://huggingface.co/spaces/YOUR_USERNAME/YOUR_SPACE_NAME
|
| 28 |
+
git push hf main
|
| 29 |
+
```
|
| 30 |
+
|
| 31 |
+
If the remote already exists:
|
| 32 |
+
|
| 33 |
+
```bash
|
| 34 |
+
git remote set-url hf https://huggingface.co/spaces/YOUR_USERNAME/YOUR_SPACE_NAME
|
| 35 |
+
git push hf main
|
| 36 |
+
```
|
| 37 |
+
|
| 38 |
+
## Add Secrets
|
| 39 |
+
|
| 40 |
+
In the Space, go to Settings -> Variables and secrets -> New secret.
|
| 41 |
+
|
| 42 |
+
Add the secrets you need:
|
| 43 |
+
|
| 44 |
+
```text
|
| 45 |
+
SUPABASE_DB_HOST
|
| 46 |
+
SUPABASE_DB_NAME
|
| 47 |
+
SUPABASE_DB_USER
|
| 48 |
+
SUPABASE_DB_PASSWORD
|
| 49 |
+
SUPABASE_DB_PORT
|
| 50 |
+
GEMINI_API_KEY_prime_1
|
| 51 |
+
GEMINI_API_KEY_prime_2
|
| 52 |
+
GEMINI_API_KEY_prime_3
|
| 53 |
+
OPENROUTER_API_KEY
|
| 54 |
+
GROQ_API_KEY
|
| 55 |
+
PINECONE_API_KEY
|
| 56 |
+
SUPABASE_URL
|
| 57 |
+
SUPABASE_KEY
|
| 58 |
+
```
|
| 59 |
+
|
| 60 |
+
Minimum for Company Extractor:
|
| 61 |
+
|
| 62 |
+
```text
|
| 63 |
+
SUPABASE_DB_HOST
|
| 64 |
+
SUPABASE_DB_PASSWORD
|
| 65 |
+
GEMINI_API_KEY_prime_1
|
| 66 |
+
GEMINI_API_KEY_prime_2
|
| 67 |
+
GEMINI_API_KEY_prime_3
|
| 68 |
+
```
|
| 69 |
+
|
| 70 |
+
Minimum for OpenRouter-backed features:
|
| 71 |
+
|
| 72 |
+
```text
|
| 73 |
+
OPENROUTER_API_KEY
|
| 74 |
+
```
|
| 75 |
+
|
| 76 |
+
## Database
|
| 77 |
+
|
| 78 |
+
Use Supabase/Postgres. Run `supabase_schema.sql` in the Supabase SQL editor before first use.
|
| 79 |
+
|
| 80 |
+
## Logs
|
| 81 |
+
|
| 82 |
+
After pushing, open the Space build/runtime logs. A healthy start should show Gunicorn binding to port `7860` and Celery starting.
|
Dockerfile
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
ENV PYTHONUNBUFFERED=1
|
| 4 |
+
ENV PYTHONDONTWRITEBYTECODE=1
|
| 5 |
+
ENV PYTHONPATH=/app
|
| 6 |
+
ENV CHROME_BIN=/usr/bin/chromium
|
| 7 |
+
ENV CHROMEDRIVER_PATH=/usr/bin/chromedriver
|
| 8 |
+
ENV PORT=7860
|
| 9 |
+
ENV WEBSITES_PORT=7860
|
| 10 |
+
|
| 11 |
+
WORKDIR /app
|
| 12 |
+
|
| 13 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 14 |
+
ca-certificates \
|
| 15 |
+
chromium \
|
| 16 |
+
chromium-driver \
|
| 17 |
+
fonts-liberation \
|
| 18 |
+
libasound2 \
|
| 19 |
+
libatk-bridge2.0-0 \
|
| 20 |
+
libgbm1 \
|
| 21 |
+
libgtk-3-0 \
|
| 22 |
+
libnss3 \
|
| 23 |
+
libxss1 \
|
| 24 |
+
redis-server \
|
| 25 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 26 |
+
|
| 27 |
+
COPY requirements.txt .
|
| 28 |
+
RUN pip install --no-cache-dir --upgrade pip setuptools wheel \
|
| 29 |
+
&& pip install --no-cache-dir -r requirements.txt
|
| 30 |
+
|
| 31 |
+
COPY . .
|
| 32 |
+
|
| 33 |
+
RUN chmod +x /app/start-hf-space.sh
|
| 34 |
+
|
| 35 |
+
EXPOSE 7860
|
| 36 |
+
|
| 37 |
+
CMD ["/app/start-hf-space.sh"]
|
JAI.txt
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
Codebase Changes Summary
|
| 2 |
+
========================
|
| 3 |
+
|
| 4 |
+
This file documents the current uncommitted changes found in the working tree.
|
| 5 |
+
It includes what changed, what the change does, and the main line numbers involved.
|
| 6 |
+
|
| 7 |
+
1. requirements.txt
|
| 8 |
+
-------------------
|
| 9 |
+
Lines involved: 142-147
|
| 10 |
+
|
| 11 |
+
Changes made:
|
| 12 |
+
- Added these dependencies:
|
| 13 |
+
- unstructured[xlsx]
|
| 14 |
+
- pandas
|
| 15 |
+
- networkx
|
| 16 |
+
- supabase
|
| 17 |
+
- ddgs
|
| 18 |
+
- langchain-excel-loader
|
| 19 |
+
|
| 20 |
+
What it does:
|
| 21 |
+
- Adds package support for spreadsheet/document ingestion, data processing, graph logic,
|
| 22 |
+
Supabase integration, DuckDuckGo search, and Excel loading through LangChain.
|
| 23 |
+
|
| 24 |
+
2. routes/ai.py
|
| 25 |
+
---------------
|
| 26 |
+
Lines involved: 254, 264, 288, 292, 508
|
| 27 |
+
|
| 28 |
+
Changes made:
|
| 29 |
+
- Added pdf_display_name initialization at line 254.
|
| 30 |
+
- Reads the job title into pdf_display_name at line 264.
|
| 31 |
+
- Builds the exported PDF filename from the searched company/job title, folder name, or file stem at line 288.
|
| 32 |
+
- Uses that generated filename in send_file at line 292.
|
| 33 |
+
- Increased agentspace chat max_tokens from 1500 to 4096 at line 508.
|
| 34 |
+
|
| 35 |
+
What it does:
|
| 36 |
+
- Company research PDFs now download with a clearer name like "<Company> Company Research".
|
| 37 |
+
- Agentspace chat responses can be longer before being cut off.
|
| 38 |
+
|
| 39 |
+
3. routes/command.py
|
| 40 |
+
--------------------
|
| 41 |
+
Lines involved: 1019
|
| 42 |
+
|
| 43 |
+
Changes made:
|
| 44 |
+
- Increased max_tokens from 1000 to 4096.
|
| 45 |
+
|
| 46 |
+
What it does:
|
| 47 |
+
- Command-generated AI responses can return longer outputs.
|
| 48 |
+
|
| 49 |
+
4. services/ai_service.py
|
| 50 |
+
-------------------------
|
| 51 |
+
Lines involved: 771-780
|
| 52 |
+
|
| 53 |
+
Changes made:
|
| 54 |
+
- Replaced the generic geo pipeline log message with friendly stage labels:
|
| 55 |
+
- Input parsed
|
| 56 |
+
- Fetching maps
|
| 57 |
+
- Fetching internet
|
| 58 |
+
- Verifying
|
| 59 |
+
- Verifying URLs
|
| 60 |
+
- Ranking
|
| 61 |
+
|
| 62 |
+
What it does:
|
| 63 |
+
- The geo search progress UI receives cleaner, user-readable status messages instead of raw internal step names.
|
| 64 |
+
|
| 65 |
+
5. services/export_service.py
|
| 66 |
+
-----------------------------
|
| 67 |
+
Lines involved: 211-238, 244, 247-248, 253-267, 334, 691-710
|
| 68 |
+
|
| 69 |
+
Changes made:
|
| 70 |
+
- Added standardize_na() at lines 211-222.
|
| 71 |
+
- Added get_geo_contact() at lines 224-238.
|
| 72 |
+
- Website export now normalizes invalid or missing values to "N/A" at line 244.
|
| 73 |
+
- Phone and Email export columns now use cleaned contact extraction at lines 247-248.
|
| 74 |
+
- Removed Match Score, Operating Hours, Operational Status, Legitimacy Label, and All Available Details from the preferred geo export table.
|
| 75 |
+
- Added mapped_or_dropped_keys at lines 253-260 to prevent dropped/mapped fields from being duplicated as extra columns.
|
| 76 |
+
- Removed the Operating Hours Excel column width entry at old line 301.
|
| 77 |
+
- Added Unicode replacement/cleanup before profile PDF generation at lines 691-710.
|
| 78 |
+
|
| 79 |
+
What it does:
|
| 80 |
+
- Geo exports are cleaner and now separate phone and email correctly, including cases where an email was mixed into the phone field.
|
| 81 |
+
- Placeholder text like "not found", "dead link", "removed", or "not directly listed" is converted to "N/A".
|
| 82 |
+
- Exported spreadsheets avoid duplicate or unwanted fields.
|
| 83 |
+
- Profile PDFs avoid black square glyphs by replacing unsupported Unicode characters before rendering.
|
| 84 |
+
|
| 85 |
+
6. services/geo_search.py
|
| 86 |
+
-------------------------
|
| 87 |
+
Lines involved: 42-43, 68-69, 87, 119-130, 285-286, 473-474, 624-647
|
| 88 |
+
|
| 89 |
+
Changes made:
|
| 90 |
+
- Prompt instructions now ask separately for Phone number and Email address at lines 42-43 and 68-69.
|
| 91 |
+
- Removed operating hours and operational status from requested geo output.
|
| 92 |
+
- Updated filtering guidance at line 87 so companies are not removed too aggressively just because headquarters may be outside the searched city.
|
| 93 |
+
- Removed Match score from the final ranking format.
|
| 94 |
+
- Added Full address, Phone number, Email, and Services/products fields to the required final output at lines 118-123.
|
| 95 |
+
- Added stricter rules at lines 129-130 for location mismatches and for using "N/A" when website, phone, or email are unavailable.
|
| 96 |
+
- Switched some Claude calls from sonnet to claude-haiku-4-5 at lines 285 and 473.
|
| 97 |
+
- Increased related max_tokens values to 4096 at lines 286, 474, and 629.
|
| 98 |
+
- Added continuation handling for judge output at lines 624-647, retrying when the model stops because of max_tokens.
|
| 99 |
+
- Added fallback text "[Judge AI returned no output]" when judge output is empty.
|
| 100 |
+
|
| 101 |
+
What it does:
|
| 102 |
+
- Geo search should return more complete contact details, cleaner missing values, and fewer wrongly discarded businesses.
|
| 103 |
+
- Ranking output is less likely to be truncated.
|
| 104 |
+
- AI calls are adjusted to use haiku with larger response limits.
|
| 105 |
+
|
| 106 |
+
7. services/ocr.py
|
| 107 |
+
------------------
|
| 108 |
+
Lines involved: 146-147
|
| 109 |
+
|
| 110 |
+
Changes made:
|
| 111 |
+
- Changed the OCR model from claude-3-5-sonnet-20241022 to claude-haiku-4-5.
|
| 112 |
+
- Increased max_tokens from 1024 to 4096.
|
| 113 |
+
|
| 114 |
+
What it does:
|
| 115 |
+
- Business card extraction now uses the newer haiku model and allows longer OCR extraction output.
|
| 116 |
+
|
| 117 |
+
8. static/js/views/learning.js
|
| 118 |
+
------------------------------
|
| 119 |
+
Lines involved: 14-15, 103-105, 110-153, 268-354, 390-408, 421-436, 440-456, 487-505, 683-730, 755-760
|
| 120 |
+
|
| 121 |
+
Changes made:
|
| 122 |
+
- Added processingTaskIds and pollingTimer state at lines 14-15.
|
| 123 |
+
- Added api.checkTaskStatus() at lines 103-105.
|
| 124 |
+
- Added startPollingTasks() at lines 110-153.
|
| 125 |
+
- Added custom Bootstrap confirm modal at lines 268-309.
|
| 126 |
+
- Added custom Bootstrap error modal at lines 312-354.
|
| 127 |
+
- Replaced browser confirm() deletion prompts with custom modals for:
|
| 128 |
+
- Collection deletion at lines 390-408.
|
| 129 |
+
- Document deletion at lines 440-456.
|
| 130 |
+
- Chat session deletion at lines 487-505.
|
| 131 |
+
- Added loading spinners and disabled states while delete actions are running.
|
| 132 |
+
- Documents with 0 pages and 0 chunks now display as "Processing..." with a spinner at lines 421-436.
|
| 133 |
+
- Upload progress text changed from "Indexing documents..." to "Uploading documents..." at line 687.
|
| 134 |
+
- Upload flows now collect returned background task IDs at lines 683-730.
|
| 135 |
+
- URL upload task IDs are also tracked at lines 755-760.
|
| 136 |
+
|
| 137 |
+
What it does:
|
| 138 |
+
- The Learning page now handles background document/video/URL indexing more gracefully.
|
| 139 |
+
- Users see processing status instead of stale document counts.
|
| 140 |
+
- Delete actions feel safer and clearer because they use styled modals and loading states.
|
| 141 |
+
- The page automatically refreshes documents and collections when background processing finishes.
|
| 142 |
+
|
| 143 |
+
9. static/js/views/prospecting.js
|
| 144 |
+
---------------------------------
|
| 145 |
+
Lines involved: 317-328, 1345-1402, 3763-3766, 3839 old line, 4143-4153, 4227, 4269, 4489-4543, 4682, 4887, 5082-5086, 5206-5278, 5302-5304, 5372-5389, 5492 old block, 5677-5685, 6142-6170 old lines, 6894 old block
|
| 146 |
+
|
| 147 |
+
Changes made:
|
| 148 |
+
- Added prospectNormalizeUrl() at lines 317-328.
|
| 149 |
+
- Removed Hours and Status detail rows from geo result cards around old lines 1345-1358.
|
| 150 |
+
- Removed Match score pill display around old line 1395.
|
| 151 |
+
- Replaced raw geo runner log text area with a structured step container at lines 3763-3766.
|
| 152 |
+
- Removed Fit Rating column around old line 3839.
|
| 153 |
+
- Geo search startup now renders structured progress steps at lines 4143-4153.
|
| 154 |
+
- Error, interruption, and stop messages now append as HTML alert rows instead of plain text at lines 4227, 4269, 4887, and 5082-5086.
|
| 155 |
+
- updateGeoProgress() now renders step-by-step status with icons for Input parsed, Fetching maps, Fetching internet, Verifying, and Ranking at lines 4489-4543.
|
| 156 |
+
- applyGeoJobToUi() now passes job.status into renderGeoResultsTable() at line 4682.
|
| 157 |
+
- renderGeoResultsTable() now accepts jobStatus at line 5206.
|
| 158 |
+
- Running or queued geo jobs now show a warning not to add companies to CRM until the pipeline finishes at lines 5262-5278.
|
| 159 |
+
- Website values are normalized and invalid website text is converted to "N/A" at lines 5302-5304.
|
| 160 |
+
- Phone/email cleanup now extracts emails mistakenly placed in the phone field and normalizes invalid placeholders at lines 5372-5389.
|
| 161 |
+
- Removed rating table cell block around old line 5492.
|
| 162 |
+
- Website display now hides the protocol in link text while keeping the full href at line 5677.
|
| 163 |
+
- Added Email detail display with mailto link at lines 5683-5685.
|
| 164 |
+
- Removed Operating Hours and Operational Status detail blocks around old lines 5672-5703.
|
| 165 |
+
- Removed Match Score and Operating Hours from copied company details around old lines 6142-6170.
|
| 166 |
+
- Removed the "Complete live details" research log panel around old line 6894.
|
| 167 |
+
|
| 168 |
+
What it does:
|
| 169 |
+
- The Prospecting geo UI is simplified and focused on useful CRM fields.
|
| 170 |
+
- Progress is shown as readable pipeline steps instead of noisy raw logs.
|
| 171 |
+
- Users are warned while results are still running so incomplete companies are not added too early.
|
| 172 |
+
- Website, phone, and email fields are cleaner and safer to display/export.
|
| 173 |
+
- Removed fields that were either noisy, duplicated, or no longer part of the geo output format.
|
| 174 |
+
|
| 175 |
+
10. scratch_check.py
|
| 176 |
+
--------------------
|
| 177 |
+
Lines involved: 1-24
|
| 178 |
+
|
| 179 |
+
Changes made:
|
| 180 |
+
- Added an untracked scratch script that opens interactions.db directly with sqlite3.
|
| 181 |
+
- It prints recent geo logs, output_json result preview, and stage previews.
|
| 182 |
+
|
| 183 |
+
What it does:
|
| 184 |
+
- Helps manually inspect the latest geo interaction stored in the local SQLite database.
|
| 185 |
+
- This appears to be a developer/debug helper and is not integrated into the app.
|
| 186 |
+
|
| 187 |
+
11. scratch_check_app.py
|
| 188 |
+
------------------------
|
| 189 |
+
Lines involved: 1-26
|
| 190 |
+
|
| 191 |
+
Changes made:
|
| 192 |
+
- Added an untracked scratch script that imports db_list_interactions from services.ai_service.
|
| 193 |
+
- It prints the latest geo interaction title, status, result, and stage previews.
|
| 194 |
+
|
| 195 |
+
What it does:
|
| 196 |
+
- Helps inspect the latest geo interaction through the app service layer instead of raw sqlite3.
|
| 197 |
+
- This also appears to be a developer/debug helper and is not integrated into the app.
|
| 198 |
+
|
| 199 |
+
12. JAI.txt
|
| 200 |
+
-----------
|
| 201 |
+
Lines involved: entire file
|
| 202 |
+
|
| 203 |
+
Changes made:
|
| 204 |
+
- Added this change summary document.
|
| 205 |
+
|
| 206 |
+
What it does:
|
| 207 |
+
- Documents the current working tree changes, their purpose, and the relevant file line numbers.
|
README.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: ProspectIQ
|
| 3 |
+
emoji: 📇
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: gray
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
---
|
| 9 |
+
|
| 10 |
+
# ProspectIQ
|
| 11 |
+
|
| 12 |
+
Flask CRM and prospecting workspace packaged as a Hugging Face Docker Space.
|
| 13 |
+
|
| 14 |
+
The Docker container starts:
|
| 15 |
+
|
| 16 |
+
- Flask via Gunicorn
|
| 17 |
+
- Celery worker
|
| 18 |
+
- Redis for local queueing
|
| 19 |
+
- Chromium/ChromeDriver for Company Extractor headless browser search
|
| 20 |
+
|
| 21 |
+
Configure API keys and database credentials in the Space settings as Secrets.
|
| 22 |
+
|
| 23 |
+
## Deployment
|
| 24 |
+
|
| 25 |
+
- Hugging Face Spaces: see `DEPLOYMENT_HUGGINGFACE.md`
|
| 26 |
+
- Azure: see `DEPLOYMENT_AZURE.md`
|
app.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from dotenv import load_dotenv
|
| 3 |
+
|
| 4 |
+
load_dotenv()
|
| 5 |
+
|
| 6 |
+
from flask import Flask, render_template
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from db import init_db
|
| 9 |
+
|
| 10 |
+
from routes.companies import companies_bp
|
| 11 |
+
from routes.contacts import contacts_bp
|
| 12 |
+
from routes.command import command_bp
|
| 13 |
+
from routes.system import stages_bp, activities_bp, stats_bp, columns_bp, settings_bp, bootstrap_bp
|
| 14 |
+
from routes.ai import ai_bp
|
| 15 |
+
from routes.learning import learning_bp
|
| 16 |
+
from routes.stt import stt_bp
|
| 17 |
+
from routes.auth import auth_bp
|
| 18 |
+
|
| 19 |
+
app = Flask(__name__)
|
| 20 |
+
app.secret_key = os.environ.get("SECRET_KEY", "prospectiq_secure_dev_session_key_98765")
|
| 21 |
+
|
| 22 |
+
from db import init_db, is_supabase_active, sync_postgres_sequences
|
| 23 |
+
# Ensure database is initialized
|
| 24 |
+
if not is_supabase_active():
|
| 25 |
+
db_file = Path(__file__).resolve().parent / "prospectiq.db"
|
| 26 |
+
if not db_file.exists() or db_file.stat().st_size == 0:
|
| 27 |
+
init_db()
|
| 28 |
+
else:
|
| 29 |
+
sync_postgres_sequences()
|
| 30 |
+
|
| 31 |
+
@app.route("/")
|
| 32 |
+
def index():
|
| 33 |
+
return render_template("index.html")
|
| 34 |
+
|
| 35 |
+
# Register Blueprints
|
| 36 |
+
app.register_blueprint(auth_bp, url_prefix='/api/auth')
|
| 37 |
+
app.register_blueprint(companies_bp, url_prefix='/api/companies')
|
| 38 |
+
app.register_blueprint(contacts_bp, url_prefix='/api/contacts')
|
| 39 |
+
app.register_blueprint(stages_bp, url_prefix='/api/stages')
|
| 40 |
+
app.register_blueprint(activities_bp, url_prefix='/api/notes')
|
| 41 |
+
app.register_blueprint(stats_bp, url_prefix='/api/stats')
|
| 42 |
+
app.register_blueprint(columns_bp, url_prefix='/api')
|
| 43 |
+
app.register_blueprint(settings_bp, url_prefix='/api/settings')
|
| 44 |
+
app.register_blueprint(command_bp, url_prefix='/api/command')
|
| 45 |
+
|
| 46 |
+
# Register AI Blueprint
|
| 47 |
+
app.register_blueprint(ai_bp)
|
| 48 |
+
app.register_blueprint(learning_bp, url_prefix='/api/learning')
|
| 49 |
+
app.register_blueprint(bootstrap_bp, url_prefix='/api/bootstrap')
|
| 50 |
+
app.register_blueprint(stt_bp)
|
| 51 |
+
|
| 52 |
+
if __name__ == "__main__":
|
| 53 |
+
port = int(os.environ.get("PORT", 5000))
|
| 54 |
+
app.run(host="0.0.0.0", port=port, debug=True)
|
celery_app.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
from dotenv import load_dotenv
|
| 4 |
+
|
| 5 |
+
load_dotenv()
|
| 6 |
+
|
| 7 |
+
# Add the project root to the Python path so Celery can find the 'services' module
|
| 8 |
+
sys.path.insert(0, os.path.abspath(os.path.dirname(__file__)))
|
| 9 |
+
|
| 10 |
+
from celery import Celery
|
| 11 |
+
|
| 12 |
+
redis_url = os.environ.get("REDIS_URL", "redis://127.0.0.1:6379/0")
|
| 13 |
+
broker_url = os.environ.get("CELERY_BROKER_URL", redis_url)
|
| 14 |
+
backend_url = os.environ.get("CELERY_RESULT_BACKEND", redis_url)
|
| 15 |
+
|
| 16 |
+
celery_app = Celery(
|
| 17 |
+
"prospectiq_tasks",
|
| 18 |
+
broker=broker_url,
|
| 19 |
+
backend=backend_url,
|
| 20 |
+
include=["tasks"]
|
| 21 |
+
)
|
db.py
ADDED
|
@@ -0,0 +1,689 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sqlite3
|
| 2 |
+
import os
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
import psycopg2
|
| 5 |
+
from psycopg2 import pool
|
| 6 |
+
|
| 7 |
+
DB_PATH = Path(__file__).resolve().parent / "prospectiq.db"
|
| 8 |
+
SCHEMA_PATH = Path(__file__).resolve().parent / "schema.sql"
|
| 9 |
+
|
| 10 |
+
# Global PG Connection Pool
|
| 11 |
+
pg_pool = None
|
| 12 |
+
|
| 13 |
+
def init_pg_pool():
|
| 14 |
+
global pg_pool
|
| 15 |
+
if pg_pool is not None:
|
| 16 |
+
return
|
| 17 |
+
|
| 18 |
+
host = os.environ.get("SUPABASE_DB_HOST") or ""
|
| 19 |
+
db = os.environ.get("SUPABASE_DB_NAME", "postgres")
|
| 20 |
+
user = os.environ.get("SUPABASE_DB_USER", "postgres")
|
| 21 |
+
password = os.environ.get("SUPABASE_DB_PASSWORD") or ""
|
| 22 |
+
port = os.environ.get("SUPABASE_DB_PORT", "6543")
|
| 23 |
+
|
| 24 |
+
if host.strip() and password.strip():
|
| 25 |
+
try:
|
| 26 |
+
pg_pool = psycopg2.pool.ThreadedConnectionPool(
|
| 27 |
+
minconn=2,
|
| 28 |
+
maxconn=20,
|
| 29 |
+
host=host,
|
| 30 |
+
database=db,
|
| 31 |
+
user=user,
|
| 32 |
+
password=password,
|
| 33 |
+
port=port
|
| 34 |
+
)
|
| 35 |
+
print("Successfully initialized PostgreSQL connection pool.")
|
| 36 |
+
except Exception as e:
|
| 37 |
+
print(f"Error initializing PostgreSQL connection pool: {e}")
|
| 38 |
+
pg_pool = None
|
| 39 |
+
|
| 40 |
+
class PostgresRow:
|
| 41 |
+
def __init__(self, data, description):
|
| 42 |
+
self._data = data
|
| 43 |
+
self._keys = [desc[0] for desc in description]
|
| 44 |
+
self._key_map = {name: i for i, name in enumerate(self._keys)}
|
| 45 |
+
|
| 46 |
+
def __getitem__(self, key):
|
| 47 |
+
if isinstance(key, int):
|
| 48 |
+
return self._data[key]
|
| 49 |
+
elif isinstance(key, str):
|
| 50 |
+
idx = self._key_map.get(key)
|
| 51 |
+
if idx is None:
|
| 52 |
+
raise KeyError(key)
|
| 53 |
+
return self._data[idx]
|
| 54 |
+
else:
|
| 55 |
+
raise TypeError("Index must be int or str")
|
| 56 |
+
|
| 57 |
+
def keys(self):
|
| 58 |
+
return self._keys
|
| 59 |
+
|
| 60 |
+
def __len__(self):
|
| 61 |
+
return len(self._data)
|
| 62 |
+
|
| 63 |
+
def __iter__(self):
|
| 64 |
+
return iter(self._keys)
|
| 65 |
+
|
| 66 |
+
def get(self, key, default=None):
|
| 67 |
+
if key in self._key_map:
|
| 68 |
+
return self[key]
|
| 69 |
+
return default
|
| 70 |
+
|
| 71 |
+
class PostgresCursorWrapper:
|
| 72 |
+
def __init__(self, pg_cursor, connection):
|
| 73 |
+
self.cursor = pg_cursor
|
| 74 |
+
self.connection = connection
|
| 75 |
+
self._lastrowid = None
|
| 76 |
+
|
| 77 |
+
def __enter__(self):
|
| 78 |
+
return self
|
| 79 |
+
|
| 80 |
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
| 81 |
+
self.close()
|
| 82 |
+
|
| 83 |
+
def execute(self, sql, params=None):
|
| 84 |
+
sql = sql.replace('?', '%s')
|
| 85 |
+
sql_upper = sql.upper().strip()
|
| 86 |
+
|
| 87 |
+
if "INSERT OR REPLACE INTO INTERACTIONS" in sql_upper:
|
| 88 |
+
sql = """
|
| 89 |
+
INSERT INTO interactions (
|
| 90 |
+
id, kind, title, status, input_json, output_json, result_text, logs_json,
|
| 91 |
+
error, run_dir, profile_path, command_json, returncode, pid,
|
| 92 |
+
created_at, started_at, finished_at, updated_at
|
| 93 |
+
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
| 94 |
+
ON CONFLICT (id) DO UPDATE SET
|
| 95 |
+
kind = EXCLUDED.kind,
|
| 96 |
+
title = EXCLUDED.title,
|
| 97 |
+
status = EXCLUDED.status,
|
| 98 |
+
input_json = EXCLUDED.input_json,
|
| 99 |
+
output_json = EXCLUDED.output_json,
|
| 100 |
+
result_text = EXCLUDED.result_text,
|
| 101 |
+
logs_json = EXCLUDED.logs_json,
|
| 102 |
+
error = EXCLUDED.error,
|
| 103 |
+
run_dir = EXCLUDED.run_dir,
|
| 104 |
+
profile_path = EXCLUDED.profile_path,
|
| 105 |
+
command_json = EXCLUDED.command_json,
|
| 106 |
+
returncode = EXCLUDED.returncode,
|
| 107 |
+
pid = EXCLUDED.pid,
|
| 108 |
+
created_at = EXCLUDED.created_at,
|
| 109 |
+
started_at = EXCLUDED.started_at,
|
| 110 |
+
finished_at = EXCLUDED.finished_at,
|
| 111 |
+
updated_at = EXCLUDED.updated_at
|
| 112 |
+
"""
|
| 113 |
+
elif "INSERT OR IGNORE INTO" in sql_upper:
|
| 114 |
+
sql = sql.replace("INSERT OR IGNORE INTO", "INSERT INTO") + " ON CONFLICT DO NOTHING"
|
| 115 |
+
|
| 116 |
+
is_insert = sql_upper.startswith("INSERT")
|
| 117 |
+
if is_insert and "RETURNING" not in sql_upper and "ROLE_PERMISSIONS" not in sql_upper:
|
| 118 |
+
sql = sql.rstrip().rstrip(';') + " RETURNING id"
|
| 119 |
+
|
| 120 |
+
# Invalidate stage or user caches automatically on database writes
|
| 121 |
+
is_write = sql_upper.startswith("INSERT") or sql_upper.startswith("UPDATE") or sql_upper.startswith("DELETE")
|
| 122 |
+
if is_write:
|
| 123 |
+
try:
|
| 124 |
+
if "STAGES" in sql_upper:
|
| 125 |
+
cache_delete("prospectiq:stages")
|
| 126 |
+
if "USERS" in sql_upper:
|
| 127 |
+
cache_delete("prospectiq:users")
|
| 128 |
+
except Exception:
|
| 129 |
+
pass
|
| 130 |
+
|
| 131 |
+
if params is not None:
|
| 132 |
+
self.cursor.execute(sql, params)
|
| 133 |
+
else:
|
| 134 |
+
self.cursor.execute(sql)
|
| 135 |
+
|
| 136 |
+
self._lastrowid = None
|
| 137 |
+
if is_insert and "RETURNING" in sql.upper():
|
| 138 |
+
try:
|
| 139 |
+
row = self.cursor.fetchone()
|
| 140 |
+
if row:
|
| 141 |
+
self._lastrowid = row[0]
|
| 142 |
+
except Exception:
|
| 143 |
+
pass
|
| 144 |
+
|
| 145 |
+
return self
|
| 146 |
+
|
| 147 |
+
def executemany(self, sql, seq_of_params):
|
| 148 |
+
sql = sql.replace('?', '%s')
|
| 149 |
+
self.cursor.executemany(sql, seq_of_params)
|
| 150 |
+
return self
|
| 151 |
+
|
| 152 |
+
def fetchone(self):
|
| 153 |
+
row = self.cursor.fetchone()
|
| 154 |
+
if row is None:
|
| 155 |
+
return None
|
| 156 |
+
return PostgresRow(row, self.cursor.description)
|
| 157 |
+
|
| 158 |
+
def fetchall(self):
|
| 159 |
+
rows = self.cursor.fetchall()
|
| 160 |
+
if not rows:
|
| 161 |
+
return []
|
| 162 |
+
desc = self.cursor.description
|
| 163 |
+
return [PostgresRow(r, desc) for r in rows]
|
| 164 |
+
|
| 165 |
+
@property
|
| 166 |
+
def rowcount(self):
|
| 167 |
+
return self.cursor.rowcount
|
| 168 |
+
|
| 169 |
+
@property
|
| 170 |
+
def lastrowid(self):
|
| 171 |
+
return self._lastrowid
|
| 172 |
+
|
| 173 |
+
def close(self):
|
| 174 |
+
self.cursor.close()
|
| 175 |
+
|
| 176 |
+
class PostgresConnectionWrapper:
|
| 177 |
+
def __init__(self, pg_conn):
|
| 178 |
+
self.conn = pg_conn
|
| 179 |
+
|
| 180 |
+
def cursor(self):
|
| 181 |
+
return PostgresCursorWrapper(self.conn.cursor(), self)
|
| 182 |
+
|
| 183 |
+
def execute(self, sql, params=None):
|
| 184 |
+
cur = self.cursor()
|
| 185 |
+
cur.execute(sql, params)
|
| 186 |
+
return cur
|
| 187 |
+
|
| 188 |
+
def executemany(self, sql, seq_of_params):
|
| 189 |
+
cur = self.cursor()
|
| 190 |
+
cur.executemany(sql, seq_of_params)
|
| 191 |
+
return cur
|
| 192 |
+
|
| 193 |
+
def commit(self):
|
| 194 |
+
self.conn.commit()
|
| 195 |
+
|
| 196 |
+
def rollback(self):
|
| 197 |
+
self.conn.rollback()
|
| 198 |
+
|
| 199 |
+
def close(self):
|
| 200 |
+
global pg_pool
|
| 201 |
+
if pg_pool is not None:
|
| 202 |
+
try:
|
| 203 |
+
pg_pool.putconn(self.conn)
|
| 204 |
+
except Exception as e:
|
| 205 |
+
print(f"Error returning connection to pool: {e}")
|
| 206 |
+
try:
|
| 207 |
+
self.conn.close()
|
| 208 |
+
except Exception:
|
| 209 |
+
pass
|
| 210 |
+
else:
|
| 211 |
+
self.conn.close()
|
| 212 |
+
|
| 213 |
+
def __enter__(self):
|
| 214 |
+
return self
|
| 215 |
+
|
| 216 |
+
def __exit__(self, exc_type, exc_val, exc_tb):
|
| 217 |
+
try:
|
| 218 |
+
if exc_type is not None:
|
| 219 |
+
self.rollback()
|
| 220 |
+
else:
|
| 221 |
+
self.commit()
|
| 222 |
+
finally:
|
| 223 |
+
self.close()
|
| 224 |
+
|
| 225 |
+
def is_supabase_active() -> bool:
|
| 226 |
+
global pg_pool
|
| 227 |
+
if pg_pool is None:
|
| 228 |
+
init_pg_pool()
|
| 229 |
+
return pg_pool is not None
|
| 230 |
+
|
| 231 |
+
def sync_postgres_sequence(table_name: str, column_name: str = "id"):
|
| 232 |
+
if not is_supabase_active():
|
| 233 |
+
return
|
| 234 |
+
safe_table = "".join(ch for ch in str(table_name) if ch.isalnum() or ch == "_")
|
| 235 |
+
safe_column = "".join(ch for ch in str(column_name) if ch.isalnum() or ch == "_")
|
| 236 |
+
if not safe_table or not safe_column:
|
| 237 |
+
return
|
| 238 |
+
conn = get_db_connection()
|
| 239 |
+
cursor = conn.cursor()
|
| 240 |
+
try:
|
| 241 |
+
cursor.execute(f"""
|
| 242 |
+
SELECT setval(
|
| 243 |
+
pg_get_serial_sequence('{safe_table}', '{safe_column}'),
|
| 244 |
+
COALESCE((SELECT MAX({safe_column}) FROM {safe_table}), 0) + 1,
|
| 245 |
+
false
|
| 246 |
+
)
|
| 247 |
+
""")
|
| 248 |
+
conn.commit()
|
| 249 |
+
except Exception as e:
|
| 250 |
+
conn.rollback()
|
| 251 |
+
print(f"Warning: could not sync PostgreSQL sequence for {safe_table}.{safe_column}: {e}")
|
| 252 |
+
finally:
|
| 253 |
+
conn.close()
|
| 254 |
+
|
| 255 |
+
def sync_postgres_sequences():
|
| 256 |
+
if not is_supabase_active():
|
| 257 |
+
return
|
| 258 |
+
for table_name in (
|
| 259 |
+
"tenants",
|
| 260 |
+
"users",
|
| 261 |
+
"stages",
|
| 262 |
+
"companies",
|
| 263 |
+
"contacts",
|
| 264 |
+
"activities",
|
| 265 |
+
"notes",
|
| 266 |
+
"stage_changes",
|
| 267 |
+
"ai_field_values",
|
| 268 |
+
):
|
| 269 |
+
sync_postgres_sequence(table_name)
|
| 270 |
+
|
| 271 |
+
def get_db_connection():
|
| 272 |
+
global pg_pool
|
| 273 |
+
if pg_pool is None:
|
| 274 |
+
init_pg_pool()
|
| 275 |
+
|
| 276 |
+
if pg_pool is not None:
|
| 277 |
+
try:
|
| 278 |
+
conn = pg_pool.getconn()
|
| 279 |
+
conn.autocommit = False
|
| 280 |
+
return PostgresConnectionWrapper(conn)
|
| 281 |
+
except Exception as e:
|
| 282 |
+
print(f"PostgreSQL connection failed, falling back to SQLite: {e}")
|
| 283 |
+
|
| 284 |
+
conn = sqlite3.connect(DB_PATH)
|
| 285 |
+
conn.row_factory = sqlite3.Row
|
| 286 |
+
return conn
|
| 287 |
+
|
| 288 |
+
def dict_from_row(row):
|
| 289 |
+
return dict(row) if row is not None else None
|
| 290 |
+
|
| 291 |
+
def init_db():
|
| 292 |
+
# Execute schema
|
| 293 |
+
conn = get_db_connection()
|
| 294 |
+
cursor = conn.cursor()
|
| 295 |
+
|
| 296 |
+
with open(SCHEMA_PATH, 'r') as f:
|
| 297 |
+
schema_sql = f.read()
|
| 298 |
+
cursor.executescript(schema_sql)
|
| 299 |
+
conn.commit()
|
| 300 |
+
|
| 301 |
+
# Seed stages if empty
|
| 302 |
+
cursor.execute("SELECT COUNT(*) FROM stages")
|
| 303 |
+
if cursor.fetchone()[0] == 0:
|
| 304 |
+
default_stages = [
|
| 305 |
+
("New", 1, "#f1f5f9", "#475569"),
|
| 306 |
+
("Shortlisted", 2, "#eff6ff", "#1d4ed8"),
|
| 307 |
+
("Research Done", 3, "#f5f3ff", "#6d28d9"),
|
| 308 |
+
("Contacted", 4, "#ecfdf5", "#047857"),
|
| 309 |
+
("Engaged", 5, "#f0fdf4", "#15803d"),
|
| 310 |
+
("Meeting Scheduled", 6, "#fffbeb", "#b45309"),
|
| 311 |
+
("Meeting Done", 7, "#fef3c7", "#b45309"),
|
| 312 |
+
("Proposal Sent", 8, "#faf5ff", "#7e22ce"),
|
| 313 |
+
("Won", 9, "#d1fae5", "#065f46"),
|
| 314 |
+
("Paused / Not a Fit", 10, "#fee2e2", "#991b1b")
|
| 315 |
+
]
|
| 316 |
+
cursor.executemany(
|
| 317 |
+
"INSERT INTO stages (name, order_index, color_bg, color_text) VALUES (?, ?, ?, ?)",
|
| 318 |
+
default_stages
|
| 319 |
+
)
|
| 320 |
+
conn.commit()
|
| 321 |
+
|
| 322 |
+
# Seed tenant if empty
|
| 323 |
+
cursor.execute("SELECT COUNT(*) FROM tenants")
|
| 324 |
+
if cursor.fetchone()[0] == 0:
|
| 325 |
+
cursor.execute(
|
| 326 |
+
"INSERT INTO tenants (id, name, brand_color) VALUES (1, 'MapleMPSS', '#3b82f6')"
|
| 327 |
+
)
|
| 328 |
+
conn.commit()
|
| 329 |
+
|
| 330 |
+
# Seed users if empty
|
| 331 |
+
cursor.execute("SELECT COUNT(*) FROM users")
|
| 332 |
+
if cursor.fetchone()[0] == 0:
|
| 333 |
+
default_users = [
|
| 334 |
+
(1, 1, "admin1@maplempss.com", "admin1", "Admin"),
|
| 335 |
+
(2, 1, "bdrep1@maplempss.com", "bd rep1", "BD Rep"),
|
| 336 |
+
(3, 1, "viewer1@maplempss.com", "viewer1", "Viewer"),
|
| 337 |
+
(4, 1, "bdrep2@maplempss.com", "bd rep2", "BD Rep"),
|
| 338 |
+
(5, 1, "bdrep3@maplempss.com", "bd rep3", "BD Rep")
|
| 339 |
+
]
|
| 340 |
+
cursor.executemany(
|
| 341 |
+
"INSERT INTO users (id, tenant_id, email, name, role) VALUES (?, ?, ?, ?, ?)",
|
| 342 |
+
default_users
|
| 343 |
+
)
|
| 344 |
+
conn.commit()
|
| 345 |
+
|
| 346 |
+
# Seed 10 dummy companies if empty
|
| 347 |
+
cursor.execute("SELECT COUNT(*) FROM companies")
|
| 348 |
+
if cursor.fetchone()[0] == 0:
|
| 349 |
+
# Columns:
|
| 350 |
+
# id, tenant_id, name, legal_name, website, phone, nic_code, industry,
|
| 351 |
+
# street, city, province, postal_code, country, size_staff, revenue_band, business_type,
|
| 352 |
+
# products_made, parts_sourced, current_supplier_notes, maplempss_fit_score, stage_id, assigned_rep_id,
|
| 353 |
+
# created_by, updated_by
|
| 354 |
+
dummy_companies = [
|
| 355 |
+
# 1. Tesla Inc. - Large OEM (California), Contacted stage, has 2 contacts, active call history, upcoming follow-up.
|
| 356 |
+
(1, 1, "Tesla Inc.", "Tesla Inc.", "https://www.tesla.com", "1-800-TESLA", "3711", "Automotive OEM",
|
| 357 |
+
"3500 Deer Creek Road", "Palo Alto", "California", "94304", "USA", 100000, "1B+", "OEM",
|
| 358 |
+
"Electric Vehicles, Energy Storage Systems", "Stamping parts, cast aluminum knuckles, fasteners", "Currently sources cast parts globally, looking for localized North American CNC shops.", 4.5, 4, 2, 1, 1),
|
| 359 |
+
|
| 360 |
+
# 2. Northstar Sourcing - Mid distributor (Ontario), Engaged stage, has overdue follow-up.
|
| 361 |
+
(2, 1, "Northstar Sourcing", "Northstar Sourcing Ltd.", "https://www.northstarsourcing.ca", "416-555-0199", "3327", "Aerospace Parts",
|
| 362 |
+
"100 Airport Road", "Toronto", "Ontario", "M5V 2N8", "Canada", 120, "10M-50M", "distributor",
|
| 363 |
+
"Landing gear brackets, actuator assemblies", "Bushings, sleeves, pivot pins", "Supplier in Mexico failing quality checks. Seeking backup CNC capacity in Ontario.", 4.0, 5, 2, 1, 1),
|
| 364 |
+
|
| 365 |
+
# 3. Ontario Automotive OEM - Large OEM (Ontario), New stage, single contact, no activities but has notes.
|
| 366 |
+
(3, 1, "Ontario Automotive OEM", "Ontario Automotive OEM Corp.", "https://www.ontarioautooem.ca", "519-555-0123", "3714", "Auto Parts Mfg",
|
| 367 |
+
"500 Tecumseh Rd E", "Windsor", "Ontario", "N8X 2S2", "Canada", 450, "100M-500M", "OEM",
|
| 368 |
+
"Suspension arms, links, engine mounts", "Steel forgings, CNC machined journals", "Currently single sourced. High risk due to logistics bottlenecks.", 3.5, 1, 2, 1, 1),
|
| 369 |
+
|
| 370 |
+
# 4. Apex Machining Ltd. - Small machine shop (BC), Research Done stage, 0 contacts (edge case: no contacts logged).
|
| 371 |
+
(4, 1, "Apex Machining Ltd.", None, "https://www.apexmachining.ca", None, "3327", "CNC Shop",
|
| 372 |
+
"12330 Vulcan Way", "Richmond", "British Columbia", "V6V 1J8", "Canada", 45, "1M-10M", "other",
|
| 373 |
+
"Custom hydraulic fittings, prototype valves", "Aluminum bars, brass castings", "High precision shop. Might be a sub-contracting partner, not a direct client.", 4.2, 3, 2, 1, 1),
|
| 374 |
+
|
| 375 |
+
# 5. Calgary Metal Press - Small shop, Paused/Not a Fit stage, low fit score.
|
| 376 |
+
(5, 1, "Calgary Metal Press", "Calgary Metal Pressing Co.", None, "403-555-0450", "3321", "Metal Stamping",
|
| 377 |
+
"7800 51 St SE", "Calgary", "Alberta", "T2C 4E3", "Canada", 15, "<1M", "OEM",
|
| 378 |
+
"Stamped brackets, flat washers", "Sheet metal coils", "Too small for our outsourcing program. Margins do not justify logistics. Paused.", 1.8, 10, 2, 1, 1),
|
| 379 |
+
|
| 380 |
+
# 6. Quebec Aerospace Group - Large Aerospace OEM, Won stage, multiple contacts and activities.
|
| 381 |
+
(6, 1, "Quebec Aerospace Group", "Quebec Aerospace Group Inc.", "https://www.quebecaerospace.com", "514-555-0900", "3364", "Aerospace OEM",
|
| 382 |
+
"450 Boulevard Saint-Martin O", "Laval", "Quebec", "H7M 3Y6", "Canada", 850, "500M-1B", "OEM",
|
| 383 |
+
"Turbine blades, housing rings, landing gears", "Inconel castings, titanium shafts", "Closed deal on turbine flange prototypes. Moving to production batches.", 4.8, 9, 2, 1, 1),
|
| 384 |
+
|
| 385 |
+
# 7. Precision Components India - Large global supplier, Shortlisted stage (Edge case: foreign country, fit assessment).
|
| 386 |
+
(7, 1, "Precision Components India", "Precision Components India Pvt. Ltd.", "https://www.precisioncompindia.com", "+91-120-555-0100", "3714", "Auto Parts Mfg",
|
| 387 |
+
"Sector 63, Block C", "Noida", "Uttar Pradesh", "201301", "India", 2100, "100M-500M", "OEM / distributor",
|
| 388 |
+
"Steering gears, universal joints, steering columns", "Steel bars, pinion blanks", "High volume Indian manufacturer. Assessing if logistics cost overrides sourcing discount.", 3.9, 2, 2, 1, 1),
|
| 389 |
+
|
| 390 |
+
# 8. Hamilton Gearworks - Mid OEM, Meeting Scheduled stage, upcoming meeting follow-up.
|
| 391 |
+
(8, 1, "Hamilton Gearworks", "Hamilton Industrial Gearworks Ltd.", "https://www.hamiltongear.ca", "905-555-0111", "3327", "Industrial Gears OEM",
|
| 392 |
+
"250 Gage Ave N", "Hamilton", "Ontario", "L8L 7B1", "Canada", 85, "10M-50M", "OEM",
|
| 393 |
+
"Heavy industrial gearboxes, custom splines", "Cast iron gear cases, forging blanks", "Scheduled discovery call with VP Operations to discuss gearbox housing outsourcing.", 4.1, 6, 2, 1, 1),
|
| 394 |
+
|
| 395 |
+
# 9. WestCoast Castings - Small Foundry, Meeting Done stage, pending next steps.
|
| 396 |
+
(9, 1, "WestCoast Castings", "WestCoast Foundries Ltd.", "https://www.westcoastcastings.com", "604-555-0188", "3315", "Foundry",
|
| 397 |
+
"1550 Kingsway Ave", "Port Coquitlam", "British Columbia", "V3C 6N6", "Canada", 30, "1M-10M", "OEM",
|
| 398 |
+
"Sand cast aluminum & bronze housings", "Ingots, sand molds", "Meeting completed last week. Waiting for their capacity sheet regarding 10-ton casting capacity.", 3.0, 7, 2, 1, 1),
|
| 399 |
+
|
| 400 |
+
# 10. Montreal Custom Fasteners - Micro distributor, Proposal Sent stage, follow-up due today.
|
| 401 |
+
(10, 1, "Montreal Custom Fasteners", None, None, "514-555-0133", "3327", "Fastener Distributor",
|
| 402 |
+
"890 Rue Saint-Jacques", "Montreal", "Quebec", "H3C 1H8", "Canada", 8, "<1M", "distributor",
|
| 403 |
+
"Custom sockets, micro fasteners", "Wire coils", "Proposal for standard fasteners supply sent. Decision-maker review today.", 3.6, 8, 2, 1, 1)
|
| 404 |
+
]
|
| 405 |
+
|
| 406 |
+
cursor.executemany(
|
| 407 |
+
"""INSERT INTO companies (
|
| 408 |
+
id, tenant_id, name, legal_name, website, phone, nic_code, industry,
|
| 409 |
+
street, city, province, postal_code, country, size_staff, revenue_band, business_type,
|
| 410 |
+
products_made, parts_sourced, current_supplier_notes, maplempss_fit_score, stage_id, assigned_rep_id,
|
| 411 |
+
created_by, updated_by
|
| 412 |
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
| 413 |
+
dummy_companies
|
| 414 |
+
)
|
| 415 |
+
conn.commit()
|
| 416 |
+
|
| 417 |
+
# Seed contacts
|
| 418 |
+
# Columns: company_id, first_name, last_name, title, email, phone, linkedin, is_primary
|
| 419 |
+
dummy_contacts = [
|
| 420 |
+
(1, "Elon", "Musk", "CEO", "elon@tesla.com", "1-800-TESLA", "https://linkedin.com/in/elonmusk", 0),
|
| 421 |
+
(1, "Priya", "Sharma", "VP Procurement", "priya.sharma@tesla.com", "650-555-0102", "https://linkedin.com/in/priyasharmaprocurement", 1),
|
| 422 |
+
(2, "David", "Miller", "Sourcing Manager", "david@northstarsourcing.ca", "416-555-0144", "https://linkedin.com/in/davidmillersourcing", 1),
|
| 423 |
+
(3, "Robert", "Chen", "Director of Supply Chain", "r.chen@ontarioautooem.ca", "519-555-0199", "https://linkedin.com/in/robertchensupply", 1),
|
| 424 |
+
# Company 4 has 0 contacts
|
| 425 |
+
(5, "Frank", "Glover", "Operations Manager", "frank@calgarymetalpress.com", "403-555-0451", None, 1),
|
| 426 |
+
(6, "Jean", "Dupont", "VP Procurement", "j.dupont@quebecaerospace.com", "514-555-0901", "https://linkedin.com/in/jeandupont", 1),
|
| 427 |
+
(6, "Marc", "Leclerc", "Buyer", "m.leclerc@quebecaerospace.com", "514-555-0902", None, 0),
|
| 428 |
+
(7, "Rajesh", "Kumar", "Director Sourcing", "r.kumar@precisioncompindia.com", None, None, 1),
|
| 429 |
+
(8, "Sarah", "Connor", "VP Operations", "sarah.connor@hamiltongear.ca", "905-555-0112", "https://linkedin.com/in/sarahconnor", 1),
|
| 430 |
+
(9, "Kevin", "Bacon", "Plant Manager", "kevin@westcoastcastings.com", "604-555-0189", None, 1),
|
| 431 |
+
(10, "Pierre", "Trudeau", "Owner", "pierre@montrealfasteners.com", "514-555-0134", None, 1)
|
| 432 |
+
]
|
| 433 |
+
cursor.executemany(
|
| 434 |
+
"""INSERT INTO contacts (
|
| 435 |
+
company_id, first_name, last_name, title, email, phone, linkedin, is_primary
|
| 436 |
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
| 437 |
+
dummy_contacts
|
| 438 |
+
)
|
| 439 |
+
conn.commit()
|
| 440 |
+
|
| 441 |
+
# Seed activities and notes
|
| 442 |
+
# Columns: company_id, contact_id, user_id, type, outcome, notes, next_action, next_action_date
|
| 443 |
+
dummy_activities = [
|
| 444 |
+
# Tesla - upcoming follow-up
|
| 445 |
+
(1, 2, 2, "Cold Call", "Answered", "Had a brief conversation with Priya. She requested our machinery inventory list and casting capacity details.", "Send CNC capacity list and certifications", "2026-06-25"),
|
| 446 |
+
(1, 2, 2, "Email", "Sent", "Follow-up email containing CNC capacity sheet and ISO registration certificate pdf.", None, None),
|
| 447 |
+
|
| 448 |
+
# Northstar - overdue follow-up (next action date: 2026-06-15)
|
| 449 |
+
(2, 3, 2, "Meeting", "Answered", "Discovery call completed. Discussed landing gear bushing volumes. They source 50,000 units annually. Mexico vendor experiencing major delays.", "Prepare landing gear bushing price quote", "2026-06-15"),
|
| 450 |
+
|
| 451 |
+
# Quebec Aerospace - Won
|
| 452 |
+
(6, 6, 2, "Meeting", "discovery", "Discovery call completed. Inconel flange specifications discussed.", "Prepare proposal", "2026-05-10"),
|
| 453 |
+
(6, 6, 2, "Email", "Sent", "Sent formal proposal quote for 100 prototype flanges.", None, None),
|
| 454 |
+
(6, 6, 2, "Meeting", "negotiation", "Flange proposal accepted. First prototype PO issued.", "Deliver flanges by end of July", "2026-07-31"),
|
| 455 |
+
|
| 456 |
+
# Hamilton Gearworks - upcoming follow-up (next action date: 2026-06-22)
|
| 457 |
+
(8, 9, 2, "LinkedIn Message", "Accepted", "Sarah accepted connection request. Offered to show gearbox housing capabilities.", "Scheduled discovery call on Teams for June 22nd", "2026-06-22"),
|
| 458 |
+
|
| 459 |
+
# Montreal Fasteners - due today (next action date: 2026-06-20)
|
| 460 |
+
(10, 11, 2, "Email", "Sent", "Sent quotation for custom brass socket order. Pricing valid for 30 days.", "Follow up on proposal decision", "2026-06-20")
|
| 461 |
+
]
|
| 462 |
+
cursor.executemany(
|
| 463 |
+
"""INSERT INTO activities (
|
| 464 |
+
company_id, contact_id, user_id, type, outcome, notes, next_action, next_action_date
|
| 465 |
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
|
| 466 |
+
dummy_activities
|
| 467 |
+
)
|
| 468 |
+
conn.commit()
|
| 469 |
+
|
| 470 |
+
# Seed notes
|
| 471 |
+
# Columns: company_id, contact_id, user_id, body, is_pinned
|
| 472 |
+
dummy_notes = [
|
| 473 |
+
(1, 2, 2, "Priya mentioned that delivery delays with current CNC supplier in Mexico are holding up their lines. Extremely high urgency.", 1),
|
| 474 |
+
(2, 3, 2, "Northstar Sourcing prefers DDP shipping terms to their warehouse in Toronto. Must build freight pricing into quote.", 0),
|
| 475 |
+
(3, 4, 2, "Windsor plant operates on a 3-shift rotation. Will require a supplier with strong backup capacity planning.", 0),
|
| 476 |
+
(4, None, 2, "Assessed website products: Apex manufactures high-end hydraulic parts. Might be better suited as a strategic partner than a direct buyer.", 0)
|
| 477 |
+
]
|
| 478 |
+
cursor.executemany(
|
| 479 |
+
"""INSERT INTO notes (
|
| 480 |
+
company_id, contact_id, user_id, body, is_pinned
|
| 481 |
+
) VALUES (?, ?, ?, ?, ?)""",
|
| 482 |
+
dummy_notes
|
| 483 |
+
)
|
| 484 |
+
conn.commit()
|
| 485 |
+
|
| 486 |
+
# Seed initial stage_changes
|
| 487 |
+
dummy_stage_changes = [
|
| 488 |
+
(1, None, 4, 2, "manual"),
|
| 489 |
+
(2, None, 5, 2, "manual"),
|
| 490 |
+
(3, None, 1, 2, "manual"),
|
| 491 |
+
(4, None, 3, 2, "manual"),
|
| 492 |
+
(5, None, 10, 2, "manual"),
|
| 493 |
+
(6, None, 9, 2, "manual"),
|
| 494 |
+
(7, None, 2, 2, "manual"),
|
| 495 |
+
(8, None, 6, 2, "manual"),
|
| 496 |
+
(9, None, 7, 2, "manual"),
|
| 497 |
+
(10, None, 8, 2, "manual")
|
| 498 |
+
]
|
| 499 |
+
cursor.executemany(
|
| 500 |
+
"""INSERT INTO stage_changes (
|
| 501 |
+
company_id, from_stage_id, to_stage_id, changed_by_user_id, source
|
| 502 |
+
) VALUES (?, ?, ?, ?, ?)""",
|
| 503 |
+
dummy_stage_changes
|
| 504 |
+
)
|
| 505 |
+
conn.commit()
|
| 506 |
+
|
| 507 |
+
conn.close()
|
| 508 |
+
|
| 509 |
+
from flask import request
|
| 510 |
+
|
| 511 |
+
def get_request_user():
|
| 512 |
+
from flask import session
|
| 513 |
+
try:
|
| 514 |
+
if 'user_id' in session:
|
| 515 |
+
return session['user_id'], session['user_role'], session['user_name']
|
| 516 |
+
except Exception:
|
| 517 |
+
pass
|
| 518 |
+
|
| 519 |
+
try:
|
| 520 |
+
user_id = request.headers.get("X-User-Id")
|
| 521 |
+
role = request.headers.get("X-User-Role")
|
| 522 |
+
name = request.headers.get("X-User-Name")
|
| 523 |
+
try:
|
| 524 |
+
user_id = int(user_id) if user_id else 2
|
| 525 |
+
except (ValueError, TypeError):
|
| 526 |
+
user_id = 2
|
| 527 |
+
role = role if role else "BD Rep"
|
| 528 |
+
name = name if name else "bd rep1"
|
| 529 |
+
return user_id, role, name
|
| 530 |
+
except Exception:
|
| 531 |
+
return 2, "BD Rep", "bd rep1"
|
| 532 |
+
|
| 533 |
+
def get_request_tenant_id():
|
| 534 |
+
from flask import session
|
| 535 |
+
try:
|
| 536 |
+
if 'tenant_id' in session:
|
| 537 |
+
return session['tenant_id']
|
| 538 |
+
except Exception:
|
| 539 |
+
pass
|
| 540 |
+
|
| 541 |
+
try:
|
| 542 |
+
user_id, _, _ = get_request_user()
|
| 543 |
+
conn = get_db_connection()
|
| 544 |
+
cursor = conn.cursor()
|
| 545 |
+
cursor.execute("SELECT tenant_id FROM users WHERE id = ?", (user_id,))
|
| 546 |
+
row = cursor.fetchone()
|
| 547 |
+
conn.close()
|
| 548 |
+
if row:
|
| 549 |
+
return row[0]
|
| 550 |
+
except Exception:
|
| 551 |
+
pass
|
| 552 |
+
return 1
|
| 553 |
+
|
| 554 |
+
def get_user_access_level(db_conn, user_id, user_role, company_id):
|
| 555 |
+
if user_role in ('Super Admin', 'Admin'):
|
| 556 |
+
return 'full'
|
| 557 |
+
cursor = db_conn.cursor()
|
| 558 |
+
cursor.execute("SELECT assigned_rep_id FROM companies WHERE id = ?", (company_id,))
|
| 559 |
+
row = cursor.fetchone()
|
| 560 |
+
if not row:
|
| 561 |
+
return 'none'
|
| 562 |
+
assigned_rep = row[0]
|
| 563 |
+
if user_role == 'BD Rep':
|
| 564 |
+
if assigned_rep == user_id:
|
| 565 |
+
return 'full'
|
| 566 |
+
else:
|
| 567 |
+
return 'basic'
|
| 568 |
+
if user_role == 'Viewer':
|
| 569 |
+
return 'viewer'
|
| 570 |
+
return 'basic'
|
| 571 |
+
|
| 572 |
+
if __name__ == "__main__":
|
| 573 |
+
init_db()
|
| 574 |
+
print("Database initialized and 10 dummy records seeded successfully.")
|
| 575 |
+
|
| 576 |
+
# =====================================================================
|
| 577 |
+
# Redis Caching Utility Functions (Stage 4)
|
| 578 |
+
# — With in-memory fallback when Redis is unavailable
|
| 579 |
+
# =====================================================================
|
| 580 |
+
import redis
|
| 581 |
+
import json
|
| 582 |
+
import time
|
| 583 |
+
import threading
|
| 584 |
+
from collections import OrderedDict
|
| 585 |
+
|
| 586 |
+
redis_client = None
|
| 587 |
+
_redis_last_attempt = 0
|
| 588 |
+
_REDIS_RETRY_INTERVAL = 60 # seconds between Redis reconnection attempts
|
| 589 |
+
_redis_fallback_warned = False
|
| 590 |
+
|
| 591 |
+
# In-memory fallback cache (thread-safe, TTL-aware, LRU-eviction)
|
| 592 |
+
_mem_cache = OrderedDict() # key -> (value, expiry_timestamp)
|
| 593 |
+
_mem_cache_lock = threading.Lock()
|
| 594 |
+
_MEM_CACHE_MAX_SIZE = 1000
|
| 595 |
+
|
| 596 |
+
def get_redis_client():
|
| 597 |
+
"""Try to return a working Redis client, or None if unavailable."""
|
| 598 |
+
global redis_client, _redis_last_attempt, _redis_fallback_warned
|
| 599 |
+
if redis_client is not None:
|
| 600 |
+
return redis_client
|
| 601 |
+
now = time.time()
|
| 602 |
+
if now - _redis_last_attempt < _REDIS_RETRY_INTERVAL:
|
| 603 |
+
return None
|
| 604 |
+
_redis_last_attempt = now
|
| 605 |
+
try:
|
| 606 |
+
redis_url = (
|
| 607 |
+
os.environ.get("REDIS_URL")
|
| 608 |
+
or os.environ.get("CELERY_BROKER_URL")
|
| 609 |
+
or os.environ.get("CELERY_RESULT_BACKEND")
|
| 610 |
+
or "redis://127.0.0.1:6379/0"
|
| 611 |
+
)
|
| 612 |
+
client = redis.Redis.from_url(redis_url, decode_responses=True, socket_connect_timeout=2)
|
| 613 |
+
client.ping()
|
| 614 |
+
redis_client = client
|
| 615 |
+
if _redis_fallback_warned:
|
| 616 |
+
print("Redis cache connection restored — switching from in-memory fallback to Redis.")
|
| 617 |
+
_redis_fallback_warned = False
|
| 618 |
+
return redis_client
|
| 619 |
+
except Exception as e:
|
| 620 |
+
if not _redis_fallback_warned:
|
| 621 |
+
print(f"Warning: Redis cache unavailable ({e}). Using in-memory fallback cache.")
|
| 622 |
+
_redis_fallback_warned = True
|
| 623 |
+
redis_client = None
|
| 624 |
+
return None
|
| 625 |
+
|
| 626 |
+
# --- In-memory cache helpers ---
|
| 627 |
+
|
| 628 |
+
def _mem_get(key):
|
| 629 |
+
with _mem_cache_lock:
|
| 630 |
+
entry = _mem_cache.get(key)
|
| 631 |
+
if entry is None:
|
| 632 |
+
return None
|
| 633 |
+
value, expiry = entry
|
| 634 |
+
if time.time() > expiry:
|
| 635 |
+
del _mem_cache[key]
|
| 636 |
+
return None
|
| 637 |
+
# Move to end for LRU freshness
|
| 638 |
+
_mem_cache.move_to_end(key)
|
| 639 |
+
return value
|
| 640 |
+
|
| 641 |
+
def _mem_set(key, value, timeout):
|
| 642 |
+
with _mem_cache_lock:
|
| 643 |
+
expiry = time.time() + timeout
|
| 644 |
+
if key in _mem_cache:
|
| 645 |
+
_mem_cache.move_to_end(key)
|
| 646 |
+
_mem_cache[key] = (value, expiry)
|
| 647 |
+
# Evict oldest entries if over capacity
|
| 648 |
+
while len(_mem_cache) > _MEM_CACHE_MAX_SIZE:
|
| 649 |
+
_mem_cache.popitem(last=False)
|
| 650 |
+
|
| 651 |
+
def _mem_delete(key):
|
| 652 |
+
with _mem_cache_lock:
|
| 653 |
+
_mem_cache.pop(key, None)
|
| 654 |
+
|
| 655 |
+
# --- Public cache API (same signatures as before) ---
|
| 656 |
+
|
| 657 |
+
def cache_get(key):
|
| 658 |
+
client = get_redis_client()
|
| 659 |
+
if client:
|
| 660 |
+
try:
|
| 661 |
+
val = client.get(key)
|
| 662 |
+
if val:
|
| 663 |
+
return json.loads(val)
|
| 664 |
+
except Exception as e:
|
| 665 |
+
print(f"Redis cache_get error: {e}")
|
| 666 |
+
# Fallback to in-memory
|
| 667 |
+
return _mem_get(key)
|
| 668 |
+
|
| 669 |
+
def cache_set(key, value, timeout=3600):
|
| 670 |
+
client = get_redis_client()
|
| 671 |
+
if client:
|
| 672 |
+
try:
|
| 673 |
+
client.setex(key, timeout, json.dumps(value))
|
| 674 |
+
return True
|
| 675 |
+
except Exception as e:
|
| 676 |
+
print(f"Redis cache_set error: {e}")
|
| 677 |
+
# Always store in memory as well (acts as fallback + L1 cache)
|
| 678 |
+
_mem_set(key, value, timeout)
|
| 679 |
+
return True
|
| 680 |
+
|
| 681 |
+
def cache_delete(key):
|
| 682 |
+
client = get_redis_client()
|
| 683 |
+
if client:
|
| 684 |
+
try:
|
| 685 |
+
client.delete(key)
|
| 686 |
+
except Exception as e:
|
| 687 |
+
print(f"Redis cache_delete error: {e}")
|
| 688 |
+
_mem_delete(key)
|
| 689 |
+
return True
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
services:
|
| 2 |
+
redis:
|
| 3 |
+
image: redis:7-alpine
|
| 4 |
+
restart: unless-stopped
|
| 5 |
+
command: redis-server --appendonly yes
|
| 6 |
+
volumes:
|
| 7 |
+
- redis_data:/data
|
| 8 |
+
|
| 9 |
+
web:
|
| 10 |
+
build: .
|
| 11 |
+
restart: unless-stopped
|
| 12 |
+
command: gunicorn app:app --bind 0.0.0.0:10000 --workers 2 --timeout 180
|
| 13 |
+
env_file:
|
| 14 |
+
- .env.production
|
| 15 |
+
environment:
|
| 16 |
+
PORT: 10000
|
| 17 |
+
REDIS_URL: redis://redis:6379/0
|
| 18 |
+
CELERY_BROKER_URL: redis://redis:6379/0
|
| 19 |
+
CELERY_RESULT_BACKEND: redis://redis:6379/0
|
| 20 |
+
ports:
|
| 21 |
+
- "80:10000"
|
| 22 |
+
depends_on:
|
| 23 |
+
- redis
|
| 24 |
+
volumes:
|
| 25 |
+
- prospectiq_settings:/app/settings
|
| 26 |
+
- prospectiq_learning_data:/app/learning_data
|
| 27 |
+
- prospectiq_research_output:/app/services/research/pipe_output
|
| 28 |
+
|
| 29 |
+
worker:
|
| 30 |
+
build: .
|
| 31 |
+
restart: unless-stopped
|
| 32 |
+
command: celery -A celery_app.celery_app worker --loglevel=info
|
| 33 |
+
env_file:
|
| 34 |
+
- .env.production
|
| 35 |
+
environment:
|
| 36 |
+
REDIS_URL: redis://redis:6379/0
|
| 37 |
+
CELERY_BROKER_URL: redis://redis:6379/0
|
| 38 |
+
CELERY_RESULT_BACKEND: redis://redis:6379/0
|
| 39 |
+
depends_on:
|
| 40 |
+
- redis
|
| 41 |
+
volumes:
|
| 42 |
+
- prospectiq_settings:/app/settings
|
| 43 |
+
- prospectiq_learning_data:/app/learning_data
|
| 44 |
+
- prospectiq_research_output:/app/services/research/pipe_output
|
| 45 |
+
|
| 46 |
+
volumes:
|
| 47 |
+
redis_data:
|
| 48 |
+
prospectiq_settings:
|
| 49 |
+
prospectiq_learning_data:
|
| 50 |
+
prospectiq_research_output:
|
documentation.md
ADDED
|
@@ -0,0 +1,666 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ProspectIQ Product Guide & Investor Proposal
|
| 2 |
+
|
| 3 |
+
## Executive Summary
|
| 4 |
+
|
| 5 |
+
ProspectIQ is an AI-assisted prospecting and sales workspace that helps a business development team find companies, capture leads, manage contacts, track follow-ups, and move opportunities through a pipeline.
|
| 6 |
+
|
| 7 |
+
It is built for the daily reality of sales work: leads come from many places, notes get scattered, follow-ups are missed, and research takes too long. ProspectIQ brings that work into one clear system. A user can scan a business card, discover companies by location, research a target account, add contacts, track the pipeline, generate outreach support, and keep all activity connected to the right company.
|
| 8 |
+
|
| 9 |
+
For investors, ProspectIQ is positioned as a practical AI business tool, not a generic chatbot. It gives teams a working operating system for prospecting: capture the lead, understand the lead, prioritize the lead, act on the lead, and preserve the learning for future sales decisions.
|
| 10 |
+
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
## Product Snapshot
|
| 14 |
+
|
| 15 |
+

|
| 16 |
+
|
| 17 |
+
ProspectIQ gives users one workspace for:
|
| 18 |
+
|
| 19 |
+
- Managing companies and contacts.
|
| 20 |
+
- Tracking sales stages visually.
|
| 21 |
+
- Capturing business cards and documents.
|
| 22 |
+
- Finding nearby or category-specific prospects.
|
| 23 |
+
- Researching companies.
|
| 24 |
+
- Chatting with an AI assistant that can use company context.
|
| 25 |
+
- Scheduling tasks and follow-ups.
|
| 26 |
+
- Reviewing activity history.
|
| 27 |
+
- Preparing better outreach.
|
| 28 |
+
|
| 29 |
+
The product is designed to be easy to understand for everyday users while still deep enough for serious sales operations.
|
| 30 |
+
|
| 31 |
+
---
|
| 32 |
+
|
| 33 |
+
## The Problem ProspectIQ Solves
|
| 34 |
+
|
| 35 |
+
Sales and business development teams often work across too many disconnected tools:
|
| 36 |
+
|
| 37 |
+
- A spreadsheet for leads.
|
| 38 |
+
- A phone gallery full of business cards.
|
| 39 |
+
- A notes app for meeting comments.
|
| 40 |
+
- Google Maps for local discovery.
|
| 41 |
+
- Email drafts in inboxes.
|
| 42 |
+
- A CRM that is often incomplete.
|
| 43 |
+
- A separate AI chatbot that does not know the customer data.
|
| 44 |
+
|
| 45 |
+
This causes three expensive problems.
|
| 46 |
+
|
| 47 |
+
### 1. Lead Data Gets Lost
|
| 48 |
+
|
| 49 |
+
A promising contact from a trade show, meeting, supplier visit, or directory search may never become a proper record. Even when it does, details are often missing.
|
| 50 |
+
|
| 51 |
+
### 2. Follow-Ups Become Inconsistent
|
| 52 |
+
|
| 53 |
+
Without a shared view of tasks, activities, and next steps, opportunities can sit untouched. Sales momentum fades because the system does not clearly show what should happen next.
|
| 54 |
+
|
| 55 |
+
### 3. Research Takes Too Much Time
|
| 56 |
+
|
| 57 |
+
Before contacting a company, reps need to understand what the company does, who to speak to, why it is a good fit, and what message to send. Doing that manually for every prospect is slow.
|
| 58 |
+
|
| 59 |
+
ProspectIQ solves these problems by turning scattered prospecting work into a structured, repeatable workflow.
|
| 60 |
+
|
| 61 |
+
---
|
| 62 |
+
|
| 63 |
+
## Who ProspectIQ Is For
|
| 64 |
+
|
| 65 |
+
ProspectIQ is useful for any team that needs to build and manage a targeted B2B prospect list.
|
| 66 |
+
|
| 67 |
+
Ideal users include:
|
| 68 |
+
|
| 69 |
+
- Business development representatives.
|
| 70 |
+
- Sales teams.
|
| 71 |
+
- Founders doing outbound sales.
|
| 72 |
+
- Local service businesses selling to offices or commercial sites.
|
| 73 |
+
- Manufacturing, sourcing, and distribution teams.
|
| 74 |
+
- Field sales teams.
|
| 75 |
+
- Agencies building prospect lists for clients.
|
| 76 |
+
- Managers who need visibility into pipeline activity.
|
| 77 |
+
|
| 78 |
+
The strongest early market is small and mid-sized B2B teams that need more structure than a spreadsheet but want something easier and more prospecting-focused than a heavy CRM.
|
| 79 |
+
|
| 80 |
+
---
|
| 81 |
+
|
| 82 |
+
## How to Use ProspectIQ
|
| 83 |
+
|
| 84 |
+
### Step 1: Start on the Home Dashboard
|
| 85 |
+
|
| 86 |
+
The Home dashboard is the daily command center. It shows the current workspace status, active tasks, recent activity, quick actions, and a calendar view.
|
| 87 |
+
|
| 88 |
+
Use it to:
|
| 89 |
+
|
| 90 |
+
- Check total companies and contacts.
|
| 91 |
+
- See active, won, and lost deals.
|
| 92 |
+
- Review today’s tasks.
|
| 93 |
+
- Add quick follow-ups.
|
| 94 |
+
- Open recent company activity.
|
| 95 |
+
- Jump into adding a company, importing contacts, reviewing the pipeline, or opening the learning playbook.
|
| 96 |
+
|
| 97 |
+
This screen helps a user answer: “What should I pay attention to today?”
|
| 98 |
+
|
| 99 |
+

|
| 100 |
+
|
| 101 |
+
### Step 2: Search or Give a Command
|
| 102 |
+
|
| 103 |
+
At the top of the app is the global command bar. It acts like one search box for the whole workspace.
|
| 104 |
+
|
| 105 |
+
Users can type normal business requests such as:
|
| 106 |
+
|
| 107 |
+
- “Show aerospace companies in Ontario.”
|
| 108 |
+
- “Find companies in the Contacted stage.”
|
| 109 |
+
- “Add a task to follow up with Northstar tomorrow.”
|
| 110 |
+
- “Show companies on the board view.”
|
| 111 |
+
- “Add a new company called Hamilton Gearworks.”
|
| 112 |
+
|
| 113 |
+
The command bar reduces clicking and helps users move faster. Instead of remembering exactly where a feature lives, the user can say what they want to do.
|
| 114 |
+
|
| 115 |
+
### Step 3: Manage Companies
|
| 116 |
+
|
| 117 |
+
The Companies screen is where the main prospect list lives.
|
| 118 |
+
|
| 119 |
+
Use it to:
|
| 120 |
+
|
| 121 |
+
- Browse all company records.
|
| 122 |
+
- Search by name, location, industry, or representative.
|
| 123 |
+
- Filter companies by province, city, industry, stage, size, last touched date, or assigned rep.
|
| 124 |
+
- Sort companies by name, fit score, or last activity.
|
| 125 |
+
- Add a new company manually.
|
| 126 |
+
- Select and delete records in bulk.
|
| 127 |
+
- Add custom columns for extra information.
|
| 128 |
+
- Hide or show columns depending on the current workflow.
|
| 129 |
+
- Open a company profile for deeper details.
|
| 130 |
+
|
| 131 |
+

|
| 132 |
+
|
| 133 |
+
This view is best when the user needs a spreadsheet-like overview of the prospect list.
|
| 134 |
+
|
| 135 |
+
### Step 4: Use the Pipeline Board
|
| 136 |
+
|
| 137 |
+
The same company records can also be viewed as a pipeline board.
|
| 138 |
+
|
| 139 |
+
Use it to:
|
| 140 |
+
|
| 141 |
+
- See where every opportunity sits.
|
| 142 |
+
- Review companies by stage.
|
| 143 |
+
- Compare how many prospects are in each stage.
|
| 144 |
+
- Identify stalled stages.
|
| 145 |
+
- Focus on active opportunities.
|
| 146 |
+
|
| 147 |
+

|
| 148 |
+
|
| 149 |
+
The board is best when a manager or sales rep wants to understand sales movement visually.
|
| 150 |
+
|
| 151 |
+
### Step 5: Open a Company Profile
|
| 152 |
+
|
| 153 |
+
Clicking a company opens its profile. This is the account’s home base.
|
| 154 |
+
|
| 155 |
+
Use a company profile to:
|
| 156 |
+
|
| 157 |
+
- Review company details.
|
| 158 |
+
- See website, phone, location, industry, staff size, and business type.
|
| 159 |
+
- Check the company’s current stage.
|
| 160 |
+
- View linked contacts.
|
| 161 |
+
- Read notes.
|
| 162 |
+
- Review outreach activity.
|
| 163 |
+
- Add new notes.
|
| 164 |
+
- Log calls, emails, meetings, and follow-ups.
|
| 165 |
+
- Store important context about fit, sourcing signals, or customer needs.
|
| 166 |
+
|
| 167 |
+
The company profile prevents account knowledge from being scattered across messages, documents, and personal memory.
|
| 168 |
+
|
| 169 |
+
### Step 6: Manage Contacts
|
| 170 |
+
|
| 171 |
+
The Contacts area stores the people connected to each company.
|
| 172 |
+
|
| 173 |
+
Use it to:
|
| 174 |
+
|
| 175 |
+
- Add new contacts.
|
| 176 |
+
- Edit contact details.
|
| 177 |
+
- Track title, email, phone, and LinkedIn.
|
| 178 |
+
- Link each contact to a company.
|
| 179 |
+
- Mark one person as the primary contact.
|
| 180 |
+
- Keep decision-maker information organized.
|
| 181 |
+
|
| 182 |
+
This is especially important for outreach because the quality of a sales process often depends on knowing the right person to contact.
|
| 183 |
+
|
| 184 |
+
### Step 7: Track Activities and Follow-Ups
|
| 185 |
+
|
| 186 |
+
ProspectIQ lets users log the work that happens around each account.
|
| 187 |
+
|
| 188 |
+
Users can track:
|
| 189 |
+
|
| 190 |
+
- Cold calls.
|
| 191 |
+
- Emails.
|
| 192 |
+
- Meetings.
|
| 193 |
+
- LinkedIn messages.
|
| 194 |
+
- Notes.
|
| 195 |
+
- Next actions.
|
| 196 |
+
- Follow-up dates.
|
| 197 |
+
|
| 198 |
+
This creates a timeline of what has happened with each company. It also helps the Home dashboard surface current and upcoming actions.
|
| 199 |
+
|
| 200 |
+
For a sales team, this is one of the most important habits ProspectIQ supports: every conversation should create a next step.
|
| 201 |
+
|
| 202 |
+
### Step 8: Use Tasks for Daily Execution
|
| 203 |
+
|
| 204 |
+
The task section helps users keep small actions visible.
|
| 205 |
+
|
| 206 |
+
Use it to:
|
| 207 |
+
|
| 208 |
+
- Quickly add a task.
|
| 209 |
+
- Mark tasks complete.
|
| 210 |
+
- Edit or delete tasks.
|
| 211 |
+
- Schedule follow-ups.
|
| 212 |
+
- Keep personal daily work close to the sales workspace.
|
| 213 |
+
|
| 214 |
+
Tasks are useful for lightweight execution: call this person, review this account, send the proposal, update a profile, or check a meeting outcome.
|
| 215 |
+
|
| 216 |
+
### Step 9: Capture Business Cards with OCR
|
| 217 |
+
|
| 218 |
+
The OCR tool turns a business card or image document into usable sales data.
|
| 219 |
+
|
| 220 |
+
Use it when:
|
| 221 |
+
|
| 222 |
+
- A rep receives a business card at a meeting.
|
| 223 |
+
- A user has an image with contact details.
|
| 224 |
+
- A company and contact need to be added quickly.
|
| 225 |
+
|
| 226 |
+
How it works from the user’s perspective:
|
| 227 |
+
|
| 228 |
+
1. Open Prospecting Tools.
|
| 229 |
+
2. Select OCR.
|
| 230 |
+
3. Drag and drop a business card image or upload one.
|
| 231 |
+
4. Review the extracted company and contact information.
|
| 232 |
+
5. Edit any fields that need correction.
|
| 233 |
+
6. Confirm and add the record to the CRM.
|
| 234 |
+
|
| 235 |
+

|
| 236 |
+
|
| 237 |
+
This feature converts offline conversations into structured pipeline data.
|
| 238 |
+
|
| 239 |
+
### Step 10: Find Companies with Geo Finder
|
| 240 |
+
|
| 241 |
+
Geo Finder helps users discover companies based on location, category, industry, or search intent.
|
| 242 |
+
|
| 243 |
+
Use it to:
|
| 244 |
+
|
| 245 |
+
- Search for companies in a city or region.
|
| 246 |
+
- Find businesses near a specific area.
|
| 247 |
+
- Identify local prospects by category.
|
| 248 |
+
- Build a ranked list of companies.
|
| 249 |
+
- Export discovery results for further review.
|
| 250 |
+
|
| 251 |
+

|
| 252 |
+
|
| 253 |
+
Example uses:
|
| 254 |
+
|
| 255 |
+
- “Manufacturing companies near Toronto.”
|
| 256 |
+
- “Corporate offices in downtown San Francisco.”
|
| 257 |
+
- “Industrial suppliers around Hamilton.”
|
| 258 |
+
- “Hospitals within this region.”
|
| 259 |
+
|
| 260 |
+
Geo Finder is valuable because it helps users create new prospect lists instead of only managing existing leads.
|
| 261 |
+
|
| 262 |
+
### Step 11: Research Companies with Company Extractor
|
| 263 |
+
|
| 264 |
+
Company Extractor helps turn company research into a structured profile.
|
| 265 |
+
|
| 266 |
+
Use it when:
|
| 267 |
+
|
| 268 |
+
- A user has a company name but needs more context.
|
| 269 |
+
- A rep wants to understand what a target company does.
|
| 270 |
+
- A manager wants a cleaner profile before outreach.
|
| 271 |
+
- A user wants to create a CRM-ready record from research.
|
| 272 |
+
|
| 273 |
+
The user can run a company extraction, review the generated profile, export it, and add useful information into the prospect list.
|
| 274 |
+
|
| 275 |
+

|
| 276 |
+
|
| 277 |
+
This feature reduces the time spent manually reading, copying, and organizing company research.
|
| 278 |
+
|
| 279 |
+
### Step 12: Use Agent Space as the AI Workbench
|
| 280 |
+
|
| 281 |
+
Agent Space is the AI assistant area inside ProspectIQ.
|
| 282 |
+
|
| 283 |
+
Use it to:
|
| 284 |
+
|
| 285 |
+
- Ask questions about a company.
|
| 286 |
+
- Load company context into the conversation.
|
| 287 |
+
- Upload or use extracted card data as context.
|
| 288 |
+
- Prepare sales notes.
|
| 289 |
+
- Draft outreach ideas.
|
| 290 |
+
- Summarize account information.
|
| 291 |
+
- Think through next steps before contacting a prospect.
|
| 292 |
+
|
| 293 |
+

|
| 294 |
+
|
| 295 |
+
Agent Space is different from a generic chatbot because it can work with the user’s prospecting context. The goal is not just to answer general questions, but to help the user act on real account information.
|
| 296 |
+
|
| 297 |
+
### Step 13: Review History
|
| 298 |
+
|
| 299 |
+
ProspectIQ keeps a history of important prospecting runs.
|
| 300 |
+
|
| 301 |
+
Use history to:
|
| 302 |
+
|
| 303 |
+
- Reopen previous OCR captures.
|
| 304 |
+
- Review earlier Geo Finder searches.
|
| 305 |
+
- Open past company research runs.
|
| 306 |
+
- Revisit previous AI conversations.
|
| 307 |
+
- Avoid repeating work.
|
| 308 |
+
|
| 309 |
+
History matters because prospecting is cumulative. The team should be able to learn from previous searches and research instead of starting from zero each time.
|
| 310 |
+
|
| 311 |
+
### Step 14: Export Useful Outputs
|
| 312 |
+
|
| 313 |
+
ProspectIQ supports export workflows for sharing and review.
|
| 314 |
+
|
| 315 |
+
Use exports to:
|
| 316 |
+
|
| 317 |
+
- Download discovered company lists.
|
| 318 |
+
- Save researched company profiles.
|
| 319 |
+
- Share findings with team members.
|
| 320 |
+
- Prepare account summaries for managers or investors.
|
| 321 |
+
|
| 322 |
+
Exports make the product useful outside the app when users need a portable report, list, or profile.
|
| 323 |
+
|
| 324 |
+
### Step 15: Use Learning, Reports, and Settings
|
| 325 |
+
|
| 326 |
+
ProspectIQ also includes supporting areas for team development and oversight.
|
| 327 |
+
|
| 328 |
+
Learning:
|
| 329 |
+
|
| 330 |
+
- Used for onboarding material.
|
| 331 |
+
- Helps new users understand sourcing guidelines, internal playbooks, and domain knowledge.
|
| 332 |
+
|
| 333 |
+
Reports:
|
| 334 |
+
|
| 335 |
+
- Used for reviewing performance and pipeline health.
|
| 336 |
+
- Helps managers understand activity, conversion, and account progress.
|
| 337 |
+
|
| 338 |
+
Settings:
|
| 339 |
+
|
| 340 |
+
- Used for workspace-level preferences and configuration.
|
| 341 |
+
- Helps the team keep the system organized.
|
| 342 |
+
|
| 343 |
+
These sections support the broader business operation around the core sales workflow.
|
| 344 |
+
|
| 345 |
+
---
|
| 346 |
+
|
| 347 |
+
## Full Feature List
|
| 348 |
+
|
| 349 |
+
### Sales Workspace
|
| 350 |
+
|
| 351 |
+
- Home dashboard.
|
| 352 |
+
- Global command bar.
|
| 353 |
+
- Sidebar navigation.
|
| 354 |
+
- Quick actions.
|
| 355 |
+
- Notifications.
|
| 356 |
+
- Daily tasks.
|
| 357 |
+
- Calendar view.
|
| 358 |
+
- Recent activity feed.
|
| 359 |
+
|
| 360 |
+
### Company Management
|
| 361 |
+
|
| 362 |
+
- Company list.
|
| 363 |
+
- Company table view.
|
| 364 |
+
- Pipeline board view.
|
| 365 |
+
- Company profiles.
|
| 366 |
+
- Search and filters.
|
| 367 |
+
- Sorting.
|
| 368 |
+
- Stage tracking.
|
| 369 |
+
- Fit score display.
|
| 370 |
+
- Bulk selection.
|
| 371 |
+
- Bulk delete.
|
| 372 |
+
- Custom columns.
|
| 373 |
+
- Inline custom-field editing.
|
| 374 |
+
|
| 375 |
+
### Contact Management
|
| 376 |
+
|
| 377 |
+
- Contact directory.
|
| 378 |
+
- Company-linked contacts.
|
| 379 |
+
- Primary contact selection.
|
| 380 |
+
- Contact editing.
|
| 381 |
+
- Email, phone, title, and LinkedIn tracking.
|
| 382 |
+
|
| 383 |
+
### Outreach Tracking
|
| 384 |
+
|
| 385 |
+
- Activity logging.
|
| 386 |
+
- Notes.
|
| 387 |
+
- Pinned notes.
|
| 388 |
+
- Next actions.
|
| 389 |
+
- Follow-up dates.
|
| 390 |
+
- Recent activity timeline.
|
| 391 |
+
- Immediate action tracking.
|
| 392 |
+
|
| 393 |
+
### AI and Prospecting Tools
|
| 394 |
+
|
| 395 |
+
- Business card OCR.
|
| 396 |
+
- Editable extraction review.
|
| 397 |
+
- OCR-to-CRM save flow.
|
| 398 |
+
- Geo Finder.
|
| 399 |
+
- Ranked company discovery.
|
| 400 |
+
- Company Extractor.
|
| 401 |
+
- Company profile generation.
|
| 402 |
+
- AI restructuring of research.
|
| 403 |
+
- AI fit analysis.
|
| 404 |
+
- AI briefing and outreach support.
|
| 405 |
+
- Agent Space assistant.
|
| 406 |
+
- Company context loading.
|
| 407 |
+
- Chat history.
|
| 408 |
+
|
| 409 |
+
### Sharing and Review
|
| 410 |
+
|
| 411 |
+
- Geo result export.
|
| 412 |
+
- Company profile export.
|
| 413 |
+
- History for previous runs.
|
| 414 |
+
- Reopen past prospecting work.
|
| 415 |
+
|
| 416 |
+
---
|
| 417 |
+
|
| 418 |
+
## The User Journey
|
| 419 |
+
|
| 420 |
+
ProspectIQ is easiest to understand as a complete sales journey.
|
| 421 |
+
|
| 422 |
+
### 1. Discover
|
| 423 |
+
|
| 424 |
+
The user finds potential companies through Geo Finder, manual entry, OCR, or Company Extractor.
|
| 425 |
+
|
| 426 |
+
### 2. Capture
|
| 427 |
+
|
| 428 |
+
The company and contact details are saved into the workspace. Business cards and research outputs become structured records.
|
| 429 |
+
|
| 430 |
+
### 3. Qualify
|
| 431 |
+
|
| 432 |
+
The user reviews industry, location, staff size, fit score, notes, and AI-generated context to decide whether the company is worth pursuing.
|
| 433 |
+
|
| 434 |
+
### 4. Organize
|
| 435 |
+
|
| 436 |
+
The company is placed into a pipeline stage. Contacts, notes, and activities are kept together.
|
| 437 |
+
|
| 438 |
+
### 5. Act
|
| 439 |
+
|
| 440 |
+
The user logs calls, sends emails, schedules meetings, adds tasks, and creates follow-ups.
|
| 441 |
+
|
| 442 |
+
### 6. Learn
|
| 443 |
+
|
| 444 |
+
The workspace keeps history. Over time, the team can see what types of companies, locations, and outreach actions produce results.
|
| 445 |
+
|
| 446 |
+
---
|
| 447 |
+
|
| 448 |
+
## Business Value
|
| 449 |
+
|
| 450 |
+
ProspectIQ creates value in five practical ways.
|
| 451 |
+
|
| 452 |
+
### 1. Faster Lead Entry
|
| 453 |
+
|
| 454 |
+
Business cards, company research, and geo searches can become CRM-ready records faster than manual typing.
|
| 455 |
+
|
| 456 |
+
### 2. Better Follow-Up Discipline
|
| 457 |
+
|
| 458 |
+
Tasks, activities, next actions, and stages make it harder for good leads to be forgotten.
|
| 459 |
+
|
| 460 |
+
### 3. Cleaner Sales Data
|
| 461 |
+
|
| 462 |
+
Companies, contacts, notes, and activity history are organized around each account.
|
| 463 |
+
|
| 464 |
+
### 4. Smarter Outreach
|
| 465 |
+
|
| 466 |
+
AI assistance helps users understand the company and prepare more relevant communication.
|
| 467 |
+
|
| 468 |
+
### 5. Stronger Management Visibility
|
| 469 |
+
|
| 470 |
+
The pipeline, dashboard, reports, and history make it easier to understand what is happening across the prospecting operation.
|
| 471 |
+
|
| 472 |
+
---
|
| 473 |
+
|
| 474 |
+
## Investor Perspective
|
| 475 |
+
|
| 476 |
+
ProspectIQ is attractive because it targets a painful and common business workflow: turning messy prospect information into revenue activity.
|
| 477 |
+
|
| 478 |
+
The product does not depend on users changing their entire sales process overnight. It improves the work they already do:
|
| 479 |
+
|
| 480 |
+
- Finding prospects.
|
| 481 |
+
- Saving contacts.
|
| 482 |
+
- Tracking companies.
|
| 483 |
+
- Preparing outreach.
|
| 484 |
+
- Following up.
|
| 485 |
+
- Reviewing progress.
|
| 486 |
+
|
| 487 |
+
This gives ProspectIQ a practical adoption path. A team can start with one workflow, such as business card capture or company discovery, then expand into pipeline management and AI-assisted sales execution.
|
| 488 |
+
|
| 489 |
+
---
|
| 490 |
+
|
| 491 |
+
## Market Opportunity
|
| 492 |
+
|
| 493 |
+
ProspectIQ sits between CRM, lead generation, sales intelligence, and AI productivity.
|
| 494 |
+
|
| 495 |
+
Potential customer segments include:
|
| 496 |
+
|
| 497 |
+
- Small and mid-sized B2B sales teams.
|
| 498 |
+
- Local service businesses selling to commercial customers.
|
| 499 |
+
- Industrial and manufacturing sales teams.
|
| 500 |
+
- Procurement and sourcing teams.
|
| 501 |
+
- Agencies doing lead research.
|
| 502 |
+
- Field sales organizations.
|
| 503 |
+
- Founder-led companies building their first outbound process.
|
| 504 |
+
|
| 505 |
+
These teams often need more than a spreadsheet but less complexity than a large enterprise CRM. ProspectIQ can serve that middle ground with a more focused prospecting experience.
|
| 506 |
+
|
| 507 |
+
---
|
| 508 |
+
|
| 509 |
+
## Competitive Advantage
|
| 510 |
+
|
| 511 |
+
### Lead Capture and CRM in One Place
|
| 512 |
+
|
| 513 |
+
Many tools help store leads. Others help generate or research leads. ProspectIQ connects both sides.
|
| 514 |
+
|
| 515 |
+
### Practical AI Inside the Workflow
|
| 516 |
+
|
| 517 |
+
The AI features are tied to everyday sales actions: scan, research, summarize, qualify, brief, and draft.
|
| 518 |
+
|
| 519 |
+
### Context-Aware Assistance
|
| 520 |
+
|
| 521 |
+
Agent Space can use company and contact context, which makes the assistant more useful than a blank chat window.
|
| 522 |
+
|
| 523 |
+
### Flexible Data Tracking
|
| 524 |
+
|
| 525 |
+
Custom columns let teams track the information that matters to their market without waiting for product changes.
|
| 526 |
+
|
| 527 |
+
### Repeatable Sales Memory
|
| 528 |
+
|
| 529 |
+
History, notes, activities, and profiles give the team a memory that compounds over time.
|
| 530 |
+
|
| 531 |
+
---
|
| 532 |
+
|
| 533 |
+
## Revenue Model
|
| 534 |
+
|
| 535 |
+
ProspectIQ can support several revenue streams:
|
| 536 |
+
|
| 537 |
+
- Monthly subscription per user.
|
| 538 |
+
- Team plans for shared workspaces.
|
| 539 |
+
- Usage credits for AI-heavy workflows.
|
| 540 |
+
- Premium exports and reporting.
|
| 541 |
+
- Vertical templates for specific industries.
|
| 542 |
+
- Setup and onboarding services.
|
| 543 |
+
- Custom workflow configuration for larger teams.
|
| 544 |
+
|
| 545 |
+
The most natural starting model is a per-seat subscription with usage limits for advanced AI prospecting features.
|
| 546 |
+
|
| 547 |
+
---
|
| 548 |
+
|
| 549 |
+
## Why Now
|
| 550 |
+
|
| 551 |
+
Sales teams are ready for AI tools, but many AI tools still feel disconnected from real work. ProspectIQ is timely because it applies AI directly to prospecting workflows.
|
| 552 |
+
|
| 553 |
+
The market is ready because:
|
| 554 |
+
|
| 555 |
+
- Teams want faster research.
|
| 556 |
+
- Businesses need cleaner lead data.
|
| 557 |
+
- Reps are expected to do more with fewer tools.
|
| 558 |
+
- AI is becoming normal in daily work.
|
| 559 |
+
- Small and mid-sized teams need simple, useful sales systems.
|
| 560 |
+
- Existing CRMs are often too broad and too manual for early prospecting.
|
| 561 |
+
|
| 562 |
+
ProspectIQ can become the place where prospecting actually happens, not just where final records are stored.
|
| 563 |
+
|
| 564 |
+
---
|
| 565 |
+
|
| 566 |
+
## Success Metrics
|
| 567 |
+
|
| 568 |
+
The product should be measured by outcomes that matter to sales teams.
|
| 569 |
+
|
| 570 |
+
Important metrics include:
|
| 571 |
+
|
| 572 |
+
- Number of leads captured per week.
|
| 573 |
+
- Time saved on manual data entry.
|
| 574 |
+
- Number of business cards converted into records.
|
| 575 |
+
- Number of companies discovered through Geo Finder.
|
| 576 |
+
- Percentage of companies with primary contacts.
|
| 577 |
+
- Number of follow-ups scheduled.
|
| 578 |
+
- Follow-up completion rate.
|
| 579 |
+
- Number of qualified companies moved forward in the pipeline.
|
| 580 |
+
- Meetings booked from ProspectIQ-managed leads.
|
| 581 |
+
- Proposals sent.
|
| 582 |
+
- Won opportunities.
|
| 583 |
+
- User retention and weekly active usage.
|
| 584 |
+
|
| 585 |
+
---
|
| 586 |
+
|
| 587 |
+
## Investment Use of Funds
|
| 588 |
+
|
| 589 |
+
Investment would help turn ProspectIQ from a functional product into a polished commercial platform.
|
| 590 |
+
|
| 591 |
+
Funding can be used for:
|
| 592 |
+
|
| 593 |
+
- Product design polish.
|
| 594 |
+
- Customer onboarding experience.
|
| 595 |
+
- Sales and marketing materials.
|
| 596 |
+
- More investor-ready demos.
|
| 597 |
+
- More reliable AI workflows.
|
| 598 |
+
- Stronger reporting dashboards.
|
| 599 |
+
- Customer pilots.
|
| 600 |
+
- Product packaging and pricing.
|
| 601 |
+
- User testing with real sales teams.
|
| 602 |
+
|
| 603 |
+
The focus should be market validation, user experience, and proving that ProspectIQ saves time while improving lead quality.
|
| 604 |
+
|
| 605 |
+
---
|
| 606 |
+
|
| 607 |
+
## Risks and Mitigations
|
| 608 |
+
|
| 609 |
+
### Risk: Users Already Have a CRM
|
| 610 |
+
|
| 611 |
+
Mitigation:
|
| 612 |
+
|
| 613 |
+
Position ProspectIQ as a prospecting workspace that can either replace lightweight CRMs or feed better data into existing sales processes.
|
| 614 |
+
|
| 615 |
+
### Risk: AI Output May Need Review
|
| 616 |
+
|
| 617 |
+
Mitigation:
|
| 618 |
+
|
| 619 |
+
Keep the user in control. ProspectIQ already uses review and confirmation flows for important captured information.
|
| 620 |
+
|
| 621 |
+
### Risk: Users May Not Change Habits Quickly
|
| 622 |
+
|
| 623 |
+
Mitigation:
|
| 624 |
+
|
| 625 |
+
Start with high-value workflows that are immediately useful, such as OCR capture, Geo Finder, and company research.
|
| 626 |
+
|
| 627 |
+
### Risk: The Market Has Many Sales Tools
|
| 628 |
+
|
| 629 |
+
Mitigation:
|
| 630 |
+
|
| 631 |
+
Focus on the early prospecting workflow, where many teams still rely on manual research and spreadsheets.
|
| 632 |
+
|
| 633 |
+
---
|
| 634 |
+
|
| 635 |
+
## Strategic Narrative
|
| 636 |
+
|
| 637 |
+
ProspectIQ is building a focused operating system for B2B prospecting.
|
| 638 |
+
|
| 639 |
+
The product starts where many sales opportunities begin: a business card, a map search, a company name, a rough note, or a research task. It turns that raw signal into a company record, a contact, a profile, a next action, and eventually a pipeline opportunity.
|
| 640 |
+
|
| 641 |
+
That flow is valuable because prospecting is still one of the messiest parts of sales. Teams do not simply need another place to store data. They need a better way to create, qualify, organize, and act on that data.
|
| 642 |
+
|
| 643 |
+
ProspectIQ can win by owning this workflow:
|
| 644 |
+
|
| 645 |
+
- Discover the company.
|
| 646 |
+
- Capture the details.
|
| 647 |
+
- Understand the fit.
|
| 648 |
+
- Prepare outreach.
|
| 649 |
+
- Track the follow-up.
|
| 650 |
+
- Move the opportunity forward.
|
| 651 |
+
|
| 652 |
+
Over time, every company record, note, activity, and outcome makes the workspace smarter and more valuable.
|
| 653 |
+
|
| 654 |
+
---
|
| 655 |
+
|
| 656 |
+
## Conclusion
|
| 657 |
+
|
| 658 |
+
ProspectIQ is a practical AI-assisted sales workspace for teams that need better prospecting. It brings together company management, contact tracking, pipeline visibility, business card capture, geographic discovery, company research, AI assistance, tasks, notes, and history.
|
| 659 |
+
|
| 660 |
+
The product is easy to explain: it helps teams find better prospects and follow up better.
|
| 661 |
+
|
| 662 |
+
For users, ProspectIQ saves time and brings order to daily sales work. For investors, it represents a focused opportunity in a large market where AI can create immediate operational value.
|
| 663 |
+
|
| 664 |
+
---
|
| 665 |
+
|
| 666 |
+
*ProspectIQ by MapleMPSS. Confidential product guide and investor proposal.*
|
learning_data/collections_meta.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
[]
|
migrate.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
import bcrypt
|
| 4 |
+
from dotenv import load_dotenv
|
| 5 |
+
|
| 6 |
+
# Load env before importing db
|
| 7 |
+
load_dotenv()
|
| 8 |
+
|
| 9 |
+
# Add project root to sys.path
|
| 10 |
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 11 |
+
|
| 12 |
+
from db import get_db_connection, is_supabase_active
|
| 13 |
+
|
| 14 |
+
def hash_password(password: str) -> str:
|
| 15 |
+
salt = bcrypt.gensalt()
|
| 16 |
+
return bcrypt.hashpw(password.encode('utf-8'), salt).decode('utf-8')
|
| 17 |
+
|
| 18 |
+
def run_migrations():
|
| 19 |
+
conn = get_db_connection()
|
| 20 |
+
cursor = conn.cursor()
|
| 21 |
+
is_pg = is_supabase_active()
|
| 22 |
+
print(f"Running migrations on {'PostgreSQL (Supabase)' if is_pg else 'SQLite'}...")
|
| 23 |
+
|
| 24 |
+
try:
|
| 25 |
+
# 1. Create Roles table
|
| 26 |
+
if is_pg:
|
| 27 |
+
cursor.execute("""
|
| 28 |
+
CREATE TABLE IF NOT EXISTS roles (
|
| 29 |
+
id SERIAL PRIMARY KEY,
|
| 30 |
+
tenant_id INTEGER NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
|
| 31 |
+
name VARCHAR(255) NOT NULL,
|
| 32 |
+
description TEXT,
|
| 33 |
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
| 34 |
+
UNIQUE(tenant_id, name)
|
| 35 |
+
)
|
| 36 |
+
""")
|
| 37 |
+
else:
|
| 38 |
+
cursor.execute("""
|
| 39 |
+
CREATE TABLE IF NOT EXISTS roles (
|
| 40 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 41 |
+
tenant_id INTEGER NOT NULL,
|
| 42 |
+
name TEXT NOT NULL,
|
| 43 |
+
description TEXT,
|
| 44 |
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
| 45 |
+
FOREIGN KEY(tenant_id) REFERENCES tenants(id) ON DELETE CASCADE,
|
| 46 |
+
UNIQUE(tenant_id, name)
|
| 47 |
+
)
|
| 48 |
+
""")
|
| 49 |
+
print("Roles table verified/created.")
|
| 50 |
+
|
| 51 |
+
# 2. Create Permissions table
|
| 52 |
+
if is_pg:
|
| 53 |
+
cursor.execute("""
|
| 54 |
+
CREATE TABLE IF NOT EXISTS permissions (
|
| 55 |
+
id SERIAL PRIMARY KEY,
|
| 56 |
+
code VARCHAR(255) UNIQUE NOT NULL,
|
| 57 |
+
description TEXT
|
| 58 |
+
)
|
| 59 |
+
""")
|
| 60 |
+
else:
|
| 61 |
+
cursor.execute("""
|
| 62 |
+
CREATE TABLE IF NOT EXISTS permissions (
|
| 63 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 64 |
+
code TEXT UNIQUE NOT NULL,
|
| 65 |
+
description TEXT
|
| 66 |
+
)
|
| 67 |
+
""")
|
| 68 |
+
print("Permissions table verified/created.")
|
| 69 |
+
|
| 70 |
+
# 3. Create Role Permissions join table
|
| 71 |
+
if is_pg:
|
| 72 |
+
cursor.execute("""
|
| 73 |
+
CREATE TABLE IF NOT EXISTS role_permissions (
|
| 74 |
+
role_id INTEGER NOT NULL REFERENCES roles(id) ON DELETE CASCADE,
|
| 75 |
+
permission_id INTEGER NOT NULL REFERENCES permissions(id) ON DELETE CASCADE,
|
| 76 |
+
PRIMARY KEY(role_id, permission_id)
|
| 77 |
+
)
|
| 78 |
+
""")
|
| 79 |
+
else:
|
| 80 |
+
cursor.execute("""
|
| 81 |
+
CREATE TABLE IF NOT EXISTS role_permissions (
|
| 82 |
+
role_id INTEGER NOT NULL,
|
| 83 |
+
permission_id INTEGER NOT NULL,
|
| 84 |
+
PRIMARY KEY(role_id, permission_id),
|
| 85 |
+
FOREIGN KEY(role_id) REFERENCES roles(id) ON DELETE CASCADE,
|
| 86 |
+
FOREIGN KEY(permission_id) REFERENCES permissions(id) ON DELETE CASCADE
|
| 87 |
+
)
|
| 88 |
+
""")
|
| 89 |
+
print("Role permissions mapping table verified/created.")
|
| 90 |
+
|
| 91 |
+
# 4. Add columns to users table
|
| 92 |
+
if is_pg:
|
| 93 |
+
# Check if columns exist first
|
| 94 |
+
cursor.execute("SELECT column_name FROM information_schema.columns WHERE table_name = 'users' AND table_schema = 'public'")
|
| 95 |
+
columns = [r[0] for r in cursor.fetchall()]
|
| 96 |
+
if 'password_hash' not in columns:
|
| 97 |
+
cursor.execute("ALTER TABLE users ADD COLUMN password_hash TEXT")
|
| 98 |
+
print("Added password_hash column to public.users (Postgres)")
|
| 99 |
+
if 'role_id' not in columns:
|
| 100 |
+
cursor.execute("ALTER TABLE users ADD COLUMN role_id INTEGER REFERENCES roles(id)")
|
| 101 |
+
print("Added role_id column to public.users (Postgres)")
|
| 102 |
+
else:
|
| 103 |
+
# SQLite: Check table info
|
| 104 |
+
cursor.execute("PRAGMA table_info(users)")
|
| 105 |
+
columns = [r[1] for r in cursor.fetchall()]
|
| 106 |
+
if 'password_hash' not in columns:
|
| 107 |
+
cursor.execute("ALTER TABLE users ADD COLUMN password_hash TEXT")
|
| 108 |
+
print("Added password_hash column to users (SQLite)")
|
| 109 |
+
if 'role_id' not in columns:
|
| 110 |
+
cursor.execute("ALTER TABLE users ADD COLUMN role_id INTEGER REFERENCES roles(id)")
|
| 111 |
+
print("Added role_id column to users (SQLite)")
|
| 112 |
+
|
| 113 |
+
conn.commit()
|
| 114 |
+
|
| 115 |
+
# 5. Seed permissions
|
| 116 |
+
default_permissions = [
|
| 117 |
+
("companies:read", "Read access to company listings and profiles"),
|
| 118 |
+
("companies:write", "Create, update, or edit company data"),
|
| 119 |
+
("companies:delete", "Delete company records"),
|
| 120 |
+
("contacts:read", "View contacts"),
|
| 121 |
+
("contacts:write", "Create, update, or delete contacts"),
|
| 122 |
+
("prospecting:use", "Execute OCR, Geo Finder, or AI Company Extractor runs"),
|
| 123 |
+
("settings:manage", "Edit tenant settings, stages, and metadata"),
|
| 124 |
+
("users:manage", "Provision users, create roles, and alter role permissions")
|
| 125 |
+
]
|
| 126 |
+
|
| 127 |
+
for code, desc in default_permissions:
|
| 128 |
+
if is_pg:
|
| 129 |
+
cursor.execute("INSERT INTO permissions (code, description) VALUES (%s, %s) ON CONFLICT (code) DO NOTHING", (code, desc))
|
| 130 |
+
else:
|
| 131 |
+
cursor.execute("INSERT OR IGNORE INTO permissions (code, description) VALUES (?, ?)", (code, desc))
|
| 132 |
+
conn.commit()
|
| 133 |
+
print("Standard permissions seeded successfully.")
|
| 134 |
+
|
| 135 |
+
# 6. Migrate existing users and tenants
|
| 136 |
+
# Fetch all unique tenants in users
|
| 137 |
+
cursor.execute("SELECT DISTINCT tenant_id FROM users")
|
| 138 |
+
tenant_ids = [r[0] for r in cursor.fetchall()]
|
| 139 |
+
|
| 140 |
+
for tenant_id in tenant_ids:
|
| 141 |
+
# Check/create standard roles for each tenant
|
| 142 |
+
for role_name in ["Admin", "BD Rep", "Viewer"]:
|
| 143 |
+
if is_pg:
|
| 144 |
+
cursor.execute("INSERT INTO roles (tenant_id, name, description) VALUES (%s, %s, %s) ON CONFLICT (tenant_id, name) DO NOTHING", (tenant_id, role_name, f"Default {role_name} role"))
|
| 145 |
+
else:
|
| 146 |
+
cursor.execute("INSERT OR IGNORE INTO roles (tenant_id, name, description) VALUES (?, ?, ?)", (tenant_id, role_name, f"Default {role_name} role"))
|
| 147 |
+
conn.commit()
|
| 148 |
+
|
| 149 |
+
# Grant permissions to roles for this tenant
|
| 150 |
+
# Admin gets all permissions
|
| 151 |
+
cursor.execute("SELECT id FROM roles WHERE tenant_id = ? AND name = ?", (tenant_id, "Admin"))
|
| 152 |
+
admin_role_id = cursor.fetchone()[0]
|
| 153 |
+
cursor.execute("SELECT id FROM permissions")
|
| 154 |
+
all_perm_ids = [r[0] for r in cursor.fetchall()]
|
| 155 |
+
for pid in all_perm_ids:
|
| 156 |
+
if is_pg:
|
| 157 |
+
cursor.execute("INSERT INTO role_permissions (role_id, permission_id) VALUES (%s, %s) ON CONFLICT DO NOTHING", (admin_role_id, pid))
|
| 158 |
+
else:
|
| 159 |
+
cursor.execute("INSERT OR IGNORE INTO role_permissions (role_id, permission_id) VALUES (?, ?)", (admin_role_id, pid))
|
| 160 |
+
|
| 161 |
+
# BD Rep gets non-admin permissions
|
| 162 |
+
cursor.execute("SELECT id FROM roles WHERE tenant_id = ? AND name = ?", (tenant_id, "BD Rep"))
|
| 163 |
+
bd_role_id = cursor.fetchone()[0]
|
| 164 |
+
cursor.execute("SELECT id FROM permissions WHERE code NOT IN ('users:manage', 'settings:manage')")
|
| 165 |
+
bd_perm_ids = [r[0] for r in cursor.fetchall()]
|
| 166 |
+
for pid in bd_perm_ids:
|
| 167 |
+
if is_pg:
|
| 168 |
+
cursor.execute("INSERT INTO role_permissions (role_id, permission_id) VALUES (%s, %s) ON CONFLICT DO NOTHING", (bd_role_id, pid))
|
| 169 |
+
else:
|
| 170 |
+
cursor.execute("INSERT OR IGNORE INTO role_permissions (role_id, permission_id) VALUES (?, ?)", (bd_role_id, pid))
|
| 171 |
+
|
| 172 |
+
# Viewer gets read permissions
|
| 173 |
+
cursor.execute("SELECT id FROM roles WHERE tenant_id = ? AND name = ?", (tenant_id, "Viewer"))
|
| 174 |
+
viewer_role_id = cursor.fetchone()[0]
|
| 175 |
+
cursor.execute("SELECT id FROM permissions WHERE code IN ('companies:read', 'contacts:read')")
|
| 176 |
+
viewer_perm_ids = [r[0] for r in cursor.fetchall()]
|
| 177 |
+
for pid in viewer_perm_ids:
|
| 178 |
+
if is_pg:
|
| 179 |
+
cursor.execute("INSERT INTO role_permissions (role_id, permission_id) VALUES (%s, %s) ON CONFLICT DO NOTHING", (viewer_role_id, pid))
|
| 180 |
+
else:
|
| 181 |
+
cursor.execute("INSERT OR IGNORE INTO role_permissions (role_id, permission_id) VALUES (?, ?)", (viewer_role_id, pid))
|
| 182 |
+
conn.commit()
|
| 183 |
+
|
| 184 |
+
# 7. Update users with correct role_id and default password_hash
|
| 185 |
+
cursor.execute("SELECT id, tenant_id, role, password_hash FROM users")
|
| 186 |
+
users_rows = cursor.fetchall()
|
| 187 |
+
|
| 188 |
+
default_hash = hash_password("password123")
|
| 189 |
+
for u in users_rows:
|
| 190 |
+
uid, tenant_id, role_str, pwd_hash = u[0], u[1], u[2], u[3]
|
| 191 |
+
|
| 192 |
+
# Map role_str to role_id
|
| 193 |
+
cursor.execute("SELECT id FROM roles WHERE tenant_id = ? AND name = ?", (tenant_id, role_str))
|
| 194 |
+
role_row = cursor.fetchone()
|
| 195 |
+
if role_row:
|
| 196 |
+
target_role_id = role_row[0]
|
| 197 |
+
cursor.execute("UPDATE users SET role_id = ? WHERE id = ?", (target_role_id, uid))
|
| 198 |
+
|
| 199 |
+
# Update password if not set
|
| 200 |
+
if not pwd_hash:
|
| 201 |
+
cursor.execute("UPDATE users SET password_hash = ? WHERE id = ?", (default_hash, uid))
|
| 202 |
+
|
| 203 |
+
conn.commit()
|
| 204 |
+
print("Users successfully mapped to dynamic roles and default passwords seeded (password123).")
|
| 205 |
+
print("Migration complete!")
|
| 206 |
+
except Exception as e:
|
| 207 |
+
conn.rollback()
|
| 208 |
+
print("Migration failed:", e)
|
| 209 |
+
raise e
|
| 210 |
+
finally:
|
| 211 |
+
conn.close()
|
| 212 |
+
|
| 213 |
+
if __name__ == "__main__":
|
| 214 |
+
run_migrations()
|
render.yaml
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
services:
|
| 2 |
+
- type: keyvalue
|
| 3 |
+
name: prospectiq-redis
|
| 4 |
+
plan: starter
|
| 5 |
+
ipAllowList: []
|
| 6 |
+
|
| 7 |
+
- type: web
|
| 8 |
+
name: prospectiq-web
|
| 9 |
+
runtime: docker
|
| 10 |
+
plan: starter
|
| 11 |
+
dockerfilePath: ./Dockerfile
|
| 12 |
+
dockerCommand: gunicorn app:app --bind 0.0.0.0:${PORT} --workers ${WEB_CONCURRENCY} --timeout ${WEB_TIMEOUT}
|
| 13 |
+
healthCheckPath: /
|
| 14 |
+
envVars:
|
| 15 |
+
- key: REDIS_URL
|
| 16 |
+
fromService:
|
| 17 |
+
type: keyvalue
|
| 18 |
+
name: prospectiq-redis
|
| 19 |
+
property: connectionString
|
| 20 |
+
- key: CELERY_BROKER_URL
|
| 21 |
+
fromService:
|
| 22 |
+
type: keyvalue
|
| 23 |
+
name: prospectiq-redis
|
| 24 |
+
property: connectionString
|
| 25 |
+
- key: CELERY_RESULT_BACKEND
|
| 26 |
+
fromService:
|
| 27 |
+
type: keyvalue
|
| 28 |
+
name: prospectiq-redis
|
| 29 |
+
property: connectionString
|
| 30 |
+
- key: PORT
|
| 31 |
+
value: 10000
|
| 32 |
+
- key: WEB_CONCURRENCY
|
| 33 |
+
value: 2
|
| 34 |
+
- key: WEB_TIMEOUT
|
| 35 |
+
value: 180
|
| 36 |
+
- key: GEMINI_API_KEY_prime_1
|
| 37 |
+
sync: false
|
| 38 |
+
- key: GEMINI_API_KEY_prime_2
|
| 39 |
+
sync: false
|
| 40 |
+
- key: GEMINI_API_KEY_prime_3
|
| 41 |
+
sync: false
|
| 42 |
+
- key: SUPABASE_DB_HOST
|
| 43 |
+
sync: false
|
| 44 |
+
- key: SUPABASE_DB_NAME
|
| 45 |
+
value: postgres
|
| 46 |
+
- key: SUPABASE_DB_USER
|
| 47 |
+
value: postgres
|
| 48 |
+
- key: SUPABASE_DB_PASSWORD
|
| 49 |
+
sync: false
|
| 50 |
+
- key: SUPABASE_DB_PORT
|
| 51 |
+
value: 6543
|
| 52 |
+
|
| 53 |
+
- type: worker
|
| 54 |
+
name: prospectiq-worker
|
| 55 |
+
runtime: docker
|
| 56 |
+
plan: starter
|
| 57 |
+
dockerfilePath: ./Dockerfile
|
| 58 |
+
dockerCommand: celery -A celery_app.celery_app worker --loglevel=info
|
| 59 |
+
envVars:
|
| 60 |
+
- key: REDIS_URL
|
| 61 |
+
fromService:
|
| 62 |
+
type: keyvalue
|
| 63 |
+
name: prospectiq-redis
|
| 64 |
+
property: connectionString
|
| 65 |
+
- key: CELERY_BROKER_URL
|
| 66 |
+
fromService:
|
| 67 |
+
type: keyvalue
|
| 68 |
+
name: prospectiq-redis
|
| 69 |
+
property: connectionString
|
| 70 |
+
- key: CELERY_RESULT_BACKEND
|
| 71 |
+
fromService:
|
| 72 |
+
type: keyvalue
|
| 73 |
+
name: prospectiq-redis
|
| 74 |
+
property: connectionString
|
| 75 |
+
- key: GEMINI_API_KEY_prime_1
|
| 76 |
+
sync: false
|
| 77 |
+
- key: GEMINI_API_KEY_prime_2
|
| 78 |
+
sync: false
|
| 79 |
+
- key: GEMINI_API_KEY_prime_3
|
| 80 |
+
sync: false
|
| 81 |
+
- key: SUPABASE_DB_HOST
|
| 82 |
+
sync: false
|
| 83 |
+
- key: SUPABASE_DB_NAME
|
| 84 |
+
value: postgres
|
| 85 |
+
- key: SUPABASE_DB_USER
|
| 86 |
+
value: postgres
|
| 87 |
+
- key: SUPABASE_DB_PASSWORD
|
| 88 |
+
sync: false
|
| 89 |
+
- key: SUPABASE_DB_PORT
|
| 90 |
+
value: 6543
|
requirements.txt
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
aiohappyeyeballs==2.6.2
|
| 2 |
+
aiohttp==3.14.1
|
| 3 |
+
aiosignal==1.4.0
|
| 4 |
+
annotated-doc==0.0.4
|
| 5 |
+
annotated-types==0.7.0
|
| 6 |
+
anyio==4.14.1
|
| 7 |
+
attrs==26.1.0
|
| 8 |
+
bcrypt==5.0.0
|
| 9 |
+
blinker==1.9.0
|
| 10 |
+
build==1.5.0
|
| 11 |
+
certifi==2026.6.17
|
| 12 |
+
cffi==2.0.0
|
| 13 |
+
charset-normalizer==3.4.7
|
| 14 |
+
click==8.4.2
|
| 15 |
+
colorama==0.4.6
|
| 16 |
+
cryptography==49.0.0
|
| 17 |
+
decorator==5.3.1
|
| 18 |
+
distro==1.9.0
|
| 19 |
+
durationpy==0.10
|
| 20 |
+
et_xmlfile==2.0.0
|
| 21 |
+
filelock==3.29.4
|
| 22 |
+
filetype==1.2.0
|
| 23 |
+
Flask==3.1.3
|
| 24 |
+
flatbuffers==25.12.19
|
| 25 |
+
frozenlist==1.8.0
|
| 26 |
+
fsspec==2026.6.0
|
| 27 |
+
google-auth==2.55.1
|
| 28 |
+
google-genai==2.10.0
|
| 29 |
+
googleapis-common-protos==1.75.0
|
| 30 |
+
greenlet==3.5.3
|
| 31 |
+
gunicorn==23.0.0
|
| 32 |
+
grpcio==1.81.1
|
| 33 |
+
h11==0.16.0
|
| 34 |
+
hf-xet==1.5.1
|
| 35 |
+
httpcore==1.0.9
|
| 36 |
+
httptools==0.8.0
|
| 37 |
+
httpx==0.28.1
|
| 38 |
+
httpx-sse==0.4.3
|
| 39 |
+
huggingface_hub==1.21.0
|
| 40 |
+
idna==3.18
|
| 41 |
+
ImageIO==2.37.3
|
| 42 |
+
imageio-ffmpeg==0.6.0
|
| 43 |
+
importlib_resources==7.1.0
|
| 44 |
+
itsdangerous==2.2.0
|
| 45 |
+
Jinja2==3.1.6
|
| 46 |
+
jsonpatch==1.33
|
| 47 |
+
jsonpointer==3.1.1
|
| 48 |
+
jsonschema==4.26.0
|
| 49 |
+
jsonschema-specifications==2025.9.1
|
| 50 |
+
kubernetes==36.0.2
|
| 51 |
+
langchain==1.3.11
|
| 52 |
+
langchain-classic==1.0.8
|
| 53 |
+
langchain-community==0.4.2
|
| 54 |
+
langchain-core==1.4.8
|
| 55 |
+
langchain-google-genai==4.2.6
|
| 56 |
+
langchain-protocol==0.0.18
|
| 57 |
+
langchain-text-splitters==1.1.2
|
| 58 |
+
langgraph==1.2.6
|
| 59 |
+
langgraph-checkpoint==4.1.1
|
| 60 |
+
langgraph-prebuilt==1.1.0
|
| 61 |
+
langgraph-sdk==0.4.2
|
| 62 |
+
langsmith==0.9.3
|
| 63 |
+
markdown-it-py==4.2.0
|
| 64 |
+
MarkupSafe==3.0.3
|
| 65 |
+
mdurl==0.1.2
|
| 66 |
+
mmh3==5.2.1
|
| 67 |
+
moviepy==2.2.1
|
| 68 |
+
multidict==6.7.1
|
| 69 |
+
numpy==2.4.6
|
| 70 |
+
oauthlib==3.3.1
|
| 71 |
+
onnxruntime==1.27.0
|
| 72 |
+
openpyxl==3.1.5
|
| 73 |
+
opentelemetry-api==1.43.0
|
| 74 |
+
opentelemetry-exporter-otlp-proto-common==1.43.0
|
| 75 |
+
opentelemetry-exporter-otlp-proto-grpc==1.43.0
|
| 76 |
+
opentelemetry-proto==1.43.0
|
| 77 |
+
opentelemetry-sdk==1.43.0
|
| 78 |
+
opentelemetry-semantic-conventions==0.64b0
|
| 79 |
+
orjson==3.11.9
|
| 80 |
+
ormsgpack==1.12.2
|
| 81 |
+
overrides==7.7.0
|
| 82 |
+
packaging==26.2
|
| 83 |
+
pillow==11.3.0
|
| 84 |
+
proglog==0.1.12
|
| 85 |
+
propcache==0.5.2
|
| 86 |
+
protobuf==7.35.1
|
| 87 |
+
pyasn1==0.6.3
|
| 88 |
+
pyasn1_modules==0.4.2
|
| 89 |
+
pybase64==1.4.3
|
| 90 |
+
pycparser==3.0
|
| 91 |
+
psycopg2-binary==2.9.12
|
| 92 |
+
pydantic==2.13.4
|
| 93 |
+
pydantic-settings==2.14.2
|
| 94 |
+
pydantic_core==2.46.4
|
| 95 |
+
Pygments==2.20.0
|
| 96 |
+
pypdf==6.14.2
|
| 97 |
+
PyPika==0.51.1
|
| 98 |
+
pyproject_hooks==1.2.0
|
| 99 |
+
python-dateutil==2.9.0.post0
|
| 100 |
+
python-dotenv==1.2.2
|
| 101 |
+
PyYAML==6.0.3
|
| 102 |
+
referencing==0.37.0
|
| 103 |
+
requests==2.34.2
|
| 104 |
+
requests-oauthlib==2.0.0
|
| 105 |
+
requests-toolbelt==1.0.0
|
| 106 |
+
rich==15.0.0
|
| 107 |
+
rpds-py==2026.5.1
|
| 108 |
+
serpapi==1.0.2
|
| 109 |
+
shellingham==1.5.4
|
| 110 |
+
six==1.17.0
|
| 111 |
+
sniffio==1.3.1
|
| 112 |
+
SpeechRecognition==3.17.0
|
| 113 |
+
SQLAlchemy==2.0.51
|
| 114 |
+
tenacity==9.1.4
|
| 115 |
+
tokenizers==0.23.1
|
| 116 |
+
tqdm==4.68.3
|
| 117 |
+
typer==0.25.1
|
| 118 |
+
typing-inspection==0.4.2
|
| 119 |
+
typing_extensions==4.15.0
|
| 120 |
+
urllib3==2.7.0
|
| 121 |
+
uuid_utils==0.16.2
|
| 122 |
+
uvicorn==0.49.0
|
| 123 |
+
watchfiles==1.2.0
|
| 124 |
+
websocket-client==1.9.0
|
| 125 |
+
websockets==15.0.1
|
| 126 |
+
Werkzeug==3.1.8
|
| 127 |
+
xxhash==3.8.0
|
| 128 |
+
yarl==1.24.2
|
| 129 |
+
zstandard==0.25.0
|
| 130 |
+
langchain-pinecone
|
| 131 |
+
pinecone-client
|
| 132 |
+
pinecone
|
| 133 |
+
pyreadline3; platform_system == "Windows"
|
| 134 |
+
celery
|
| 135 |
+
redis
|
| 136 |
+
selenium
|
| 137 |
+
webdriver-manager
|
| 138 |
+
reportlab
|
| 139 |
+
markdown
|
| 140 |
+
beautifulsoup4
|
| 141 |
+
anthropic
|
| 142 |
+
unstructured[xlsx]
|
| 143 |
+
pandas
|
| 144 |
+
networkx
|
| 145 |
+
supabase
|
| 146 |
+
ddgs
|
| 147 |
+
langchain-excel-loader
|
routes/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Routes package for ProspectIQ."""
|
routes/ai.py
ADDED
|
@@ -0,0 +1,912 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
import re
|
| 5 |
+
import threading
|
| 6 |
+
import base64
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import Any
|
| 9 |
+
from flask import Blueprint, jsonify, request, send_file
|
| 10 |
+
|
| 11 |
+
from services.export_service import build_profile_pdf, build_geo_excel, parse_geo_companies, parse_geo_companies_from_stages
|
| 12 |
+
from services.ai_service import (
|
| 13 |
+
JOBS,
|
| 14 |
+
JOBS_LOCK,
|
| 15 |
+
PIPELINE_SCRIPT,
|
| 16 |
+
append_log,
|
| 17 |
+
clean_int,
|
| 18 |
+
current_job_status,
|
| 19 |
+
db_get_interaction,
|
| 20 |
+
db_list_interactions,
|
| 21 |
+
db_prune_geo_history,
|
| 22 |
+
db_update_interaction,
|
| 23 |
+
fallback_profile_path,
|
| 24 |
+
find_final_profile,
|
| 25 |
+
gemini_keys,
|
| 26 |
+
get_base_company_context,
|
| 27 |
+
get_job_state,
|
| 28 |
+
list_profiles,
|
| 29 |
+
new_job,
|
| 30 |
+
now,
|
| 31 |
+
resolve_project_file,
|
| 32 |
+
run_company_research_job,
|
| 33 |
+
run_geo_job,
|
| 34 |
+
safe_read_profile,
|
| 35 |
+
safe_read_text,
|
| 36 |
+
stop_process_tree,
|
| 37 |
+
update_job,
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
ai_bp = Blueprint("ai", __name__)
|
| 41 |
+
|
| 42 |
+
ALLOWED_IMAGE_MIME_TYPES = {
|
| 43 |
+
"image/jpeg",
|
| 44 |
+
"image/png",
|
| 45 |
+
"image/webp",
|
| 46 |
+
"image/gif",
|
| 47 |
+
"image/bmp",
|
| 48 |
+
"image/tiff",
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
def get_openrouter_key_response():
|
| 52 |
+
key = os.environ.get("OPENROUTER_API_KEY", "").strip()
|
| 53 |
+
if not key:
|
| 54 |
+
return None, (jsonify({"error": "OPENROUTER_API_KEY is not configured."}), 503)
|
| 55 |
+
return key, None
|
| 56 |
+
|
| 57 |
+
def get_gemini_key() -> str:
|
| 58 |
+
keys = gemini_keys()
|
| 59 |
+
return keys[0] if keys else ""
|
| 60 |
+
|
| 61 |
+
def create_completed_interaction(
|
| 62 |
+
kind: str,
|
| 63 |
+
title: str,
|
| 64 |
+
input_payload: dict[str, Any],
|
| 65 |
+
result: dict[str, Any] | None = None,
|
| 66 |
+
error: str = "",
|
| 67 |
+
) -> str:
|
| 68 |
+
job_id = new_job(kind, title, input_payload)
|
| 69 |
+
update_job(
|
| 70 |
+
job_id,
|
| 71 |
+
status="failed" if error else "completed",
|
| 72 |
+
started_at=now(),
|
| 73 |
+
finished_at=now(),
|
| 74 |
+
result=result or {},
|
| 75 |
+
error=error,
|
| 76 |
+
)
|
| 77 |
+
return job_id
|
| 78 |
+
|
| 79 |
+
def enqueue_celery_task(task_func, fallback_func, *args):
|
| 80 |
+
try:
|
| 81 |
+
task_func.delay(*args)
|
| 82 |
+
return True
|
| 83 |
+
except Exception as exc:
|
| 84 |
+
print(f"Warning: Celery enqueue failed for {getattr(task_func, 'name', task_func)}: {exc}")
|
| 85 |
+
thread = threading.Thread(target=fallback_func, args=args, daemon=True)
|
| 86 |
+
thread.start()
|
| 87 |
+
return False
|
| 88 |
+
|
| 89 |
+
def geo_result_payload(job_id: str) -> dict[str, Any] | None:
|
| 90 |
+
payload = get_job_state(job_id)
|
| 91 |
+
with JOBS_LOCK:
|
| 92 |
+
job = JOBS.get(job_id)
|
| 93 |
+
if not payload and job:
|
| 94 |
+
payload = dict(job)
|
| 95 |
+
if not payload or payload.get("kind") != "geo":
|
| 96 |
+
return None
|
| 97 |
+
|
| 98 |
+
result = payload.get("result")
|
| 99 |
+
if not isinstance(result, dict):
|
| 100 |
+
return payload
|
| 101 |
+
if result.get("result") and not result.get("companies"):
|
| 102 |
+
result["companies"] = parse_geo_companies(str(result.get("result") or ""))
|
| 103 |
+
if not result.get("companies") and result.get("stages"):
|
| 104 |
+
fallback_companies = parse_geo_companies_from_stages(result.get("stages") or [])
|
| 105 |
+
if fallback_companies:
|
| 106 |
+
result["companies"] = fallback_companies
|
| 107 |
+
result["ranking_unavailable"] = True
|
| 108 |
+
result["notice"] = "Ranking was not available, so these discovered companies are shown unranked."
|
| 109 |
+
payload["result"] = result
|
| 110 |
+
return payload
|
| 111 |
+
|
| 112 |
+
def safe_download_name(value: str, fallback: str = "geo-results") -> str:
|
| 113 |
+
name = re.sub(r"[^A-Za-z0-9._-]+", "-", value.strip()).strip("-._")
|
| 114 |
+
return (name[:90] or fallback) + ".xlsx"
|
| 115 |
+
|
| 116 |
+
def safe_pdf_name(value: str, fallback: str = "company-profile") -> str:
|
| 117 |
+
name = re.sub(r"[^A-Za-z0-9._-]+", "-", value.strip()).strip("-._")
|
| 118 |
+
return (name[:90] or fallback) + ".pdf"
|
| 119 |
+
|
| 120 |
+
# =====================================================================
|
| 121 |
+
# Business Card OCR Route
|
| 122 |
+
# =====================================================================
|
| 123 |
+
|
| 124 |
+
@ai_bp.post("/api/ocr")
|
| 125 |
+
def api_ocr():
|
| 126 |
+
uploaded_file = request.files.get("card")
|
| 127 |
+
if not uploaded_file or not uploaded_file.filename:
|
| 128 |
+
return jsonify({"error": "Upload a business card or document image."}), 400
|
| 129 |
+
|
| 130 |
+
mime_type = uploaded_file.mimetype or "application/octet-stream"
|
| 131 |
+
input_payload = {"filename": uploaded_file.filename, "mime_type": mime_type}
|
| 132 |
+
if mime_type not in ALLOWED_IMAGE_MIME_TYPES:
|
| 133 |
+
create_completed_interaction("ocr", uploaded_file.filename, input_payload, error="Unsupported image type.")
|
| 134 |
+
return jsonify({"error": "Upload a JPG, PNG, WebP, GIF, BMP, or TIFF image."}), 400
|
| 135 |
+
|
| 136 |
+
file_content_b64 = base64.b64encode(uploaded_file.read()).decode("ascii")
|
| 137 |
+
job_id = new_job("ocr", uploaded_file.filename, input_payload)
|
| 138 |
+
|
| 139 |
+
from tasks import ocr_task
|
| 140 |
+
|
| 141 |
+
def fallback_ocr(job_id_arg: str, filename_arg: str, mime_type_arg: str, file_content_arg: str):
|
| 142 |
+
ocr_task.run(job_id_arg, filename_arg, mime_type_arg, file_content_arg)
|
| 143 |
+
|
| 144 |
+
used_celery = enqueue_celery_task(
|
| 145 |
+
ocr_task,
|
| 146 |
+
fallback_ocr,
|
| 147 |
+
job_id,
|
| 148 |
+
uploaded_file.filename,
|
| 149 |
+
mime_type,
|
| 150 |
+
file_content_b64,
|
| 151 |
+
)
|
| 152 |
+
return jsonify({"job_id": job_id, "status": "queued", "queued": used_celery}), 202
|
| 153 |
+
|
| 154 |
+
# =====================================================================
|
| 155 |
+
# Geo Finder Routes
|
| 156 |
+
# =====================================================================
|
| 157 |
+
|
| 158 |
+
@ai_bp.post("/api/geo-search")
|
| 159 |
+
def api_geo_search():
|
| 160 |
+
payload = request.get_json(force=True, silent=True) or {}
|
| 161 |
+
query = str(payload.get("query") or "").strip()
|
| 162 |
+
if not query:
|
| 163 |
+
return jsonify({"error": "Enter a company type, industry, location, or coordinates."}), 400
|
| 164 |
+
|
| 165 |
+
job_id = new_job("geo", query, {"query": query})
|
| 166 |
+
from tasks import geo_search_task
|
| 167 |
+
used_celery = enqueue_celery_task(geo_search_task, run_geo_job, job_id, query)
|
| 168 |
+
return jsonify({"job_id": job_id, "queued": used_celery})
|
| 169 |
+
|
| 170 |
+
@ai_bp.get("/api/geo-export/<job_id>")
|
| 171 |
+
def api_geo_export(job_id: str):
|
| 172 |
+
job = geo_result_payload(job_id)
|
| 173 |
+
if not job:
|
| 174 |
+
return jsonify({"error": "Geo run not found."}), 404
|
| 175 |
+
if job.get("status") != "completed":
|
| 176 |
+
return jsonify({"error": "Geo export is available after the run completes."}), 400
|
| 177 |
+
|
| 178 |
+
try:
|
| 179 |
+
output = build_geo_excel(job)
|
| 180 |
+
except (RuntimeError, ValueError) as exc:
|
| 181 |
+
return jsonify({"error": str(exc)}), 400
|
| 182 |
+
|
| 183 |
+
filename = safe_download_name(str(job.get("title") or "geo-results"))
|
| 184 |
+
return send_file(
|
| 185 |
+
output,
|
| 186 |
+
as_attachment=True,
|
| 187 |
+
download_name=filename,
|
| 188 |
+
mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
+
# =====================================================================
|
| 192 |
+
# Company Research & Extractor Routes
|
| 193 |
+
# =====================================================================
|
| 194 |
+
|
| 195 |
+
@ai_bp.post("/api/company-research")
|
| 196 |
+
def api_company_research():
|
| 197 |
+
payload = request.get_json(force=True, silent=True) or {}
|
| 198 |
+
company = str(payload.get("company") or "").strip()
|
| 199 |
+
if not company:
|
| 200 |
+
return jsonify({"error": "Enter a company name."}), 400
|
| 201 |
+
if not get_base_company_context():
|
| 202 |
+
return jsonify({
|
| 203 |
+
"error": "Base company context is required before running Company Research.",
|
| 204 |
+
"redirect": "#/settings",
|
| 205 |
+
}), 409
|
| 206 |
+
if not PIPELINE_SCRIPT.exists():
|
| 207 |
+
return jsonify({"error": f"Missing pipeline script: {PIPELINE_SCRIPT}"}), 500
|
| 208 |
+
|
| 209 |
+
job_id = new_job("research", company, payload)
|
| 210 |
+
from tasks import company_research_task
|
| 211 |
+
used_celery = enqueue_celery_task(company_research_task, run_company_research_job, job_id, payload)
|
| 212 |
+
return jsonify({"job_id": job_id, "queued": used_celery})
|
| 213 |
+
|
| 214 |
+
@ai_bp.get("/api/profiles")
|
| 215 |
+
def api_profiles():
|
| 216 |
+
return jsonify(list_profiles())
|
| 217 |
+
|
| 218 |
+
@ai_bp.get("/api/profile")
|
| 219 |
+
def api_profile():
|
| 220 |
+
raw_path = request.args.get("path", "")
|
| 221 |
+
try:
|
| 222 |
+
resolved = resolve_project_file(raw_path)
|
| 223 |
+
text = safe_read_profile(resolved)
|
| 224 |
+
if not text and resolved.name == "final_integrated_company_profile_gemini_enriched.txt":
|
| 225 |
+
fallback = resolved.with_name("final_integrated_company_profile.txt")
|
| 226 |
+
if fallback.exists():
|
| 227 |
+
fallback_text = safe_read_profile(fallback)
|
| 228 |
+
if fallback_text:
|
| 229 |
+
return jsonify({"path": str(fallback), "text": fallback_text})
|
| 230 |
+
return jsonify({"path": str(resolved), "text": text or safe_read_text(resolved)})
|
| 231 |
+
except Exception as exc:
|
| 232 |
+
return jsonify({"error": str(exc)}), 500
|
| 233 |
+
|
| 234 |
+
@ai_bp.get("/api/profile-pdf")
|
| 235 |
+
def api_profile_pdf():
|
| 236 |
+
raw_path = request.args.get("path", "")
|
| 237 |
+
job_id = str(request.args.get("job_id") or "").strip()
|
| 238 |
+
stored_text = ""
|
| 239 |
+
stored_source = ""
|
| 240 |
+
pdf_display_name = ""
|
| 241 |
+
if job_id:
|
| 242 |
+
job = get_job_state(job_id)
|
| 243 |
+
if not job:
|
| 244 |
+
return jsonify({"error": "Job not found."}), 404
|
| 245 |
+
result = job.get("result") if isinstance(job.get("result"), dict) else {}
|
| 246 |
+
stored_text = str(result.get("text") or job.get("result_text") or "").strip()
|
| 247 |
+
stored_source = str(result.get("path") or job.get("profile_path") or "").strip()
|
| 248 |
+
raw_path = raw_path or stored_source
|
| 249 |
+
# Use the searched company name for the downloaded PDF when available.
|
| 250 |
+
pdf_display_name = str(job.get("title") or "").strip()
|
| 251 |
+
|
| 252 |
+
try:
|
| 253 |
+
resolved = None
|
| 254 |
+
text = ""
|
| 255 |
+
if raw_path:
|
| 256 |
+
try:
|
| 257 |
+
resolved = resolve_project_file(raw_path)
|
| 258 |
+
text = safe_read_profile(resolved)
|
| 259 |
+
if not text:
|
| 260 |
+
resolved = fallback_profile_path(resolved)
|
| 261 |
+
text = safe_read_profile(resolved)
|
| 262 |
+
except (ValueError, FileNotFoundError):
|
| 263 |
+
if not stored_text:
|
| 264 |
+
raise
|
| 265 |
+
|
| 266 |
+
if not text and stored_text:
|
| 267 |
+
text = stored_text
|
| 268 |
+
resolved = Path(stored_source or raw_path or f"{job_id}-company-profile.md")
|
| 269 |
+
if not text:
|
| 270 |
+
return jsonify({"error": "Profile has no usable text to export."}), 400
|
| 271 |
+
|
| 272 |
+
output = build_profile_pdf(text, resolved)
|
| 273 |
+
filename_base = (pdf_display_name or resolved.parent.name or resolved.stem) + " Company Research"
|
| 274 |
+
return send_file(
|
| 275 |
+
output,
|
| 276 |
+
as_attachment=True,
|
| 277 |
+
download_name=safe_pdf_name(filename_base),
|
| 278 |
+
mimetype="application/pdf",
|
| 279 |
+
)
|
| 280 |
+
except (ValueError, FileNotFoundError) as exc:
|
| 281 |
+
return jsonify({"error": str(exc)}), 400
|
| 282 |
+
except Exception as exc:
|
| 283 |
+
return jsonify({"error": str(exc)}), 500
|
| 284 |
+
|
| 285 |
+
# =====================================================================
|
| 286 |
+
# Jobs & Audit History Management Routes
|
| 287 |
+
# =====================================================================
|
| 288 |
+
|
| 289 |
+
@ai_bp.get("/api/jobs")
|
| 290 |
+
def api_jobs():
|
| 291 |
+
return jsonify(db_list_interactions())
|
| 292 |
+
|
| 293 |
+
@ai_bp.get("/api/jobs/<job_id>")
|
| 294 |
+
def api_job(job_id: str):
|
| 295 |
+
payload = get_job_state(job_id)
|
| 296 |
+
with JOBS_LOCK:
|
| 297 |
+
job = JOBS.get(job_id)
|
| 298 |
+
if not payload and job:
|
| 299 |
+
payload = dict(job)
|
| 300 |
+
if not payload:
|
| 301 |
+
return jsonify({"error": "Job not found."}), 404
|
| 302 |
+
if (
|
| 303 |
+
payload.get("kind") == "research"
|
| 304 |
+
and payload.get("status") not in {"queued", "running"}
|
| 305 |
+
and payload.get("run_dir")
|
| 306 |
+
and not payload.get("result")
|
| 307 |
+
):
|
| 308 |
+
payload["result"] = find_final_profile(payload.get("run_dir"))
|
| 309 |
+
if payload.get("kind") == "geo" and isinstance(payload.get("result"), dict):
|
| 310 |
+
result = payload["result"]
|
| 311 |
+
if result.get("result") and not result.get("companies"):
|
| 312 |
+
result["companies"] = parse_geo_companies(str(result.get("result") or ""))
|
| 313 |
+
if not result.get("companies") and result.get("stages"):
|
| 314 |
+
fallback_companies = parse_geo_companies_from_stages(result.get("stages") or [])
|
| 315 |
+
if fallback_companies:
|
| 316 |
+
result["companies"] = fallback_companies
|
| 317 |
+
result["ranking_unavailable"] = True
|
| 318 |
+
result["notice"] = "Ranking was not available, so these discovered companies are shown unranked."
|
| 319 |
+
return jsonify(payload)
|
| 320 |
+
|
| 321 |
+
@ai_bp.post("/api/jobs/<job_id>/stop")
|
| 322 |
+
def api_stop_job(job_id: str):
|
| 323 |
+
payload = get_job_state(job_id)
|
| 324 |
+
with JOBS_LOCK:
|
| 325 |
+
job = JOBS.get(job_id)
|
| 326 |
+
if not payload and job:
|
| 327 |
+
payload = dict(job)
|
| 328 |
+
if not payload:
|
| 329 |
+
return jsonify({"error": "Job not found."}), 404
|
| 330 |
+
if payload.get("kind") not in {"research", "geo"}:
|
| 331 |
+
return jsonify({"error": "Only Regional Prospecting and Company Research jobs can be stopped."}), 400
|
| 332 |
+
if payload.get("status") not in {"queued", "running"}:
|
| 333 |
+
return jsonify({"error": "This job is not running."}), 400
|
| 334 |
+
|
| 335 |
+
pid = payload.get("pid")
|
| 336 |
+
if job:
|
| 337 |
+
update_job(job_id, status="canceled", finished_at=now(), error="Stopped by user.")
|
| 338 |
+
append_log(job_id, "Stop requested by user.")
|
| 339 |
+
else:
|
| 340 |
+
db_update_interaction(job_id, status="canceled", finished_at=now(), error="Stopped by user.")
|
| 341 |
+
|
| 342 |
+
if payload.get("kind") == "geo":
|
| 343 |
+
return jsonify({"success": True, "status": "canceled"})
|
| 344 |
+
|
| 345 |
+
if pid:
|
| 346 |
+
try:
|
| 347 |
+
stop_process_tree(int(pid))
|
| 348 |
+
except Exception as exc:
|
| 349 |
+
append_log(job_id, f"Stop command failed: {type(exc).__name__}: {exc}")
|
| 350 |
+
return jsonify({"error": f"Could not stop process: {exc}"}), 500
|
| 351 |
+
|
| 352 |
+
return jsonify({"success": True, "status": "canceled"})
|
| 353 |
+
|
| 354 |
+
@ai_bp.get("/api/history")
|
| 355 |
+
def api_history():
|
| 356 |
+
limit = clean_int(request.args.get("limit"), 80, 1, 250)
|
| 357 |
+
kind = str(request.args.get("kind") or "").strip()
|
| 358 |
+
if kind == "geo":
|
| 359 |
+
db_prune_geo_history(20)
|
| 360 |
+
return jsonify(db_list_interactions(limit, kind))
|
| 361 |
+
|
| 362 |
+
@ai_bp.get("/api/geo-history")
|
| 363 |
+
def api_geo_history():
|
| 364 |
+
limit = clean_int(request.args.get("limit"), 20, 1, 100)
|
| 365 |
+
db_prune_geo_history(20)
|
| 366 |
+
return jsonify(db_list_interactions(limit, "geo"))
|
| 367 |
+
|
| 368 |
+
@ai_bp.get("/api/ocr-history")
|
| 369 |
+
def api_ocr_history():
|
| 370 |
+
limit = clean_int(request.args.get("limit"), 20, 1, 100)
|
| 371 |
+
return jsonify(db_list_interactions(limit, "ocr"))
|
| 372 |
+
|
| 373 |
+
@ai_bp.post("/api/agentspace/chat")
|
| 374 |
+
def api_agentspace_chat():
|
| 375 |
+
import os
|
| 376 |
+
import requests
|
| 377 |
+
|
| 378 |
+
payload = request.get_json(force=True, silent=True) or {}
|
| 379 |
+
messages = payload.get("messages", [])
|
| 380 |
+
|
| 381 |
+
agentspace_system_prompt = """You are ProspectIQ's intelligent AI Assistant (Agent Space).
|
| 382 |
+
Your goal is to help users interact with their CRM data, analyze business cards, run company research, and manage sales workflows.
|
| 383 |
+
|
| 384 |
+
CRITICAL BEHAVIOR & CONVERSATIONAL RULES:
|
| 385 |
+
1. Be Conversational and Helpful: If the user gives a clear prompt, fulfill it directly. If their request is ambiguous, unclear, or missing key details, do NOT give a blunt error or make assumptions. Instead, ask friendly clarifying follow-up questions to help them clarify their intent.
|
| 386 |
+
2. Suggest MCQ Options for Ambiguity: When a question or command is ambiguous or could mean multiple things, present a clear Multiple-Choice Question (MCQ) numbered list (e.g., 1) Option A\n2) Option B) so the user can easily select the right path.
|
| 387 |
+
3. Distinguish Between Contacts vs. Users/Roles:
|
| 388 |
+
- External CRM Contacts: People working at client/prospect companies (buyers, decision-makers, managers). Added to CRM under a specific Company.
|
| 389 |
+
- Internal Users/Representatives: Team members (BD Reps, Admins, Viewers) who log into ProspectIQ to manage pipelines.
|
| 390 |
+
If the user says "add user", "add role", "add rep", or mentions login roles/permissions, this is an INTERNAL representative, NOT a CRM contact. If ambiguous, ask an MCQ clarifying whether they mean an Internal Team Member or an External CRM Contact.
|
| 391 |
+
4. CRM Database Queries vs. External Prospecting:
|
| 392 |
+
- If the user asks for something like "list top 3 companies from database", "show all companies currently in new stage", "show/sow companies in CRM", or questions about existing saved records or pipeline stages, answer using the loaded CRM context or direct them to CRM (`filter_companies`). NEVER suggest or trigger external Regional Prospecting (`geo`) or Company Research when they explicitly ask about pipeline stages ("new stage", "shortlisted", etc.) or existing records already in the CRM/database.
|
| 393 |
+
5. Clean Company & Attribute Extraction: When adding or editing companies with descriptive prompts, ALWAYS isolate the clean short company name (e.g. "Abel music house") without trailing clauses. Put leadership (CEO, VP) or extra context into notes, geographic location into province/city, business type into industry, and average staff into size_staff. If decision makers or executives are mentioned (e.g. "CEO is Sarah Connor, VP is Kyle Reese"), include them in a "contacts" array inside "company_data" (e.g. [{"first_name": "Sarah", "last_name": "Connor", "title": "CEO", "is_primary": true}, {"first_name": "Kyle", "last_name": "Reese", "title": "VP", "is_primary": false}]) so they are automatically added to the Contacts CRM table alongside the company!"""
|
| 394 |
+
|
| 395 |
+
if messages and messages[0].get("role") == "system":
|
| 396 |
+
messages[0]["content"] = agentspace_system_prompt + "\n\n--- Current Loaded Context & Instructions ---\n" + str(messages[0].get("content", ""))
|
| 397 |
+
else:
|
| 398 |
+
messages.insert(0, {"role": "system", "content": agentspace_system_prompt})
|
| 399 |
+
|
| 400 |
+
# Extract user query
|
| 401 |
+
user_query = ""
|
| 402 |
+
for msg in reversed(messages):
|
| 403 |
+
if msg.get("role") == "user":
|
| 404 |
+
user_query = msg.get("content")
|
| 405 |
+
break
|
| 406 |
+
|
| 407 |
+
# Log chat job in database
|
| 408 |
+
job_id = new_job("chat", user_query or "Chat Session", {"messages": messages})
|
| 409 |
+
update_job(job_id, status="running", started_at=now())
|
| 410 |
+
|
| 411 |
+
gemini_key = get_gemini_key()
|
| 412 |
+
gemini_error = ""
|
| 413 |
+
|
| 414 |
+
try:
|
| 415 |
+
if gemini_key:
|
| 416 |
+
res = requests.post(
|
| 417 |
+
"https://generativelanguage.googleapis.com/v1beta/openai/chat/completions",
|
| 418 |
+
headers={
|
| 419 |
+
"Authorization": f"Bearer {gemini_key}",
|
| 420 |
+
"Content-Type": "application/json",
|
| 421 |
+
},
|
| 422 |
+
json={
|
| 423 |
+
"model": "gemini-2.5-flash-lite",
|
| 424 |
+
"service_tier": "priority",
|
| 425 |
+
"messages": messages,
|
| 426 |
+
"temperature": 0.7,
|
| 427 |
+
"max_tokens": 1500,
|
| 428 |
+
},
|
| 429 |
+
timeout=30,
|
| 430 |
+
)
|
| 431 |
+
if res.ok:
|
| 432 |
+
data = res.json()
|
| 433 |
+
reply = data["choices"][0]["message"]["content"]
|
| 434 |
+
update_job(
|
| 435 |
+
job_id,
|
| 436 |
+
status="completed",
|
| 437 |
+
finished_at=now(),
|
| 438 |
+
result={
|
| 439 |
+
"reply": reply,
|
| 440 |
+
"messages": messages + [{"role": "assistant", "content": reply}],
|
| 441 |
+
"provider": "gemini",
|
| 442 |
+
"service_tier": "priority",
|
| 443 |
+
}
|
| 444 |
+
)
|
| 445 |
+
return jsonify({"reply": reply})
|
| 446 |
+
gemini_error = f"Gemini Error: {res.status_code} - {res.text}"
|
| 447 |
+
|
| 448 |
+
openrouter_key = os.environ.get("OPENROUTER_API_KEY", "").strip()
|
| 449 |
+
openrouter_error = ""
|
| 450 |
+
if openrouter_key:
|
| 451 |
+
res = requests.post(
|
| 452 |
+
"https://openrouter.ai/api/v1/chat/completions",
|
| 453 |
+
headers={
|
| 454 |
+
"Authorization": f"Bearer {openrouter_key}",
|
| 455 |
+
"Content-Type": "application/json",
|
| 456 |
+
"HTTP-Referer": "http://localhost:5000",
|
| 457 |
+
"X-Title": "ProspectIQ"
|
| 458 |
+
},
|
| 459 |
+
json={
|
| 460 |
+
"model": "google/gemini-2.5-flash-lite",
|
| 461 |
+
"messages": messages,
|
| 462 |
+
"temperature": 0.7,
|
| 463 |
+
"max_tokens": 1500,
|
| 464 |
+
},
|
| 465 |
+
timeout=30,
|
| 466 |
+
)
|
| 467 |
+
if res.ok:
|
| 468 |
+
data = res.json()
|
| 469 |
+
reply = data["choices"][0]["message"]["content"]
|
| 470 |
+
update_job(
|
| 471 |
+
job_id,
|
| 472 |
+
status="completed",
|
| 473 |
+
finished_at=now(),
|
| 474 |
+
result={
|
| 475 |
+
"reply": reply,
|
| 476 |
+
"messages": messages + [{"role": "assistant", "content": reply}],
|
| 477 |
+
"provider": "openrouter",
|
| 478 |
+
}
|
| 479 |
+
)
|
| 480 |
+
return jsonify({"reply": reply})
|
| 481 |
+
openrouter_error = f"OpenRouter Error: {res.status_code} - {res.text}"
|
| 482 |
+
else:
|
| 483 |
+
openrouter_error = "OPENROUTER_API_KEY is not configured."
|
| 484 |
+
|
| 485 |
+
# --- Claude API Fallback ---
|
| 486 |
+
try:
|
| 487 |
+
import anthropic
|
| 488 |
+
claude_key = os.environ.get("ANTHROPIC_API_KEY", "").strip()
|
| 489 |
+
if not claude_key:
|
| 490 |
+
raise ValueError("ANTHROPIC_API_KEY is not configured.")
|
| 491 |
+
|
| 492 |
+
client = anthropic.Anthropic(api_key=claude_key)
|
| 493 |
+
|
| 494 |
+
system_msg = ""
|
| 495 |
+
anthropic_messages = []
|
| 496 |
+
|
| 497 |
+
for msg in messages:
|
| 498 |
+
if msg.get("role") == "system":
|
| 499 |
+
system_msg = msg.get("content")
|
| 500 |
+
else:
|
| 501 |
+
anthropic_messages.append(msg)
|
| 502 |
+
|
| 503 |
+
kwargs = {
|
| 504 |
+
"model": "claude-haiku-4-5",
|
| 505 |
+
"max_tokens": 4096,
|
| 506 |
+
"messages": anthropic_messages
|
| 507 |
+
}
|
| 508 |
+
if system_msg:
|
| 509 |
+
kwargs["system"] = system_msg
|
| 510 |
+
|
| 511 |
+
full_response = ""
|
| 512 |
+
max_continuations = 5
|
| 513 |
+
continuation_count = 0
|
| 514 |
+
|
| 515 |
+
while True:
|
| 516 |
+
claude_res = client.messages.create(**kwargs)
|
| 517 |
+
|
| 518 |
+
for block in claude_res.content:
|
| 519 |
+
if block.type == "text":
|
| 520 |
+
full_response += block.text
|
| 521 |
+
|
| 522 |
+
stop_reason = claude_res.stop_reason
|
| 523 |
+
|
| 524 |
+
if stop_reason == "end_turn":
|
| 525 |
+
break
|
| 526 |
+
elif stop_reason == "max_tokens":
|
| 527 |
+
continuation_count += 1
|
| 528 |
+
if continuation_count >= max_continuations:
|
| 529 |
+
break
|
| 530 |
+
|
| 531 |
+
kwargs["messages"].append({"role": "assistant", "content": claude_res.content})
|
| 532 |
+
kwargs["messages"].append({
|
| 533 |
+
"role": "user",
|
| 534 |
+
"content": "Your previous response was cut off. Please continue exactly from where you left off."
|
| 535 |
+
})
|
| 536 |
+
elif stop_reason == "refusal":
|
| 537 |
+
full_response += "\n\n[Refusal: Claude declined to respond]"
|
| 538 |
+
break
|
| 539 |
+
else:
|
| 540 |
+
break
|
| 541 |
+
|
| 542 |
+
reply = full_response
|
| 543 |
+
|
| 544 |
+
update_job(
|
| 545 |
+
job_id,
|
| 546 |
+
status="completed",
|
| 547 |
+
finished_at=now(),
|
| 548 |
+
result={
|
| 549 |
+
"reply": reply,
|
| 550 |
+
"messages": messages + [{"role": "assistant", "content": reply}],
|
| 551 |
+
"provider": "claude",
|
| 552 |
+
}
|
| 553 |
+
)
|
| 554 |
+
return jsonify({"reply": reply})
|
| 555 |
+
except Exception as claude_exc:
|
| 556 |
+
error_msg = f"{gemini_error} | {openrouter_error} | Claude Error: {str(claude_exc)}".strip(" |")
|
| 557 |
+
update_job(job_id, status="failed", finished_at=now(), error=error_msg)
|
| 558 |
+
return jsonify({"error": error_msg}), 500
|
| 559 |
+
except Exception as exc:
|
| 560 |
+
update_job(job_id, status="failed", finished_at=now(), error=str(exc))
|
| 561 |
+
return jsonify({"error": str(exc)}), 500
|
| 562 |
+
|
| 563 |
+
@ai_bp.post("/api/ai/restructure")
|
| 564 |
+
def api_ai_restructure():
|
| 565 |
+
import os
|
| 566 |
+
import json
|
| 567 |
+
import requests
|
| 568 |
+
|
| 569 |
+
payload = request.get_json(force=True, silent=True) or {}
|
| 570 |
+
text = payload.get("text", "")
|
| 571 |
+
category = payload.get("category", "")
|
| 572 |
+
|
| 573 |
+
if not text:
|
| 574 |
+
return jsonify({"error": "No text provided"}), 400
|
| 575 |
+
|
| 576 |
+
openrouter_key, missing_response = get_openrouter_key_response()
|
| 577 |
+
if missing_response:
|
| 578 |
+
return missing_response
|
| 579 |
+
api_url = "https://openrouter.ai/api/v1/chat/completions"
|
| 580 |
+
|
| 581 |
+
headers = {
|
| 582 |
+
"Authorization": f"Bearer {openrouter_key}",
|
| 583 |
+
"Content-Type": "application/json",
|
| 584 |
+
"HTTP-Referer": "http://localhost:5000",
|
| 585 |
+
"X-Title": "ProspectIQ"
|
| 586 |
+
}
|
| 587 |
+
|
| 588 |
+
system_prompt = f"""
|
| 589 |
+
You are an expert procurement and sourcing assistant.
|
| 590 |
+
Your task is to extract, clean, and structure raw company research text into a clean list of facts, bullet points, or metric key-value fields.
|
| 591 |
+
Focus on formatting the category: {category}.
|
| 592 |
+
Format the response as a clean, structured JSON object with a list of key-value pairs or sections, so it can be parsed and rendered inside a dashboard.
|
| 593 |
+
Response must be ONLY valid JSON in this format (no markdown code blocks, no backticks, no other text):
|
| 594 |
+
{{
|
| 595 |
+
"title": "Category Name",
|
| 596 |
+
"sections": [
|
| 597 |
+
{{
|
| 598 |
+
"heading": "Section Heading",
|
| 599 |
+
"items": [
|
| 600 |
+
{{"label": "Label name", "detail": "Detail text"}}
|
| 601 |
+
]
|
| 602 |
+
}}
|
| 603 |
+
]
|
| 604 |
+
}}
|
| 605 |
+
"""
|
| 606 |
+
|
| 607 |
+
req_payload = {
|
| 608 |
+
"model": "openai/gpt-oss-120b",
|
| 609 |
+
"messages": [
|
| 610 |
+
{"role": "system", "content": system_prompt},
|
| 611 |
+
{"role": "user", "content": text}
|
| 612 |
+
],
|
| 613 |
+
"temperature": 0.3,
|
| 614 |
+
"max_tokens": 1200
|
| 615 |
+
}
|
| 616 |
+
|
| 617 |
+
try:
|
| 618 |
+
res = requests.post(api_url, headers=headers, json=req_payload)
|
| 619 |
+
if not res.ok:
|
| 620 |
+
return jsonify({"error": f"API Error: {res.status_code} - {res.text}"}), 502
|
| 621 |
+
data = res.json()
|
| 622 |
+
content = data["choices"][0]["message"]["content"]
|
| 623 |
+
content = content.replace("```json", "").replace("```", "").strip()
|
| 624 |
+
# Ensure it is valid JSON
|
| 625 |
+
parsed = json.loads(content)
|
| 626 |
+
return jsonify(parsed)
|
| 627 |
+
except Exception as exc:
|
| 628 |
+
return jsonify({"error": str(exc)}), 500
|
| 629 |
+
|
| 630 |
+
@ai_bp.post("/api/ai/fit-analysis")
|
| 631 |
+
def api_ai_fit_analysis():
|
| 632 |
+
import os
|
| 633 |
+
import json
|
| 634 |
+
import requests
|
| 635 |
+
import hashlib
|
| 636 |
+
from db import cache_get, cache_set
|
| 637 |
+
|
| 638 |
+
payload = request.get_json(force=True, silent=True) or {}
|
| 639 |
+
company_name = payload.get("company_name", "the company")
|
| 640 |
+
staff_size = payload.get("staff_size", 0)
|
| 641 |
+
industry = payload.get("industry", "")
|
| 642 |
+
expansion = payload.get("expansion", "")
|
| 643 |
+
|
| 644 |
+
# Generate cache key
|
| 645 |
+
input_str = f"{company_name.lower().strip()}:{staff_size}:{industry.lower().strip()}:{expansion.lower().strip()}"
|
| 646 |
+
input_hash = hashlib.md5(input_str.encode('utf-8')).hexdigest()
|
| 647 |
+
cache_key = f"prospectiq:fit_analysis:{input_hash}"
|
| 648 |
+
|
| 649 |
+
cached_val = cache_get(cache_key)
|
| 650 |
+
if cached_val is not None:
|
| 651 |
+
return jsonify(cached_val)
|
| 652 |
+
|
| 653 |
+
openrouter_key, missing_response = get_openrouter_key_response()
|
| 654 |
+
if missing_response:
|
| 655 |
+
return missing_response
|
| 656 |
+
api_url = "https://openrouter.ai/api/v1/chat/completions"
|
| 657 |
+
|
| 658 |
+
headers = {
|
| 659 |
+
"Authorization": f"Bearer {openrouter_key}",
|
| 660 |
+
"Content-Type": "application/json",
|
| 661 |
+
"HTTP-Referer": "http://localhost:5000",
|
| 662 |
+
"X-Title": "ProspectIQ"
|
| 663 |
+
}
|
| 664 |
+
|
| 665 |
+
system_prompt = """
|
| 666 |
+
You are an expert sales analyst for a corporate office water supply service (recurring 20-litre cans).
|
| 667 |
+
Assess this company's compatibility for water supply services.
|
| 668 |
+
Assign a fit grade (High, Medium, or Low), estimate weekly consumption (number of cans, e.g. 40-60 cans/week), list 3 key compatibility drivers (short phrases), and write a 2-sentence rationale.
|
| 669 |
+
Return ONLY a valid JSON object in this format (no backticks, no other text):
|
| 670 |
+
{
|
| 671 |
+
"grade": "High",
|
| 672 |
+
"estimated_cans": "40-60 cans/week",
|
| 673 |
+
"drivers": [
|
| 674 |
+
"Driver 1...",
|
| 675 |
+
"Driver 2...",
|
| 676 |
+
"Driver 3..."
|
| 677 |
+
],
|
| 678 |
+
"rationale": "Rationale..."
|
| 679 |
+
}
|
| 680 |
+
"""
|
| 681 |
+
|
| 682 |
+
user_content = f"Company: {company_name}\nStaff Size: {staff_size}\nIndustry: {industry}\nExpansion/Sourcing signals: {expansion}"
|
| 683 |
+
|
| 684 |
+
req_payload = {
|
| 685 |
+
"model": "openai/gpt-oss-120b",
|
| 686 |
+
"messages": [
|
| 687 |
+
{"role": "system", "content": system_prompt},
|
| 688 |
+
{"role": "user", "content": user_content}
|
| 689 |
+
],
|
| 690 |
+
"temperature": 0.3,
|
| 691 |
+
"max_tokens": 800
|
| 692 |
+
}
|
| 693 |
+
|
| 694 |
+
try:
|
| 695 |
+
res = requests.post(api_url, headers=headers, json=req_payload)
|
| 696 |
+
if not res.ok:
|
| 697 |
+
raise RuntimeError(f"API Error: {res.status_code}")
|
| 698 |
+
data = res.json()
|
| 699 |
+
content = data["choices"][0]["message"]["content"]
|
| 700 |
+
content = content.replace("```json", "").replace("```", "").strip()
|
| 701 |
+
parsed = json.loads(content)
|
| 702 |
+
|
| 703 |
+
# Save to database if company_id is present
|
| 704 |
+
company_id = payload.get("company_id")
|
| 705 |
+
if company_id:
|
| 706 |
+
try:
|
| 707 |
+
from db import get_db_connection
|
| 708 |
+
with get_db_connection() as conn:
|
| 709 |
+
with conn.cursor() as cursor:
|
| 710 |
+
cursor.execute(
|
| 711 |
+
"SELECT id FROM ai_field_values WHERE company_id = ? AND field_name = ?",
|
| 712 |
+
(int(company_id), "ai_fit_analysis")
|
| 713 |
+
)
|
| 714 |
+
row = cursor.fetchone()
|
| 715 |
+
if row:
|
| 716 |
+
cursor.execute(
|
| 717 |
+
"UPDATE ai_field_values SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
| 718 |
+
(json.dumps(parsed), row[0])
|
| 719 |
+
)
|
| 720 |
+
else:
|
| 721 |
+
cursor.execute(
|
| 722 |
+
"INSERT INTO ai_field_values (company_id, field_name, value) VALUES (?, ?, ?)",
|
| 723 |
+
(int(company_id), "ai_fit_analysis", json.dumps(parsed))
|
| 724 |
+
)
|
| 725 |
+
except Exception as e:
|
| 726 |
+
print(f"Error persisting fit analysis to DB: {e}")
|
| 727 |
+
|
| 728 |
+
# Cache successful briefing for 24 hours
|
| 729 |
+
cache_set(cache_key, parsed, timeout=86400)
|
| 730 |
+
return jsonify(parsed)
|
| 731 |
+
except Exception as exc:
|
| 732 |
+
# Fallback response
|
| 733 |
+
fallback = {
|
| 734 |
+
"grade": "High" if int(staff_size or 0) > 100 else "Medium",
|
| 735 |
+
"estimated_cans": f"{max(5, int(staff_size or 0) // 10)}-{max(10, int(staff_size or 0) // 5)} cans/week",
|
| 736 |
+
"drivers": [
|
| 737 |
+
f"Headcount of {staff_size} requires consistent workspace amenities.",
|
| 738 |
+
f"Sourcing signals indicate facility operational scales.",
|
| 739 |
+
"High office/facility density boosts water replenishment rate."
|
| 740 |
+
],
|
| 741 |
+
"rationale": f"{company_name} presents a strong opportunity due to its operational scale. The active headcount and office layout support a recurring hydration contract."
|
| 742 |
+
}
|
| 743 |
+
|
| 744 |
+
# Save fallback to database if company_id is present
|
| 745 |
+
company_id = payload.get("company_id")
|
| 746 |
+
if company_id:
|
| 747 |
+
try:
|
| 748 |
+
from db import get_db_connection
|
| 749 |
+
with get_db_connection() as conn:
|
| 750 |
+
with conn.cursor() as cursor:
|
| 751 |
+
cursor.execute(
|
| 752 |
+
"SELECT id FROM ai_field_values WHERE company_id = ? AND field_name = ?",
|
| 753 |
+
(int(company_id), "ai_fit_analysis")
|
| 754 |
+
)
|
| 755 |
+
row = cursor.fetchone()
|
| 756 |
+
if row:
|
| 757 |
+
cursor.execute(
|
| 758 |
+
"UPDATE ai_field_values SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
| 759 |
+
(json.dumps(fallback), row[0])
|
| 760 |
+
)
|
| 761 |
+
else:
|
| 762 |
+
cursor.execute(
|
| 763 |
+
"INSERT INTO ai_field_values (company_id, field_name, value) VALUES (?, ?, ?)",
|
| 764 |
+
(int(company_id), "ai_fit_analysis", json.dumps(fallback))
|
| 765 |
+
)
|
| 766 |
+
except Exception as e:
|
| 767 |
+
print(f"Error persisting fit analysis fallback to DB: {e}")
|
| 768 |
+
|
| 769 |
+
# Cache fallback for 1 hour
|
| 770 |
+
cache_set(cache_key, fallback, timeout=3600)
|
| 771 |
+
return jsonify(fallback)
|
| 772 |
+
|
| 773 |
+
@ai_bp.post("/api/ai/briefing")
|
| 774 |
+
def api_ai_briefing():
|
| 775 |
+
import os
|
| 776 |
+
import json
|
| 777 |
+
import requests
|
| 778 |
+
import hashlib
|
| 779 |
+
from db import cache_get, cache_set
|
| 780 |
+
|
| 781 |
+
payload = request.get_json(force=True, silent=True) or {}
|
| 782 |
+
company_name = payload.get("company_name", "the company")
|
| 783 |
+
contact_name = payload.get("contact_name", "Facilities Team")
|
| 784 |
+
contact_title = payload.get("contact_title", "Facilities Representative")
|
| 785 |
+
triggers = payload.get("triggers", "")
|
| 786 |
+
|
| 787 |
+
# Generate cache key
|
| 788 |
+
input_str = f"{company_name.lower().strip()}:{contact_name.lower().strip()}:{contact_title.lower().strip()}:{triggers.lower().strip()}"
|
| 789 |
+
input_hash = hashlib.md5(input_str.encode('utf-8')).hexdigest()
|
| 790 |
+
cache_key = f"prospectiq:briefing:{input_hash}"
|
| 791 |
+
|
| 792 |
+
cached_val = cache_get(cache_key)
|
| 793 |
+
if cached_val is not None:
|
| 794 |
+
return jsonify(cached_val)
|
| 795 |
+
|
| 796 |
+
openrouter_key, missing_response = get_openrouter_key_response()
|
| 797 |
+
if missing_response:
|
| 798 |
+
return missing_response
|
| 799 |
+
api_url = "https://openrouter.ai/api/v1/chat/completions"
|
| 800 |
+
|
| 801 |
+
headers = {
|
| 802 |
+
"Authorization": f"Bearer {openrouter_key}",
|
| 803 |
+
"Content-Type": "application/json",
|
| 804 |
+
"HTTP-Referer": "http://localhost:5000",
|
| 805 |
+
"X-Title": "ProspectIQ"
|
| 806 |
+
}
|
| 807 |
+
|
| 808 |
+
system_prompt = """
|
| 809 |
+
You are an AI sales assistant drafting briefing dossiers and cold outreach emails for a corporate water supply vendor.
|
| 810 |
+
Create a sales briefing dossier (3 bullet points of critical triggers/talking points) and a tailored B2B cold email pitch offering 20-litre recurring water supply services.
|
| 811 |
+
Address the email to the contact. Refer to their company expansion/recent triggers.
|
| 812 |
+
Return ONLY a valid JSON object in this format (no backticks, no other text):
|
| 813 |
+
{
|
| 814 |
+
"dossier_bullets": [
|
| 815 |
+
"Point 1...",
|
| 816 |
+
"Point 2...",
|
| 817 |
+
"Point 3..."
|
| 818 |
+
],
|
| 819 |
+
"email_subject": "Subject...",
|
| 820 |
+
"email_body": "Dear [Name],\\n\\n[Body...]"
|
| 821 |
+
}
|
| 822 |
+
"""
|
| 823 |
+
|
| 824 |
+
user_content = f"Company: {company_name}\nContact: {contact_name} ({contact_title})\nTriggers: {triggers}"
|
| 825 |
+
|
| 826 |
+
req_payload = {
|
| 827 |
+
"model": "openai/gpt-oss-120b",
|
| 828 |
+
"messages": [
|
| 829 |
+
{"role": "system", "content": system_prompt},
|
| 830 |
+
{"role": "user", "content": user_content}
|
| 831 |
+
],
|
| 832 |
+
"temperature": 0.4,
|
| 833 |
+
"max_tokens": 1000
|
| 834 |
+
}
|
| 835 |
+
|
| 836 |
+
try:
|
| 837 |
+
res = requests.post(api_url, headers=headers, json=req_payload)
|
| 838 |
+
if not res.ok:
|
| 839 |
+
raise RuntimeError(f"API Error: {res.status_code}")
|
| 840 |
+
data = res.json()
|
| 841 |
+
content = data["choices"][0]["message"]["content"]
|
| 842 |
+
content = content.replace("```json", "").replace("```", "").strip()
|
| 843 |
+
parsed = json.loads(content)
|
| 844 |
+
|
| 845 |
+
# Save to database if company_id is present
|
| 846 |
+
company_id = payload.get("company_id")
|
| 847 |
+
if company_id:
|
| 848 |
+
try:
|
| 849 |
+
from db import get_db_connection
|
| 850 |
+
with get_db_connection() as conn:
|
| 851 |
+
with conn.cursor() as cursor:
|
| 852 |
+
cursor.execute(
|
| 853 |
+
"SELECT id FROM ai_field_values WHERE company_id = ? AND field_name = ?",
|
| 854 |
+
(int(company_id), "ai_briefing")
|
| 855 |
+
)
|
| 856 |
+
row = cursor.fetchone()
|
| 857 |
+
if row:
|
| 858 |
+
cursor.execute(
|
| 859 |
+
"UPDATE ai_field_values SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
| 860 |
+
(json.dumps(parsed), row[0])
|
| 861 |
+
)
|
| 862 |
+
else:
|
| 863 |
+
cursor.execute(
|
| 864 |
+
"INSERT INTO ai_field_values (company_id, field_name, value) VALUES (?, ?, ?)",
|
| 865 |
+
(int(company_id), "ai_briefing", json.dumps(parsed))
|
| 866 |
+
)
|
| 867 |
+
except Exception as e:
|
| 868 |
+
print(f"Error persisting briefing to DB: {e}")
|
| 869 |
+
|
| 870 |
+
# Cache successful briefing for 24 hours
|
| 871 |
+
cache_set(cache_key, parsed, timeout=86400)
|
| 872 |
+
return jsonify(parsed)
|
| 873 |
+
except Exception as exc:
|
| 874 |
+
# Fallback response
|
| 875 |
+
fallback = {
|
| 876 |
+
"dossier_bullets": [
|
| 877 |
+
f"Refer to {company_name}'s recent headcount growth signals.",
|
| 878 |
+
f"Focus discussion on facilities and office amenities.",
|
| 879 |
+
f"Target outreach directly to {contact_name} as the primary operational stakeholder."
|
| 880 |
+
],
|
| 881 |
+
"email_subject": "Sleek and Sustainable Hydration for your Facilities",
|
| 882 |
+
"email_body": f"Dear {contact_name},\n\nI hope this email finds you well.\n\nGiven your role as {contact_title} at {company_name}, I wanted to reach out regarding your office amenity setups. We specialize in providing premium, high-frequency recurring 20-litre drinking water supply services, customized specifically for dynamic environments like yours.\n\nWe would love to set up a quick 5-minute call to discuss how we can streamline your drinking water supply.\n\nBest regards,\nBD Team"
|
| 883 |
+
}
|
| 884 |
+
|
| 885 |
+
# Save fallback to database if company_id is present
|
| 886 |
+
company_id = payload.get("company_id")
|
| 887 |
+
if company_id:
|
| 888 |
+
try:
|
| 889 |
+
from db import get_db_connection
|
| 890 |
+
with get_db_connection() as conn:
|
| 891 |
+
with conn.cursor() as cursor:
|
| 892 |
+
cursor.execute(
|
| 893 |
+
"SELECT id FROM ai_field_values WHERE company_id = ? AND field_name = ?",
|
| 894 |
+
(int(company_id), "ai_briefing")
|
| 895 |
+
)
|
| 896 |
+
row = cursor.fetchone()
|
| 897 |
+
if row:
|
| 898 |
+
cursor.execute(
|
| 899 |
+
"UPDATE ai_field_values SET value = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
| 900 |
+
(json.dumps(fallback), row[0])
|
| 901 |
+
)
|
| 902 |
+
else:
|
| 903 |
+
cursor.execute(
|
| 904 |
+
"INSERT INTO ai_field_values (company_id, field_name, value) VALUES (?, ?, ?)",
|
| 905 |
+
(int(company_id), "ai_briefing", json.dumps(fallback))
|
| 906 |
+
)
|
| 907 |
+
except Exception as e:
|
| 908 |
+
print(f"Error persisting briefing fallback to DB: {e}")
|
| 909 |
+
|
| 910 |
+
# Cache fallback for 1 hour
|
| 911 |
+
cache_set(cache_key, fallback, timeout=3600)
|
| 912 |
+
return jsonify(fallback)
|
routes/auth.py
ADDED
|
@@ -0,0 +1,615 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import bcrypt
|
| 2 |
+
from flask import Blueprint, request, jsonify, session
|
| 3 |
+
from db import get_db_connection, dict_from_row, get_request_user
|
| 4 |
+
|
| 5 |
+
auth_bp = Blueprint('auth', __name__)
|
| 6 |
+
|
| 7 |
+
def hash_password(password: str) -> str:
|
| 8 |
+
salt = bcrypt.gensalt()
|
| 9 |
+
return bcrypt.hashpw(password.encode('utf-8'), salt).decode('utf-8')
|
| 10 |
+
|
| 11 |
+
def check_password(password: str, hashed: str) -> bool:
|
| 12 |
+
return bcrypt.checkpw(password.encode('utf-8'), hashed.encode('utf-8'))
|
| 13 |
+
|
| 14 |
+
# =====================================================================
|
| 15 |
+
# Registration & Login
|
| 16 |
+
# =====================================================================
|
| 17 |
+
|
| 18 |
+
@auth_bp.route("/register", methods=["POST"])
|
| 19 |
+
def register():
|
| 20 |
+
data = request.get_json() or {}
|
| 21 |
+
org_name = data.get("org_name")
|
| 22 |
+
admin_name = data.get("name")
|
| 23 |
+
email = data.get("email")
|
| 24 |
+
password = data.get("password")
|
| 25 |
+
|
| 26 |
+
if not all([org_name, admin_name, email, password]):
|
| 27 |
+
return jsonify({"error": "All fields are required"}), 400
|
| 28 |
+
|
| 29 |
+
conn = get_db_connection()
|
| 30 |
+
cursor = conn.cursor()
|
| 31 |
+
try:
|
| 32 |
+
# Check if email exists
|
| 33 |
+
cursor.execute("SELECT id FROM users WHERE email = ?", (email,))
|
| 34 |
+
if cursor.fetchone():
|
| 35 |
+
conn.close()
|
| 36 |
+
return jsonify({"error": "Email is already registered"}), 400
|
| 37 |
+
|
| 38 |
+
# 1. Create Tenant (Organization)
|
| 39 |
+
cursor.execute(
|
| 40 |
+
"INSERT INTO tenants (name, brand_color) VALUES (?, ?)",
|
| 41 |
+
(org_name, "#3b82f6")
|
| 42 |
+
)
|
| 43 |
+
# Handle lastrowid for SQLite / Postgres
|
| 44 |
+
cursor.execute("SELECT last_insert_rowid() FROM tenants") if not hasattr(cursor, 'lastrowid') or cursor.lastrowid is None else None
|
| 45 |
+
tenant_id = cursor.lastrowid or cursor.fetchone()[0]
|
| 46 |
+
|
| 47 |
+
# 2. Seed Default Roles for this Tenant
|
| 48 |
+
cursor.execute("INSERT INTO roles (tenant_id, name, description) VALUES (?, ?, ?)", (tenant_id, "Admin", "Full administrative access"))
|
| 49 |
+
cursor.execute("SELECT last_insert_rowid() FROM roles") if not hasattr(cursor, 'lastrowid') or cursor.lastrowid is None else None
|
| 50 |
+
admin_role_id = cursor.lastrowid or cursor.fetchone()[0]
|
| 51 |
+
|
| 52 |
+
cursor.execute("INSERT INTO roles (tenant_id, name, description) VALUES (?, ?, ?)", (tenant_id, "BD Rep", "Access assigned companies and log activities"))
|
| 53 |
+
cursor.execute("SELECT last_insert_rowid() FROM roles") if not hasattr(cursor, 'lastrowid') or cursor.lastrowid is None else None
|
| 54 |
+
rep_role_id = cursor.lastrowid or cursor.fetchone()[0]
|
| 55 |
+
|
| 56 |
+
cursor.execute("INSERT INTO roles (tenant_id, name, description) VALUES (?, ?, ?)", (tenant_id, "Viewer", "Read-only access"))
|
| 57 |
+
cursor.execute("SELECT last_insert_rowid() FROM roles") if not hasattr(cursor, 'lastrowid') or cursor.lastrowid is None else None
|
| 58 |
+
viewer_role_id = cursor.lastrowid or cursor.fetchone()[0]
|
| 59 |
+
|
| 60 |
+
# 3. Associate all global permissions to the Admin role
|
| 61 |
+
cursor.execute("SELECT id FROM permissions")
|
| 62 |
+
perm_ids = [r[0] for r in cursor.fetchall()]
|
| 63 |
+
for pid in perm_ids:
|
| 64 |
+
cursor.execute(
|
| 65 |
+
"INSERT INTO role_permissions (role_id, permission_id) VALUES (?, ?)",
|
| 66 |
+
(admin_role_id, pid)
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
# 4. Associate read/write permissions to the BD Rep role
|
| 70 |
+
cursor.execute("SELECT id FROM permissions WHERE code NOT IN ('users:manage', 'settings:manage')")
|
| 71 |
+
rep_perm_ids = [r[0] for r in cursor.fetchall()]
|
| 72 |
+
for pid in rep_perm_ids:
|
| 73 |
+
cursor.execute(
|
| 74 |
+
"INSERT INTO role_permissions (role_id, permission_id) VALUES (?, ?)",
|
| 75 |
+
(rep_role_id, pid)
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
# 5. Associate read permissions to the Viewer role
|
| 79 |
+
cursor.execute("SELECT id FROM permissions WHERE code IN ('companies:read', 'contacts:read')")
|
| 80 |
+
viewer_perm_ids = [r[0] for r in cursor.fetchall()]
|
| 81 |
+
for pid in viewer_perm_ids:
|
| 82 |
+
cursor.execute(
|
| 83 |
+
"INSERT INTO role_permissions (role_id, permission_id) VALUES (?, ?)",
|
| 84 |
+
(viewer_role_id, pid)
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
# 6. Create Admin User
|
| 88 |
+
pwd_hash = hash_password(password)
|
| 89 |
+
cursor.execute(
|
| 90 |
+
"INSERT INTO users (tenant_id, email, name, password_hash, role_id, role) VALUES (?, ?, ?, ?, ?, ?)",
|
| 91 |
+
(tenant_id, email, admin_name, pwd_hash, admin_role_id, "Admin")
|
| 92 |
+
)
|
| 93 |
+
conn.commit()
|
| 94 |
+
return jsonify({"success": True, "message": "Organization and Admin registered successfully."})
|
| 95 |
+
except Exception as e:
|
| 96 |
+
conn.rollback()
|
| 97 |
+
return jsonify({"error": str(e)}), 500
|
| 98 |
+
finally:
|
| 99 |
+
conn.close()
|
| 100 |
+
|
| 101 |
+
@auth_bp.route("/login", methods=["POST"])
|
| 102 |
+
def login():
|
| 103 |
+
data = request.get_json() or {}
|
| 104 |
+
email = data.get("email")
|
| 105 |
+
password = data.get("password")
|
| 106 |
+
|
| 107 |
+
if not email or not password:
|
| 108 |
+
return jsonify({"error": "Email and password are required"}), 400
|
| 109 |
+
|
| 110 |
+
conn = get_db_connection()
|
| 111 |
+
cursor = conn.cursor()
|
| 112 |
+
cursor.execute("""
|
| 113 |
+
SELECT u.id, u.tenant_id, u.email, u.name, u.password_hash, r.name as role_name, u.role_id
|
| 114 |
+
FROM users u
|
| 115 |
+
LEFT JOIN roles r ON u.role_id = r.id
|
| 116 |
+
WHERE u.email = ? AND u.is_active = 1
|
| 117 |
+
""", (email,))
|
| 118 |
+
row = cursor.fetchone()
|
| 119 |
+
conn.close()
|
| 120 |
+
|
| 121 |
+
if not row or not row['password_hash'] or not check_password(password, row['password_hash']):
|
| 122 |
+
return jsonify({"error": "Invalid email or password"}), 401
|
| 123 |
+
|
| 124 |
+
# Store user in Flask Session
|
| 125 |
+
session['user_id'] = row['id']
|
| 126 |
+
session['tenant_id'] = row['tenant_id']
|
| 127 |
+
session['user_role'] = row['role_name']
|
| 128 |
+
session['user_name'] = row['name']
|
| 129 |
+
|
| 130 |
+
return jsonify({
|
| 131 |
+
"success": True,
|
| 132 |
+
"user": {
|
| 133 |
+
"id": row['id'],
|
| 134 |
+
"name": row['name'],
|
| 135 |
+
"email": row['email'],
|
| 136 |
+
"role": row['role_name']
|
| 137 |
+
}
|
| 138 |
+
})
|
| 139 |
+
|
| 140 |
+
@auth_bp.route("/me", methods=["GET"])
|
| 141 |
+
def me():
|
| 142 |
+
if 'user_id' not in session:
|
| 143 |
+
return jsonify({"authenticated": False}), 401
|
| 144 |
+
|
| 145 |
+
# Fetch fresh user permissions and role from DB
|
| 146 |
+
conn = get_db_connection()
|
| 147 |
+
cursor = conn.cursor()
|
| 148 |
+
cursor.execute("""
|
| 149 |
+
SELECT p.code
|
| 150 |
+
FROM role_permissions rp
|
| 151 |
+
JOIN permissions p ON rp.permission_id = p.id
|
| 152 |
+
WHERE rp.role_id = (SELECT role_id FROM users WHERE id = ?)
|
| 153 |
+
""", (session['user_id'],))
|
| 154 |
+
permissions = [r[0] for r in cursor.fetchall()]
|
| 155 |
+
|
| 156 |
+
# Also fetch tenant name
|
| 157 |
+
cursor.execute("SELECT name FROM tenants WHERE id = ?", (session['tenant_id'],))
|
| 158 |
+
tenant_name = cursor.fetchone()[0]
|
| 159 |
+
conn.close()
|
| 160 |
+
|
| 161 |
+
return jsonify({
|
| 162 |
+
"authenticated": True,
|
| 163 |
+
"user": {
|
| 164 |
+
"id": session['user_id'],
|
| 165 |
+
"tenant_id": session['tenant_id'],
|
| 166 |
+
"tenant_name": tenant_name,
|
| 167 |
+
"name": session['user_name'],
|
| 168 |
+
"role": session['user_role'],
|
| 169 |
+
"permissions": permissions
|
| 170 |
+
}
|
| 171 |
+
})
|
| 172 |
+
|
| 173 |
+
@auth_bp.route("/logout", methods=["POST"])
|
| 174 |
+
def logout():
|
| 175 |
+
session.clear()
|
| 176 |
+
return jsonify({"success": True})
|
| 177 |
+
|
| 178 |
+
# =====================================================================
|
| 179 |
+
# Administration & Roles Console
|
| 180 |
+
# =====================================================================
|
| 181 |
+
|
| 182 |
+
@auth_bp.route("/users", methods=["GET"])
|
| 183 |
+
def get_tenant_users():
|
| 184 |
+
# Only authenticated users
|
| 185 |
+
if 'user_id' not in session:
|
| 186 |
+
return jsonify({"error": "Unauthorized"}), 401
|
| 187 |
+
|
| 188 |
+
# Check permission
|
| 189 |
+
conn = get_db_connection()
|
| 190 |
+
cursor = conn.cursor()
|
| 191 |
+
cursor.execute("""
|
| 192 |
+
SELECT COUNT(*) FROM role_permissions rp
|
| 193 |
+
JOIN permissions p ON rp.permission_id = p.id
|
| 194 |
+
WHERE rp.role_id = (SELECT role_id FROM users WHERE id = ?) AND p.code = 'users:manage'
|
| 195 |
+
""", (session['user_id'],))
|
| 196 |
+
is_admin = cursor.fetchone()[0] > 0
|
| 197 |
+
|
| 198 |
+
if not is_admin:
|
| 199 |
+
conn.close()
|
| 200 |
+
return jsonify({"error": "Forbidden: Requires user management permission"}), 403
|
| 201 |
+
|
| 202 |
+
cursor.execute("""
|
| 203 |
+
SELECT u.id, u.email, u.name, r.name as role_name, u.role_id, u.is_active
|
| 204 |
+
FROM users u
|
| 205 |
+
LEFT JOIN roles r ON u.role_id = r.id
|
| 206 |
+
WHERE u.tenant_id = ? ORDER BY u.id ASC
|
| 207 |
+
""", (session['tenant_id'],))
|
| 208 |
+
rows = cursor.fetchall()
|
| 209 |
+
users = [dict_from_row(r) for r in rows]
|
| 210 |
+
conn.close()
|
| 211 |
+
return jsonify(users)
|
| 212 |
+
|
| 213 |
+
@auth_bp.route("/admin/dashboard", methods=["GET"])
|
| 214 |
+
def get_admin_dashboard_stats():
|
| 215 |
+
if 'user_id' not in session:
|
| 216 |
+
return jsonify({"error": "Unauthorized"}), 401
|
| 217 |
+
|
| 218 |
+
conn = get_db_connection()
|
| 219 |
+
cursor = conn.cursor()
|
| 220 |
+
|
| 221 |
+
# Validate permission
|
| 222 |
+
cursor.execute("""
|
| 223 |
+
SELECT COUNT(*) FROM role_permissions rp
|
| 224 |
+
JOIN permissions p ON rp.permission_id = p.id
|
| 225 |
+
WHERE rp.role_id = (SELECT role_id FROM users WHERE id = ?) AND p.code = 'users:manage'
|
| 226 |
+
""", (session['user_id'],))
|
| 227 |
+
is_admin = cursor.fetchone()[0] > 0
|
| 228 |
+
if not is_admin:
|
| 229 |
+
conn.close()
|
| 230 |
+
return jsonify({"error": "Forbidden: Requires user management permission"}), 403
|
| 231 |
+
|
| 232 |
+
tenant_id = session['tenant_id']
|
| 233 |
+
|
| 234 |
+
# 1. Total reps (non-admin roles)
|
| 235 |
+
cursor.execute("""
|
| 236 |
+
SELECT COUNT(*)
|
| 237 |
+
FROM users u
|
| 238 |
+
LEFT JOIN roles r ON u.role_id = r.id
|
| 239 |
+
WHERE u.tenant_id = ? AND r.name != 'Admin'
|
| 240 |
+
""", (tenant_id,))
|
| 241 |
+
total_reps = cursor.fetchone()[0]
|
| 242 |
+
|
| 243 |
+
# 2. Total active reps
|
| 244 |
+
cursor.execute("""
|
| 245 |
+
SELECT COUNT(*)
|
| 246 |
+
FROM users u
|
| 247 |
+
LEFT JOIN roles r ON u.role_id = r.id
|
| 248 |
+
WHERE u.tenant_id = ? AND r.name != 'Admin' AND u.is_active = 1
|
| 249 |
+
""", (tenant_id,))
|
| 250 |
+
active_reps = cursor.fetchone()[0]
|
| 251 |
+
|
| 252 |
+
# 3. Total companies in organization
|
| 253 |
+
cursor.execute("SELECT COUNT(*) FROM companies WHERE tenant_id = ?", (tenant_id,))
|
| 254 |
+
total_companies = cursor.fetchone()[0]
|
| 255 |
+
|
| 256 |
+
# 4. Total activities in organization
|
| 257 |
+
cursor.execute("""
|
| 258 |
+
SELECT COUNT(*)
|
| 259 |
+
FROM activities a
|
| 260 |
+
JOIN users u ON a.user_id = u.id
|
| 261 |
+
WHERE u.tenant_id = ?
|
| 262 |
+
""", (tenant_id,))
|
| 263 |
+
total_activities = cursor.fetchone()[0]
|
| 264 |
+
|
| 265 |
+
# Fetch list of reps with stats
|
| 266 |
+
cursor.execute("""
|
| 267 |
+
SELECT
|
| 268 |
+
u.id,
|
| 269 |
+
u.name,
|
| 270 |
+
u.email,
|
| 271 |
+
u.is_active,
|
| 272 |
+
r.name as role_name,
|
| 273 |
+
(SELECT COUNT(*) FROM companies c WHERE c.assigned_rep_id = u.id) as assigned_companies,
|
| 274 |
+
(SELECT COUNT(*) FROM activities a WHERE a.user_id = u.id) as total_activities,
|
| 275 |
+
u.role_id
|
| 276 |
+
FROM users u
|
| 277 |
+
LEFT JOIN roles r ON u.role_id = r.id
|
| 278 |
+
WHERE u.tenant_id = ? AND r.name != 'Admin'
|
| 279 |
+
ORDER BY u.id ASC
|
| 280 |
+
""", (tenant_id,))
|
| 281 |
+
|
| 282 |
+
reps = []
|
| 283 |
+
for row in cursor.fetchall():
|
| 284 |
+
reps.append({
|
| 285 |
+
"id": row[0],
|
| 286 |
+
"name": row[1],
|
| 287 |
+
"email": row[2],
|
| 288 |
+
"is_active": row[3],
|
| 289 |
+
"role_name": row[4],
|
| 290 |
+
"assigned_companies": row[5],
|
| 291 |
+
"total_activities": row[6],
|
| 292 |
+
"role_id": row[7]
|
| 293 |
+
})
|
| 294 |
+
|
| 295 |
+
conn.close()
|
| 296 |
+
|
| 297 |
+
return jsonify({
|
| 298 |
+
"stats": {
|
| 299 |
+
"total_reps": total_reps,
|
| 300 |
+
"active_reps": active_reps,
|
| 301 |
+
"total_companies": total_companies,
|
| 302 |
+
"total_activities": total_activities
|
| 303 |
+
},
|
| 304 |
+
"reps": reps
|
| 305 |
+
})
|
| 306 |
+
|
| 307 |
+
@auth_bp.route("/users/create", methods=["POST"])
|
| 308 |
+
def create_tenant_user():
|
| 309 |
+
if 'user_id' not in session:
|
| 310 |
+
return jsonify({"error": "Unauthorized"}), 401
|
| 311 |
+
|
| 312 |
+
conn = get_db_connection()
|
| 313 |
+
cursor = conn.cursor()
|
| 314 |
+
|
| 315 |
+
# Validate permission
|
| 316 |
+
cursor.execute("""
|
| 317 |
+
SELECT COUNT(*) FROM role_permissions rp
|
| 318 |
+
JOIN permissions p ON rp.permission_id = p.id
|
| 319 |
+
WHERE rp.role_id = (SELECT role_id FROM users WHERE id = ?) AND p.code = 'users:manage'
|
| 320 |
+
""", (session['user_id'],))
|
| 321 |
+
is_admin = cursor.fetchone()[0] > 0
|
| 322 |
+
if not is_admin:
|
| 323 |
+
conn.close()
|
| 324 |
+
return jsonify({"error": "Forbidden: Requires user management permission"}), 403
|
| 325 |
+
|
| 326 |
+
data = request.get_json() or {}
|
| 327 |
+
name = data.get("name")
|
| 328 |
+
email = data.get("email")
|
| 329 |
+
password = data.get("password")
|
| 330 |
+
role_id = data.get("role_id")
|
| 331 |
+
|
| 332 |
+
if not all([name, email, password, role_id]):
|
| 333 |
+
conn.close()
|
| 334 |
+
return jsonify({"error": "Missing required user fields"}), 400
|
| 335 |
+
|
| 336 |
+
try:
|
| 337 |
+
# Check if email exists
|
| 338 |
+
cursor.execute("SELECT id FROM users WHERE email = ?", (email,))
|
| 339 |
+
if cursor.fetchone():
|
| 340 |
+
conn.close()
|
| 341 |
+
return jsonify({"error": "Email is already registered"}), 400
|
| 342 |
+
|
| 343 |
+
# Validate that the role belongs to this tenant
|
| 344 |
+
cursor.execute("SELECT name FROM roles WHERE id = ? AND tenant_id = ?", (role_id, session['tenant_id']))
|
| 345 |
+
role_row = cursor.fetchone()
|
| 346 |
+
if not role_row:
|
| 347 |
+
conn.close()
|
| 348 |
+
return jsonify({"error": "Invalid role selected"}), 400
|
| 349 |
+
role_name = role_row[0]
|
| 350 |
+
|
| 351 |
+
if role_name.strip().lower() == "admin":
|
| 352 |
+
conn.close()
|
| 353 |
+
return jsonify({"error": "Creating additional Administrator accounts is disabled. Only BD Rep and Viewer roles can be created."}), 400
|
| 354 |
+
|
| 355 |
+
pwd_hash = hash_password(password)
|
| 356 |
+
cursor.execute("""
|
| 357 |
+
INSERT INTO users (tenant_id, email, name, password_hash, role_id, role)
|
| 358 |
+
VALUES (?, ?, ?, ?, ?, ?)
|
| 359 |
+
""", (session['tenant_id'], email, name, pwd_hash, role_id, role_name))
|
| 360 |
+
conn.commit()
|
| 361 |
+
return jsonify({"success": True, "message": "User created successfully."})
|
| 362 |
+
except Exception as e:
|
| 363 |
+
conn.rollback()
|
| 364 |
+
return jsonify({"error": str(e)}), 500
|
| 365 |
+
finally:
|
| 366 |
+
conn.close()
|
| 367 |
+
|
| 368 |
+
@auth_bp.route("/roles", methods=["GET"])
|
| 369 |
+
def get_tenant_roles():
|
| 370 |
+
if 'user_id' not in session:
|
| 371 |
+
return jsonify({"error": "Unauthorized"}), 401
|
| 372 |
+
|
| 373 |
+
conn = get_db_connection()
|
| 374 |
+
cursor = conn.cursor()
|
| 375 |
+
|
| 376 |
+
# Fetch roles and their permissions list
|
| 377 |
+
cursor.execute("""
|
| 378 |
+
SELECT id, name, description
|
| 379 |
+
FROM roles
|
| 380 |
+
WHERE tenant_id = ? ORDER BY id ASC
|
| 381 |
+
""", (session['tenant_id'],))
|
| 382 |
+
roles = [dict_from_row(r) for r in cursor.fetchall()]
|
| 383 |
+
|
| 384 |
+
for r in roles:
|
| 385 |
+
cursor.execute("""
|
| 386 |
+
SELECT p.code
|
| 387 |
+
FROM role_permissions rp
|
| 388 |
+
JOIN permissions p ON rp.permission_id = p.id
|
| 389 |
+
WHERE rp.role_id = ?
|
| 390 |
+
""", (r['id'],))
|
| 391 |
+
r['permissions'] = [row[0] for row in cursor.fetchall()]
|
| 392 |
+
|
| 393 |
+
conn.close()
|
| 394 |
+
return jsonify(roles)
|
| 395 |
+
|
| 396 |
+
@auth_bp.route("/permissions", methods=["GET"])
|
| 397 |
+
def get_all_permissions():
|
| 398 |
+
if 'user_id' not in session:
|
| 399 |
+
return jsonify({"error": "Unauthorized"}), 401
|
| 400 |
+
|
| 401 |
+
conn = get_db_connection()
|
| 402 |
+
cursor = conn.cursor()
|
| 403 |
+
cursor.execute("SELECT id, code, description FROM permissions ORDER BY id ASC")
|
| 404 |
+
permissions = [dict_from_row(r) for r in cursor.fetchall()]
|
| 405 |
+
conn.close()
|
| 406 |
+
return jsonify(permissions)
|
| 407 |
+
|
| 408 |
+
@auth_bp.route("/roles/<int:role_id>/permissions", methods=["POST"])
|
| 409 |
+
def update_role_permissions(role_id):
|
| 410 |
+
if 'user_id' not in session:
|
| 411 |
+
return jsonify({"error": "Unauthorized"}), 401
|
| 412 |
+
|
| 413 |
+
conn = get_db_connection()
|
| 414 |
+
cursor = conn.cursor()
|
| 415 |
+
|
| 416 |
+
# Validate permission to manage users/roles
|
| 417 |
+
cursor.execute("""
|
| 418 |
+
SELECT COUNT(*) FROM role_permissions rp
|
| 419 |
+
JOIN permissions p ON rp.permission_id = p.id
|
| 420 |
+
WHERE rp.role_id = (SELECT role_id FROM users WHERE id = ?) AND p.code = 'users:manage'
|
| 421 |
+
""", (session['user_id'],))
|
| 422 |
+
is_admin = cursor.fetchone()[0] > 0
|
| 423 |
+
if not is_admin:
|
| 424 |
+
conn.close()
|
| 425 |
+
return jsonify({"error": "Forbidden: Requires role management permission"}), 403
|
| 426 |
+
|
| 427 |
+
# Validate that role belongs to the current tenant
|
| 428 |
+
cursor.execute("SELECT name FROM roles WHERE id = ? AND tenant_id = ?", (role_id, session['tenant_id']))
|
| 429 |
+
role_row = cursor.fetchone()
|
| 430 |
+
if not role_row:
|
| 431 |
+
conn.close()
|
| 432 |
+
return jsonify({"error": "Role not found or access denied"}), 404
|
| 433 |
+
|
| 434 |
+
# Do not allow modifying the primary 'Admin' role's permissions to prevent lock-outs
|
| 435 |
+
if role_row[0] == 'Admin':
|
| 436 |
+
conn.close()
|
| 437 |
+
return jsonify({"error": "Cannot modify permissions of the core Admin role"}), 400
|
| 438 |
+
|
| 439 |
+
data = request.get_json() or {}
|
| 440 |
+
permission_codes = data.get("permissions", [])
|
| 441 |
+
|
| 442 |
+
try:
|
| 443 |
+
# Delete existing permissions mapping
|
| 444 |
+
cursor.execute("DELETE FROM role_permissions WHERE role_id = ?", (role_id,))
|
| 445 |
+
|
| 446 |
+
# Resolve permission codes to IDs
|
| 447 |
+
if permission_codes:
|
| 448 |
+
# Prepare SQL IN clause safely
|
| 449 |
+
placeholders = ",".join(["?"] * len(permission_codes))
|
| 450 |
+
cursor.execute(f"SELECT id FROM permissions WHERE code IN ({placeholders})", tuple(permission_codes))
|
| 451 |
+
perm_ids = [r[0] for r in cursor.fetchall()]
|
| 452 |
+
|
| 453 |
+
# Insert new mappings
|
| 454 |
+
for pid in perm_ids:
|
| 455 |
+
cursor.execute("INSERT INTO role_permissions (role_id, permission_id) VALUES (?, ?)", (role_id, pid))
|
| 456 |
+
|
| 457 |
+
conn.commit()
|
| 458 |
+
return jsonify({"success": True, "message": "Role permissions updated successfully."})
|
| 459 |
+
except Exception as e:
|
| 460 |
+
conn.rollback()
|
| 461 |
+
return jsonify({"error": str(e)}), 500
|
| 462 |
+
finally:
|
| 463 |
+
conn.close()
|
| 464 |
+
|
| 465 |
+
@auth_bp.route("/roles/create", methods=["POST"])
|
| 466 |
+
def create_custom_role():
|
| 467 |
+
if 'user_id' not in session:
|
| 468 |
+
return jsonify({"error": "Unauthorized"}), 401
|
| 469 |
+
|
| 470 |
+
conn = get_db_connection()
|
| 471 |
+
cursor = conn.cursor()
|
| 472 |
+
|
| 473 |
+
# Validate permission
|
| 474 |
+
cursor.execute("""
|
| 475 |
+
SELECT COUNT(*) FROM role_permissions rp
|
| 476 |
+
JOIN permissions p ON rp.permission_id = p.id
|
| 477 |
+
WHERE rp.role_id = (SELECT role_id FROM users WHERE id = ?) AND p.code = 'users:manage'
|
| 478 |
+
""", (session['user_id'],))
|
| 479 |
+
is_admin = cursor.fetchone()[0] > 0
|
| 480 |
+
if not is_admin:
|
| 481 |
+
conn.close()
|
| 482 |
+
return jsonify({"error": "Forbidden: Requires role management permission"}), 403
|
| 483 |
+
|
| 484 |
+
data = request.get_json() or {}
|
| 485 |
+
name = data.get("name")
|
| 486 |
+
description = data.get("description", "")
|
| 487 |
+
|
| 488 |
+
if not name:
|
| 489 |
+
conn.close()
|
| 490 |
+
return jsonify({"error": "Role name is required"}), 400
|
| 491 |
+
|
| 492 |
+
try:
|
| 493 |
+
cursor.execute("""
|
| 494 |
+
INSERT INTO roles (tenant_id, name, description)
|
| 495 |
+
VALUES (?, ?, ?)
|
| 496 |
+
""", (session['tenant_id'], name, description))
|
| 497 |
+
conn.commit()
|
| 498 |
+
return jsonify({"success": True, "message": f"Role '{name}' created successfully."})
|
| 499 |
+
except Exception as e:
|
| 500 |
+
conn.rollback()
|
| 501 |
+
return jsonify({"error": str(e)}), 500
|
| 502 |
+
finally:
|
| 503 |
+
conn.close()
|
| 504 |
+
|
| 505 |
+
@auth_bp.route("/users/<int:target_user_id>/update", methods=["POST"])
|
| 506 |
+
def update_tenant_user(target_user_id):
|
| 507 |
+
if 'user_id' not in session:
|
| 508 |
+
return jsonify({"error": "Unauthorized"}), 401
|
| 509 |
+
|
| 510 |
+
conn = get_db_connection()
|
| 511 |
+
cursor = conn.cursor()
|
| 512 |
+
|
| 513 |
+
# Validate permission of caller
|
| 514 |
+
cursor.execute("""
|
| 515 |
+
SELECT COUNT(*) FROM role_permissions rp
|
| 516 |
+
JOIN permissions p ON rp.permission_id = p.id
|
| 517 |
+
WHERE rp.role_id = (SELECT role_id FROM users WHERE id = ?) AND p.code = 'users:manage'
|
| 518 |
+
""", (session['user_id'],))
|
| 519 |
+
is_admin = cursor.fetchone()[0] > 0
|
| 520 |
+
if not is_admin:
|
| 521 |
+
conn.close()
|
| 522 |
+
return jsonify({"error": "Forbidden"}), 403
|
| 523 |
+
|
| 524 |
+
# Check that target user belongs to same tenant
|
| 525 |
+
cursor.execute("SELECT tenant_id, role_id FROM users WHERE id = ?", (target_user_id,))
|
| 526 |
+
target_user = cursor.fetchone()
|
| 527 |
+
if not target_user or target_user[0] != session['tenant_id']:
|
| 528 |
+
conn.close()
|
| 529 |
+
return jsonify({"error": "User not found in your organization"}), 404
|
| 530 |
+
|
| 531 |
+
data = request.get_json() or {}
|
| 532 |
+
role_id = data.get("role_id")
|
| 533 |
+
is_active = data.get("is_active") # 0 or 1
|
| 534 |
+
name = data.get("name")
|
| 535 |
+
email = data.get("email")
|
| 536 |
+
|
| 537 |
+
if role_id is not None:
|
| 538 |
+
# Verify role_id belongs to the tenant
|
| 539 |
+
cursor.execute("SELECT name FROM roles WHERE id = ? AND tenant_id = ?", (role_id, session['tenant_id']))
|
| 540 |
+
role_row = cursor.fetchone()
|
| 541 |
+
if not role_row:
|
| 542 |
+
conn.close()
|
| 543 |
+
return jsonify({"error": "Invalid role selection"}), 400
|
| 544 |
+
|
| 545 |
+
# Don't let an admin change their own role or remove the last admin role
|
| 546 |
+
if target_user_id == session['user_id'] and role_row[0] != 'Admin':
|
| 547 |
+
conn.close()
|
| 548 |
+
return jsonify({"error": "You cannot remove your own Admin privileges"}), 400
|
| 549 |
+
|
| 550 |
+
# Admin restriction guard: cannot assign Admin role to others
|
| 551 |
+
if target_user_id != session['user_id'] and role_row[0].strip().lower() == "admin":
|
| 552 |
+
conn.close()
|
| 553 |
+
return jsonify({"error": "Creating additional Administrator accounts is disabled."}), 400
|
| 554 |
+
|
| 555 |
+
cursor.execute("UPDATE users SET role_id = ?, role = ? WHERE id = ?", (role_id, role_row[0], target_user_id))
|
| 556 |
+
|
| 557 |
+
if is_active is not None:
|
| 558 |
+
if target_user_id == session['user_id'] and int(is_active) == 0:
|
| 559 |
+
conn.close()
|
| 560 |
+
return jsonify({"error": "You cannot deactivate your own account"}), 400
|
| 561 |
+
cursor.execute("UPDATE users SET is_active = ? WHERE id = ?", (int(is_active), target_user_id))
|
| 562 |
+
|
| 563 |
+
if name:
|
| 564 |
+
cursor.execute("UPDATE users SET name = ? WHERE id = ?", (name, target_user_id))
|
| 565 |
+
if email:
|
| 566 |
+
cursor.execute("UPDATE users SET email = ? WHERE id = ?", (email, target_user_id))
|
| 567 |
+
|
| 568 |
+
conn.commit()
|
| 569 |
+
conn.close()
|
| 570 |
+
return jsonify({"success": True})
|
| 571 |
+
|
| 572 |
+
@auth_bp.route("/users/<int:target_user_id>/delete", methods=["POST"])
|
| 573 |
+
def delete_tenant_user(target_user_id):
|
| 574 |
+
if 'user_id' not in session:
|
| 575 |
+
return jsonify({"error": "Unauthorized"}), 401
|
| 576 |
+
|
| 577 |
+
conn = get_db_connection()
|
| 578 |
+
cursor = conn.cursor()
|
| 579 |
+
|
| 580 |
+
# Validate permission
|
| 581 |
+
cursor.execute("""
|
| 582 |
+
SELECT COUNT(*) FROM role_permissions rp
|
| 583 |
+
JOIN permissions p ON rp.permission_id = p.id
|
| 584 |
+
WHERE rp.role_id = (SELECT role_id FROM users WHERE id = ?) AND p.code = 'users:manage'
|
| 585 |
+
""", (session['user_id'],))
|
| 586 |
+
is_admin = cursor.fetchone()[0] > 0
|
| 587 |
+
if not is_admin:
|
| 588 |
+
conn.close()
|
| 589 |
+
return jsonify({"error": "Forbidden"}), 403
|
| 590 |
+
|
| 591 |
+
if target_user_id == session['user_id']:
|
| 592 |
+
conn.close()
|
| 593 |
+
return jsonify({"error": "You cannot delete your own account"}), 400
|
| 594 |
+
|
| 595 |
+
# Verify target belongs to same tenant
|
| 596 |
+
cursor.execute("SELECT tenant_id FROM users WHERE id = ?", (target_user_id,))
|
| 597 |
+
target_user = cursor.fetchone()
|
| 598 |
+
if not target_user or target_user[0] != session['tenant_id']:
|
| 599 |
+
conn.close()
|
| 600 |
+
return jsonify({"error": "User not found in your organization"}), 404
|
| 601 |
+
|
| 602 |
+
try:
|
| 603 |
+
cursor.execute("DELETE FROM users WHERE id = ?", (target_user_id,))
|
| 604 |
+
conn.commit()
|
| 605 |
+
success = True
|
| 606 |
+
error_msg = None
|
| 607 |
+
except Exception as e:
|
| 608 |
+
success = False
|
| 609 |
+
error_msg = "This user has active pipeline history (assigned companies or activities) and cannot be deleted. Deactivate their account instead."
|
| 610 |
+
|
| 611 |
+
conn.close()
|
| 612 |
+
if success:
|
| 613 |
+
return jsonify({"success": True})
|
| 614 |
+
else:
|
| 615 |
+
return jsonify({"error": error_msg}), 409
|
routes/command.py
ADDED
|
@@ -0,0 +1,1246 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import json
|
| 3 |
+
import re
|
| 4 |
+
import datetime
|
| 5 |
+
import requests
|
| 6 |
+
from flask import Blueprint, request, jsonify, session
|
| 7 |
+
from db import get_db_connection
|
| 8 |
+
from routes.auth import hash_password
|
| 9 |
+
from services.ai_service import clean_int, db_list_interactions, db_list_interactions_by_kinds
|
| 10 |
+
from services.tool_router import heuristic_tool_route, normalize_tool_route, save_tool_route
|
| 11 |
+
|
| 12 |
+
def db_handle_rep_command(action, query_data, tenant_id):
|
| 13 |
+
conn = get_db_connection()
|
| 14 |
+
cursor = conn.cursor()
|
| 15 |
+
try:
|
| 16 |
+
if action == "add_rep":
|
| 17 |
+
name = query_data.get("name")
|
| 18 |
+
email = query_data.get("email")
|
| 19 |
+
role_name = query_data.get("role", "BD Rep")
|
| 20 |
+
|
| 21 |
+
if role_name.strip().lower() == "admin":
|
| 22 |
+
return {"action": "answer", "reply": "Creating additional Administrator accounts is disabled. Only BD Rep and Viewer roles can be created."}
|
| 23 |
+
password = query_data.get("password", "password123")
|
| 24 |
+
is_active = int(query_data.get("is_active", 1))
|
| 25 |
+
|
| 26 |
+
if not name or not email:
|
| 27 |
+
return {"action": "answer", "reply": "Representative registration requires both a name and a valid email address."}
|
| 28 |
+
|
| 29 |
+
# Check duplicate email
|
| 30 |
+
cursor.execute("SELECT id FROM users WHERE email = ?", (email,))
|
| 31 |
+
if cursor.fetchone():
|
| 32 |
+
return {"action": "answer", "reply": f"The email {email} is already registered."}
|
| 33 |
+
|
| 34 |
+
# Get role ID
|
| 35 |
+
cursor.execute("SELECT id FROM roles WHERE tenant_id = ? AND LOWER(name) = LOWER(?)", (tenant_id, role_name))
|
| 36 |
+
role_row = cursor.fetchone()
|
| 37 |
+
if not role_row:
|
| 38 |
+
cursor.execute("SELECT id FROM roles WHERE tenant_id = ? AND name = ?", (tenant_id, "BD Rep"))
|
| 39 |
+
role_row = cursor.fetchone()
|
| 40 |
+
role_id = role_row[0] if role_row else 1
|
| 41 |
+
|
| 42 |
+
pwd_hash = hash_password(password)
|
| 43 |
+
cursor.execute("""
|
| 44 |
+
INSERT INTO users (tenant_id, email, name, password_hash, role_id, role, is_active)
|
| 45 |
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
| 46 |
+
""", (tenant_id, email, name, pwd_hash, role_id, role_name, is_active))
|
| 47 |
+
conn.commit()
|
| 48 |
+
return {"action": "refresh_reps", "reply": f"Successfully registered new representative: {name} ({email}) as {role_name}."}
|
| 49 |
+
|
| 50 |
+
elif action == "edit_rep":
|
| 51 |
+
target_user_id = query_data.get("target_user_id")
|
| 52 |
+
email_lookup = query_data.get("email_lookup")
|
| 53 |
+
|
| 54 |
+
# Find user
|
| 55 |
+
if target_user_id:
|
| 56 |
+
cursor.execute("SELECT id, name, email, role_id FROM users WHERE id = ? AND tenant_id = ?", (target_user_id, tenant_id))
|
| 57 |
+
elif email_lookup:
|
| 58 |
+
cursor.execute("SELECT id, name, email, role_id FROM users WHERE LOWER(email) = LOWER(?) AND tenant_id = ?", (email_lookup, tenant_id))
|
| 59 |
+
else:
|
| 60 |
+
return {"action": "answer", "reply": "Could not identify the representative to edit."}
|
| 61 |
+
|
| 62 |
+
user_row = cursor.fetchone()
|
| 63 |
+
if not user_row:
|
| 64 |
+
return {"action": "answer", "reply": "Representative not found."}
|
| 65 |
+
uid, cur_name, cur_email, cur_role_id = user_row[0], user_row[1], user_row[2], user_row[3]
|
| 66 |
+
|
| 67 |
+
rep_data = query_data.get("rep_data", {})
|
| 68 |
+
new_name = rep_data.get("name", cur_name)
|
| 69 |
+
new_email = rep_data.get("email", cur_email)
|
| 70 |
+
new_role_name = rep_data.get("role")
|
| 71 |
+
new_is_active = rep_data.get("is_active")
|
| 72 |
+
new_password = rep_data.get("password")
|
| 73 |
+
|
| 74 |
+
if new_role_name and new_role_name.strip().lower() == "admin":
|
| 75 |
+
return {"action": "answer", "reply": "Elevating a user to Administrator is disabled. Only BD Rep and Viewer roles can be assigned."}
|
| 76 |
+
|
| 77 |
+
if uid == 1:
|
| 78 |
+
if new_is_active is not None and int(new_is_active) == 0:
|
| 79 |
+
return {"action": "answer", "reply": "The primary Administrator account cannot be deactivated."}
|
| 80 |
+
if new_role_name and new_role_name.strip().lower() != "admin":
|
| 81 |
+
return {"action": "answer", "reply": "The primary Administrator account's role cannot be changed."}
|
| 82 |
+
|
| 83 |
+
new_role_id = cur_role_id
|
| 84 |
+
if new_role_name:
|
| 85 |
+
cursor.execute("SELECT id FROM roles WHERE tenant_id = ? AND LOWER(name) = LOWER(?)", (tenant_id, new_role_name))
|
| 86 |
+
role_row = cursor.fetchone()
|
| 87 |
+
if role_row:
|
| 88 |
+
new_role_id = role_row[0]
|
| 89 |
+
else:
|
| 90 |
+
new_role_name = None
|
| 91 |
+
|
| 92 |
+
if new_password:
|
| 93 |
+
pwd_hash = hash_password(new_password)
|
| 94 |
+
cursor.execute("UPDATE users SET password_hash = ? WHERE id = ?", (pwd_hash, uid))
|
| 95 |
+
if new_is_active is not None:
|
| 96 |
+
if int(new_is_active) == 0 and uid == session.get('user_id'):
|
| 97 |
+
return {"action": "answer", "reply": "You cannot deactivate your own administrator account."}
|
| 98 |
+
cursor.execute("UPDATE users SET is_active = ? WHERE id = ?", (int(new_is_active), uid))
|
| 99 |
+
|
| 100 |
+
cursor.execute("""
|
| 101 |
+
UPDATE users SET name = ?, email = ?, role_id = ?, role = COALESCE(?, role)
|
| 102 |
+
WHERE id = ?
|
| 103 |
+
""", (new_name, new_email, new_role_id, new_role_name, uid))
|
| 104 |
+
conn.commit()
|
| 105 |
+
return {"action": "refresh_reps", "reply": f"Successfully updated representative: {new_name}."}
|
| 106 |
+
|
| 107 |
+
elif action == "delete_rep":
|
| 108 |
+
target_user_id = query_data.get("target_user_id")
|
| 109 |
+
email_lookup = query_data.get("email_lookup")
|
| 110 |
+
|
| 111 |
+
# Find user
|
| 112 |
+
if target_user_id:
|
| 113 |
+
cursor.execute("SELECT id, name, email FROM users WHERE id = ? AND tenant_id = ?", (target_user_id, tenant_id))
|
| 114 |
+
elif email_lookup:
|
| 115 |
+
cursor.execute("SELECT id, name, email FROM users WHERE LOWER(email) = LOWER(?) AND tenant_id = ?", (email_lookup, tenant_id))
|
| 116 |
+
else:
|
| 117 |
+
return {"action": "answer", "reply": "Could not identify the representative to delete."}
|
| 118 |
+
|
| 119 |
+
user_row = cursor.fetchone()
|
| 120 |
+
if not user_row:
|
| 121 |
+
return {"action": "answer", "reply": "Representative not found."}
|
| 122 |
+
uid, cur_name, cur_email = user_row[0], user_row[1], user_row[2]
|
| 123 |
+
|
| 124 |
+
if uid == session.get('user_id'):
|
| 125 |
+
return {"action": "answer", "reply": "You cannot delete your own administrator account."}
|
| 126 |
+
|
| 127 |
+
try:
|
| 128 |
+
cursor.execute("SELECT COUNT(*) FROM companies WHERE assigned_rep_id = ?", (uid,))
|
| 129 |
+
c_cnt = cursor.fetchone()[0]
|
| 130 |
+
cursor.execute("SELECT COUNT(*) FROM activities WHERE user_id = ?", (uid,))
|
| 131 |
+
a_cnt = cursor.fetchone()[0]
|
| 132 |
+
|
| 133 |
+
if c_cnt > 0 or a_cnt > 0:
|
| 134 |
+
return {"action": "answer", "reply": f"Representative {cur_name} cannot be deleted because they have associated pipeline records. Please deactivate them instead."}
|
| 135 |
+
|
| 136 |
+
cursor.execute("DELETE FROM users WHERE id = ?", (uid,))
|
| 137 |
+
conn.commit()
|
| 138 |
+
return {"action": "refresh_reps", "reply": f"Successfully deleted representative: {cur_name}."}
|
| 139 |
+
except Exception as del_err:
|
| 140 |
+
conn.rollback()
|
| 141 |
+
return {"action": "answer", "reply": f"Could not delete representative: {del_err}. We recommend deactivating their account instead."}
|
| 142 |
+
except Exception as e:
|
| 143 |
+
conn.rollback()
|
| 144 |
+
return {"action": "answer", "reply": f"An error occurred while managing representative: {e}"}
|
| 145 |
+
finally:
|
| 146 |
+
conn.close()
|
| 147 |
+
|
| 148 |
+
command_bp = Blueprint('command', __name__)
|
| 149 |
+
|
| 150 |
+
GROQ_URL = "https://api.groq.com/openai/v1/chat/completions"
|
| 151 |
+
OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
|
| 152 |
+
|
| 153 |
+
DEFAULT_STAGE_NAMES = [
|
| 154 |
+
"New",
|
| 155 |
+
"Shortlisted",
|
| 156 |
+
"Research Done",
|
| 157 |
+
"Contacted",
|
| 158 |
+
"Engaged",
|
| 159 |
+
"Meeting Scheduled",
|
| 160 |
+
"Meeting Done",
|
| 161 |
+
"Proposal Sent",
|
| 162 |
+
"Won",
|
| 163 |
+
"Paused / Not a Fit",
|
| 164 |
+
]
|
| 165 |
+
|
| 166 |
+
COMMON_LOCATION_WORDS = {
|
| 167 |
+
"toronto": ("city", "Toronto"),
|
| 168 |
+
"windsor": ("city", "Windsor"),
|
| 169 |
+
"brampton": ("city", "Brampton"),
|
| 170 |
+
"mississauga": ("city", "Mississauga"),
|
| 171 |
+
"hamilton": ("city", "Hamilton"),
|
| 172 |
+
"ottawa": ("city", "Ottawa"),
|
| 173 |
+
"montreal": ("city", "Montreal"),
|
| 174 |
+
"vancouver": ("city", "Vancouver"),
|
| 175 |
+
"calgary": ("city", "Calgary"),
|
| 176 |
+
"pune": ("city", "Pune"),
|
| 177 |
+
"mumbai": ("city", "Mumbai"),
|
| 178 |
+
"delhi": ("city", "Delhi"),
|
| 179 |
+
"bengaluru": ("city", "Bengaluru"),
|
| 180 |
+
"bangalore": ("city", "Bengaluru"),
|
| 181 |
+
"chennai": ("city", "Chennai"),
|
| 182 |
+
"hyderabad": ("city", "Hyderabad"),
|
| 183 |
+
"ontario": ("province", "Ontario"),
|
| 184 |
+
"quebec": ("province", "Quebec"),
|
| 185 |
+
"alberta": ("province", "Alberta"),
|
| 186 |
+
"california": ("province", "California"),
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
def _answer(reply):
|
| 190 |
+
return {"action": "answer", "reply": reply}
|
| 191 |
+
|
| 192 |
+
def _clean_text(value):
|
| 193 |
+
return re.sub(r"\s+", " ", str(value or "")).strip()
|
| 194 |
+
|
| 195 |
+
def _norm(value):
|
| 196 |
+
return re.sub(r"[^a-z0-9]+", " ", str(value or "").lower()).strip()
|
| 197 |
+
|
| 198 |
+
def _has_word(query, words):
|
| 199 |
+
return any(re.search(rf"\b{re.escape(word)}\b", query, re.I) for word in words)
|
| 200 |
+
|
| 201 |
+
def _has_phrase(query, phrases):
|
| 202 |
+
normalized = _norm(query)
|
| 203 |
+
return any(_norm(phrase) in normalized for phrase in phrases)
|
| 204 |
+
|
| 205 |
+
def _extract_context_list(context, labels):
|
| 206 |
+
decoder = json.JSONDecoder()
|
| 207 |
+
for label in labels:
|
| 208 |
+
idx = context.find(label)
|
| 209 |
+
if idx < 0:
|
| 210 |
+
continue
|
| 211 |
+
start = context.find("[", idx)
|
| 212 |
+
if start < 0:
|
| 213 |
+
continue
|
| 214 |
+
try:
|
| 215 |
+
value, _ = decoder.raw_decode(context[start:])
|
| 216 |
+
if isinstance(value, list):
|
| 217 |
+
return value
|
| 218 |
+
except Exception:
|
| 219 |
+
continue
|
| 220 |
+
return []
|
| 221 |
+
|
| 222 |
+
def _context_entities(context):
|
| 223 |
+
return (
|
| 224 |
+
_extract_context_list(context, ["Full Database (Companies):", "Companies:"]),
|
| 225 |
+
_extract_context_list(context, ["Full Database (Contacts):", "Contacts:"]),
|
| 226 |
+
)
|
| 227 |
+
|
| 228 |
+
def _stage_names(companies):
|
| 229 |
+
names = []
|
| 230 |
+
for company in companies:
|
| 231 |
+
stage = company.get("stage") or company.get("stage_name")
|
| 232 |
+
if stage and stage not in names:
|
| 233 |
+
names.append(stage)
|
| 234 |
+
for stage in DEFAULT_STAGE_NAMES:
|
| 235 |
+
if stage not in names:
|
| 236 |
+
names.append(stage)
|
| 237 |
+
return names
|
| 238 |
+
|
| 239 |
+
def _stage_id_for_name(companies, stage_name):
|
| 240 |
+
wanted = _norm(stage_name)
|
| 241 |
+
for company in companies:
|
| 242 |
+
if _norm(company.get("stage") or company.get("stage_name")) == wanted and company.get("stage_id"):
|
| 243 |
+
return company.get("stage_id")
|
| 244 |
+
stage_order = {name.lower(): idx + 1 for idx, name in enumerate(DEFAULT_STAGE_NAMES)}
|
| 245 |
+
return stage_order.get(str(stage_name or "").lower())
|
| 246 |
+
|
| 247 |
+
def _find_company(query, companies):
|
| 248 |
+
qnorm = _norm(query)
|
| 249 |
+
id_match = re.search(r"\b(?:company\s*)?id\s*#?\s*(\d+)\b", query, re.I)
|
| 250 |
+
if id_match:
|
| 251 |
+
cid = int(id_match.group(1))
|
| 252 |
+
for company in companies:
|
| 253 |
+
if int(company.get("id") or 0) == cid:
|
| 254 |
+
return company
|
| 255 |
+
|
| 256 |
+
best = None
|
| 257 |
+
best_score = 0
|
| 258 |
+
for company in companies:
|
| 259 |
+
name = company.get("name") or company.get("company_name") or ""
|
| 260 |
+
nnorm = _norm(name)
|
| 261 |
+
if not nnorm:
|
| 262 |
+
continue
|
| 263 |
+
score = 0
|
| 264 |
+
if nnorm and nnorm in qnorm:
|
| 265 |
+
score = len(nnorm) + 100
|
| 266 |
+
else:
|
| 267 |
+
tokens = [token for token in nnorm.split() if len(token) > 2]
|
| 268 |
+
matched = sum(1 for token in tokens if re.search(rf"\b{re.escape(token)}\b", qnorm))
|
| 269 |
+
if tokens and matched:
|
| 270 |
+
score = int((matched / len(tokens)) * 80) + matched
|
| 271 |
+
if score > best_score:
|
| 272 |
+
best = company
|
| 273 |
+
best_score = score
|
| 274 |
+
return best if best_score >= 45 else None
|
| 275 |
+
|
| 276 |
+
def _find_contact(query, contacts):
|
| 277 |
+
qnorm = _norm(query)
|
| 278 |
+
id_match = re.search(r"\b(?:contact\s*)?id\s*#?\s*(\d+)\b", query, re.I)
|
| 279 |
+
if id_match:
|
| 280 |
+
contact_id = int(id_match.group(1))
|
| 281 |
+
for contact in contacts:
|
| 282 |
+
if int(contact.get("id") or 0) == contact_id:
|
| 283 |
+
return contact
|
| 284 |
+
|
| 285 |
+
best = None
|
| 286 |
+
best_score = 0
|
| 287 |
+
for contact in contacts:
|
| 288 |
+
full_name = _norm(f"{contact.get('first_name', '')} {contact.get('last_name', '')}")
|
| 289 |
+
if not full_name:
|
| 290 |
+
continue
|
| 291 |
+
if full_name in qnorm:
|
| 292 |
+
score = len(full_name) + 100
|
| 293 |
+
else:
|
| 294 |
+
tokens = [token for token in full_name.split() if len(token) > 1]
|
| 295 |
+
matched = sum(1 for token in tokens if re.search(rf"\b{re.escape(token)}\b", qnorm))
|
| 296 |
+
score = int((matched / max(len(tokens), 1)) * 80) if matched else 0
|
| 297 |
+
if score > best_score:
|
| 298 |
+
best = contact
|
| 299 |
+
best_score = score
|
| 300 |
+
return best if best_score >= 45 else None
|
| 301 |
+
|
| 302 |
+
def _extract_known_value(query, values):
|
| 303 |
+
qnorm = _norm(query)
|
| 304 |
+
for value in sorted([v for v in values if v], key=len, reverse=True):
|
| 305 |
+
if _norm(value) and re.search(rf"\b{re.escape(_norm(value))}\b", qnorm):
|
| 306 |
+
return value
|
| 307 |
+
return ""
|
| 308 |
+
|
| 309 |
+
def _clean_company_name_candidate(value):
|
| 310 |
+
name = _clean_text(value.strip(" ,.-"))
|
| 311 |
+
name = re.split(
|
| 312 |
+
r"\s+(?:so|then|and\s+then|please)\s+(?:add|create|register|save|put|insert)\b",
|
| 313 |
+
name,
|
| 314 |
+
1,
|
| 315 |
+
flags=re.I,
|
| 316 |
+
)[0]
|
| 317 |
+
name = re.split(
|
| 318 |
+
r"\s+(?:in|at|with|where|who|whose|which|when|located|doing|and\s+they\s+do|staff|size|around|industry|stage|city|province|state|country|phone|website|email|CEO|VP|president|founder)\b|,|\.|\;|\-",
|
| 319 |
+
name,
|
| 320 |
+
1,
|
| 321 |
+
flags=re.I,
|
| 322 |
+
)[0]
|
| 323 |
+
name = _clean_text(name.strip(" ,.-"))
|
| 324 |
+
if _norm(name) in {"it", "them", "this", "that", "these", "those", "him", "her"}:
|
| 325 |
+
return ""
|
| 326 |
+
return name
|
| 327 |
+
|
| 328 |
+
def _extract_company_name_for_add(query):
|
| 329 |
+
explicit_subject_patterns = [
|
| 330 |
+
r"\b(?:company|business|supplier|vendor|manufacturer|firm)\s+(?:called|named)\s+(.+?)(?=\s+(?:so|then|and\s+then|please)\s+(?:add|create|register|save|put|insert)\b|$)",
|
| 331 |
+
r"\b(?:called|named)\s+(.+?)(?=\s+(?:so|then|and\s+then|please)\s+(?:add|create|register|save|put|insert)\b|$)",
|
| 332 |
+
]
|
| 333 |
+
action_patterns = [
|
| 334 |
+
r"\badd\s+(?:this|that|the)\s+(?:company|supplier|vendor|manufacturer|business)\s+(?:called|named)\s+(.+)$",
|
| 335 |
+
r"\badd\s+(?:(?:a|the)\s+)?(?:new\s+)?(?:company|supplier|vendor|manufacturer|business)\s+(?:called|named)?\s*(.+)$",
|
| 336 |
+
r"\bcreate\s+(?:(?:a|the)\s+)?(?:new\s+)?(?:company|supplier|vendor|manufacturer|business)\s+(?:called|named)?\s*(.+)$",
|
| 337 |
+
r"\bcreate\s+(?:(?:a|the)\s+)?(?:new\s+)?company\s+called\s+(.+)$",
|
| 338 |
+
r"\b(?:register|save|put|insert)\s+(.+?)\s+(?:as|into|to)\s+(?:a\s+|the\s+)?(?:company|supplier|vendor|manufacturer|business|companies)\b",
|
| 339 |
+
r"\b(?:add|register|save|put|insert)\s+(.+?)\s+(?:in|into|to)\s+(?:the\s+)?crm\b",
|
| 340 |
+
r"\b(.+?)\s+should\s+be\s+(?:added|created|registered|saved)\s+(?:as|into|to)\s+(?:a\s+|the\s+)?(?:company|supplier|vendor|manufacturer|business|companies)\b",
|
| 341 |
+
]
|
| 342 |
+
for pattern in explicit_subject_patterns + action_patterns:
|
| 343 |
+
match = re.search(pattern, query, re.I)
|
| 344 |
+
if not match:
|
| 345 |
+
continue
|
| 346 |
+
name = _clean_company_name_candidate(match.group(1))
|
| 347 |
+
if name:
|
| 348 |
+
return name
|
| 349 |
+
return ""
|
| 350 |
+
|
| 351 |
+
def _split_trailing_location_from_name(name, known_locations):
|
| 352 |
+
cleaned = _clean_text(name)
|
| 353 |
+
lowered = _norm(cleaned)
|
| 354 |
+
for location in sorted(known_locations, key=len, reverse=True):
|
| 355 |
+
loc_norm = _norm(location)
|
| 356 |
+
if loc_norm and lowered.endswith(" " + loc_norm):
|
| 357 |
+
return _clean_text(cleaned[:-(len(location))].strip(" ,.-")), location
|
| 358 |
+
return cleaned, ""
|
| 359 |
+
|
| 360 |
+
def _known_locations(companies):
|
| 361 |
+
values = set(COMMON_LOCATION_WORDS.keys())
|
| 362 |
+
for company in companies:
|
| 363 |
+
for key in ("city", "province", "country"):
|
| 364 |
+
value = company.get(key)
|
| 365 |
+
if value:
|
| 366 |
+
values.add(str(value))
|
| 367 |
+
return values
|
| 368 |
+
|
| 369 |
+
def _apply_company_location(company_data, query, raw_name, companies):
|
| 370 |
+
known_locations = _known_locations(companies)
|
| 371 |
+
explicit_location = re.search(r"\b(?:in|at|from)\s+([^,]+)", query, re.I)
|
| 372 |
+
location = _clean_text(explicit_location.group(1)) if explicit_location else ""
|
| 373 |
+
if location:
|
| 374 |
+
location = _clean_text(re.split(r"\s+(?:with|industry|stage|city|province|state|country|phone|website|email)\b", location, 1, flags=re.I)[0].strip(" ,.-"))
|
| 375 |
+
if _norm(location) in {"crm", "the crm"}:
|
| 376 |
+
location = ""
|
| 377 |
+
if not location:
|
| 378 |
+
name_without_location, trailing_location = _split_trailing_location_from_name(raw_name, known_locations)
|
| 379 |
+
if trailing_location:
|
| 380 |
+
company_data["name"] = name_without_location
|
| 381 |
+
location = trailing_location
|
| 382 |
+
|
| 383 |
+
if not location:
|
| 384 |
+
return
|
| 385 |
+
|
| 386 |
+
loc_key = _norm(location)
|
| 387 |
+
loc_type, canonical = COMMON_LOCATION_WORDS.get(loc_key, ("", location))
|
| 388 |
+
if not loc_type:
|
| 389 |
+
for company in companies:
|
| 390 |
+
if _norm(company.get("city")) == loc_key:
|
| 391 |
+
loc_type, canonical = "city", company.get("city")
|
| 392 |
+
break
|
| 393 |
+
if _norm(company.get("province")) == loc_key:
|
| 394 |
+
loc_type, canonical = "province", company.get("province")
|
| 395 |
+
break
|
| 396 |
+
if loc_type == "province":
|
| 397 |
+
company_data["province"] = canonical
|
| 398 |
+
else:
|
| 399 |
+
company_data["city"] = canonical
|
| 400 |
+
|
| 401 |
+
def _extract_contact_data(query, companies):
|
| 402 |
+
match = re.search(r"\b(?:add|create|register|save|put)\s+(?:a\s+|new\s+)?contact\s+(.+)$", query, re.I)
|
| 403 |
+
if not match:
|
| 404 |
+
match = re.search(r"\b(?:add|create|register|save|put)\s+(.+?)\s+(?:as|into|to)\s+(?:a\s+)?contact\b", query, re.I)
|
| 405 |
+
if not match:
|
| 406 |
+
match = re.search(r"\b(.+?)\s+should\s+be\s+(?:added|created|registered|saved)\s+(?:as|into|to)\s+(?:a\s+)?contact\b", query, re.I)
|
| 407 |
+
if not match:
|
| 408 |
+
return None
|
| 409 |
+
tail = match.group(1)
|
| 410 |
+
name_part = re.split(r"\s+(?:at|for|with|email|phone|title|linkedin)\b|,", tail, 1, flags=re.I)[0]
|
| 411 |
+
names = _clean_text(name_part).split()
|
| 412 |
+
if len(names) < 2:
|
| 413 |
+
return {"error": "Please include both first and last name for the contact."}
|
| 414 |
+
|
| 415 |
+
contact_data = {"first_name": names[0], "last_name": " ".join(names[1:])}
|
| 416 |
+
email = re.search(r"[\w.+-]+@[\w.-]+\.\w+", query)
|
| 417 |
+
if email:
|
| 418 |
+
contact_data["email"] = email.group(0)
|
| 419 |
+
phone = re.search(r"\b(?:phone|mobile|call)\s*(?:is|:)?\s*([+()0-9][+()0-9 .-]{5,})", query, re.I)
|
| 420 |
+
if phone:
|
| 421 |
+
contact_data["phone"] = _clean_text(phone.group(1))
|
| 422 |
+
title = re.search(r"\btitle\s*(?:is|to|:)?\s*([^,]+)", query, re.I)
|
| 423 |
+
if title:
|
| 424 |
+
contact_data["title"] = _clean_text(title.group(1))
|
| 425 |
+
linkedin = re.search(r"\b(?:linkedin|linkedin\s*url|linkedin\s*id)\s*(?:is|to|:)?\s*(\S+)", query, re.I)
|
| 426 |
+
if linkedin:
|
| 427 |
+
contact_data["linkedin"] = _clean_text(linkedin.group(1))
|
| 428 |
+
|
| 429 |
+
company_id = re.search(r"\bcompany\s*id\s*#?\s*(\d+)\b", query, re.I)
|
| 430 |
+
if company_id:
|
| 431 |
+
contact_data["company_id"] = int(company_id.group(1))
|
| 432 |
+
else:
|
| 433 |
+
company = _find_company(query, companies)
|
| 434 |
+
if company:
|
| 435 |
+
contact_data["company_id"] = company.get("id")
|
| 436 |
+
if not contact_data.get("company_id"):
|
| 437 |
+
return {"error": "Please specify which company this contact belongs to, for example: Add contact Jane Mari at Acme Robotics."}
|
| 438 |
+
return contact_data
|
| 439 |
+
|
| 440 |
+
def _parse_client_date(client_time):
|
| 441 |
+
if not client_time:
|
| 442 |
+
return datetime.date.today()
|
| 443 |
+
text = str(client_time)
|
| 444 |
+
for fmt in ("%a %b %d %Y %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d"):
|
| 445 |
+
try:
|
| 446 |
+
return datetime.datetime.strptime(text[:len(fmt)], fmt).date()
|
| 447 |
+
except Exception:
|
| 448 |
+
continue
|
| 449 |
+
iso_match = re.search(r"\b(\d{4}-\d{2}-\d{2})\b", text)
|
| 450 |
+
if iso_match:
|
| 451 |
+
try:
|
| 452 |
+
return datetime.date.fromisoformat(iso_match.group(1))
|
| 453 |
+
except Exception:
|
| 454 |
+
pass
|
| 455 |
+
return datetime.date.today()
|
| 456 |
+
|
| 457 |
+
def _parse_task_date(query, client_time):
|
| 458 |
+
if re.search(r"\b(?:unscheduled|unassigned)\b", query, re.I):
|
| 459 |
+
return ""
|
| 460 |
+
base = _parse_client_date(client_time)
|
| 461 |
+
lowered = query.lower()
|
| 462 |
+
if re.search(r"\btoday\b", lowered):
|
| 463 |
+
return base.isoformat()
|
| 464 |
+
if re.search(r"\btomorrow\b", lowered):
|
| 465 |
+
return (base + datetime.timedelta(days=1)).isoformat()
|
| 466 |
+
|
| 467 |
+
iso_match = re.search(r"\b(\d{4}-\d{2}-\d{2})\b", query)
|
| 468 |
+
if iso_match:
|
| 469 |
+
return iso_match.group(1)
|
| 470 |
+
|
| 471 |
+
weekdays = {
|
| 472 |
+
"monday": 0,
|
| 473 |
+
"tuesday": 1,
|
| 474 |
+
"wednesday": 2,
|
| 475 |
+
"thursday": 3,
|
| 476 |
+
"friday": 4,
|
| 477 |
+
"saturday": 5,
|
| 478 |
+
"sunday": 6,
|
| 479 |
+
}
|
| 480 |
+
for day_name, day_num in weekdays.items():
|
| 481 |
+
if re.search(rf"\bnext\s+{day_name}\b", lowered):
|
| 482 |
+
days_ahead = (day_num - base.weekday()) % 7
|
| 483 |
+
if days_ahead == 0:
|
| 484 |
+
days_ahead = 7
|
| 485 |
+
return (base + datetime.timedelta(days=days_ahead)).isoformat()
|
| 486 |
+
if re.search(rf"\b{day_name}\b", lowered):
|
| 487 |
+
days_ahead = (day_num - base.weekday()) % 7
|
| 488 |
+
return (base + datetime.timedelta(days=days_ahead)).isoformat()
|
| 489 |
+
return ""
|
| 490 |
+
|
| 491 |
+
def _parse_task_time(query):
|
| 492 |
+
if re.search(r"\b(?:unscheduled|unassigned)\b", query, re.I):
|
| 493 |
+
return ""
|
| 494 |
+
match = re.search(r"\b(?:at\s*)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)\b", query, re.I)
|
| 495 |
+
if match:
|
| 496 |
+
hour = int(match.group(1))
|
| 497 |
+
minute = int(match.group(2) or 0)
|
| 498 |
+
suffix = match.group(3).lower()
|
| 499 |
+
if suffix == "pm" and hour != 12:
|
| 500 |
+
hour += 12
|
| 501 |
+
if suffix == "am" and hour == 12:
|
| 502 |
+
hour = 0
|
| 503 |
+
return f"{hour:02d}:{minute:02d}"
|
| 504 |
+
match = re.search(r"\b(?:at\s*)?([01]?\d|2[0-3]):([0-5]\d)\b", query)
|
| 505 |
+
if match:
|
| 506 |
+
return f"{int(match.group(1)):02d}:{int(match.group(2)):02d}"
|
| 507 |
+
return ""
|
| 508 |
+
|
| 509 |
+
def _extract_task_title(query):
|
| 510 |
+
cleaned = re.sub(r"\b(?:add|create|schedule|save|put)\s+(?:a\s+|new\s+)?(?:task|todo|to-do|reminder|action)\b", " ", query, flags=re.I)
|
| 511 |
+
cleaned = re.sub(r"\bremind\s+me\s+to\b", " ", cleaned, flags=re.I)
|
| 512 |
+
cleaned = re.sub(r"\bi\s+need\s+to\b", " ", cleaned, flags=re.I)
|
| 513 |
+
cleaned = re.sub(r"\b(?:unscheduled|unassigned)\b", " ", cleaned, flags=re.I)
|
| 514 |
+
cleaned = re.sub(r"\b(?:next\s+)?(?:monday|tuesday|wednesday|thursday|friday|saturday|sunday)\b", " ", cleaned, flags=re.I)
|
| 515 |
+
cleaned = re.sub(r"\b(?:today|tomorrow)\b", " ", cleaned, flags=re.I)
|
| 516 |
+
cleaned = re.sub(r"\b\d{4}-\d{2}-\d{2}\b", " ", cleaned)
|
| 517 |
+
cleaned = re.sub(r"\b(?:at\s*)?\d{1,2}(?::\d{2})?\s*(?:am|pm)\b", " ", cleaned, flags=re.I)
|
| 518 |
+
cleaned = re.sub(r"\b(?:at\s*)?(?:[01]?\d|2[0-3]):[0-5]\d\b", " ", cleaned, flags=re.I)
|
| 519 |
+
cleaned = re.sub(r"\s+", " ", cleaned).strip(" ,.-")
|
| 520 |
+
return cleaned
|
| 521 |
+
|
| 522 |
+
def _infer_command_intent(query):
|
| 523 |
+
q = _clean_text(query)
|
| 524 |
+
add_words = ("add", "create", "register", "save", "put", "insert")
|
| 525 |
+
edit_words = ("change", "update", "set", "edit", "modify")
|
| 526 |
+
delete_words = ("delete", "remove")
|
| 527 |
+
view_words = ("open", "show", "view")
|
| 528 |
+
list_words = ("show", "sow", "shwo", "list", "find", "view", "display", "get", "fetch", "all", "bring", "give")
|
| 529 |
+
company_words = ("company", "companies", "business", "supplier", "vendor", "manufacturer", "firm")
|
| 530 |
+
contact_words = ("contact", "person")
|
| 531 |
+
task_words = ("task", "todo", "to-do", "reminder", "action")
|
| 532 |
+
|
| 533 |
+
is_add = _has_word(q, add_words) or _has_phrase(q, ("should be added", "should be registered", "should be saved"))
|
| 534 |
+
is_edit = _has_word(q, edit_words)
|
| 535 |
+
is_delete = _has_word(q, delete_words)
|
| 536 |
+
is_view = _has_word(q, view_words) or re.search(r"\bgo\s+to\b", q, re.I)
|
| 537 |
+
is_list = _has_word(q, list_words)
|
| 538 |
+
has_company = _has_word(q, company_words) or _has_phrase(q, ("as a company", "into companies", "to companies"))
|
| 539 |
+
rep_words = ("rep", "representative", "user", "team member", "role", "admin", "viewer", "bd rep")
|
| 540 |
+
is_rep = _has_word(q, rep_words)
|
| 541 |
+
has_contact = (_has_word(q, contact_words) or _has_phrase(q, ("as a contact", "into contacts", "to contacts"))) and not is_rep
|
| 542 |
+
has_task = _has_word(q, task_words) or re.search(r"\bremind\s+me\s+to\b", q, re.I)
|
| 543 |
+
if is_add and _has_word(q, ("crm",)) and not has_contact and not has_task and not is_rep:
|
| 544 |
+
has_company = True
|
| 545 |
+
|
| 546 |
+
if (is_add or _has_word(q, ("schedule",)) or re.search(r"\bremind\s+me\s+to\b", q, re.I)) and has_task:
|
| 547 |
+
return "add_task"
|
| 548 |
+
if (is_add or _has_phrase(q, ("should be added",))) and has_contact:
|
| 549 |
+
return "add_contact"
|
| 550 |
+
if is_edit and (has_contact or (_has_word(q, ("email", "title")) and not has_company and not is_rep)):
|
| 551 |
+
return "edit_contact"
|
| 552 |
+
if is_delete and has_contact:
|
| 553 |
+
return "delete_contact"
|
| 554 |
+
if is_list and re.search(r"\bcontacts\b", q, re.I):
|
| 555 |
+
return "filter_contacts"
|
| 556 |
+
if (is_add or _has_phrase(q, ("should be added", "should be registered", "should be saved"))) and has_company and not has_contact and not has_task and not is_rep:
|
| 557 |
+
return "add_company"
|
| 558 |
+
if is_edit and (has_company or _has_word(q, ("stage", "industry", "city", "province", "website", "phone"))):
|
| 559 |
+
return "edit_company"
|
| 560 |
+
if is_delete and has_company:
|
| 561 |
+
return "delete_company"
|
| 562 |
+
if is_view and re.search(r"\b(?:profile|page|card|company)\b", q, re.I):
|
| 563 |
+
return "view_company_profile"
|
| 564 |
+
if (is_list or re.search(r"\b(?:companies|stage|stages|pipeline)\b", q, re.I)) and re.search(r"\b(?:companies|stage|stages|pipeline)\b", q, re.I) and not is_add and not is_edit and not is_delete and not has_task and not is_rep and not has_contact:
|
| 565 |
+
return "filter_companies"
|
| 566 |
+
return ""
|
| 567 |
+
|
| 568 |
+
def deterministic_command_route(query, context, client_time=None):
|
| 569 |
+
query = _clean_text(query)
|
| 570 |
+
if not query:
|
| 571 |
+
return None
|
| 572 |
+
# 1. Deactivate representative
|
| 573 |
+
deact_match = re.search(r"\b(?:deactivate|disable|suspend)\s+(?:rep|representative|user|member)?\s*(.+)", query, re.I)
|
| 574 |
+
if deact_match:
|
| 575 |
+
target = deact_match.group(1).strip()
|
| 576 |
+
email_match = re.search(r"([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})", target)
|
| 577 |
+
email_lookup = email_match.group(1) if email_match else None
|
| 578 |
+
target_name = target
|
| 579 |
+
if email_lookup:
|
| 580 |
+
target_name = target.replace(email_lookup, "").strip()
|
| 581 |
+
return {
|
| 582 |
+
"action": "edit_rep_handler",
|
| 583 |
+
"email_lookup": email_lookup,
|
| 584 |
+
"name_lookup": target_name,
|
| 585 |
+
"rep_data": {"is_active": 0}
|
| 586 |
+
}
|
| 587 |
+
|
| 588 |
+
# 2. Activate representative
|
| 589 |
+
act_match = re.search(r"\b(?:activate|enable)\s+(?:rep|representative|user|member)?\s*(.+)", query, re.I)
|
| 590 |
+
if act_match:
|
| 591 |
+
target = act_match.group(1).strip()
|
| 592 |
+
email_match = re.search(r"([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})", target)
|
| 593 |
+
email_lookup = email_match.group(1) if email_match else None
|
| 594 |
+
target_name = target
|
| 595 |
+
if email_lookup:
|
| 596 |
+
target_name = target.replace(email_lookup, "").strip()
|
| 597 |
+
return {
|
| 598 |
+
"action": "edit_rep_handler",
|
| 599 |
+
"email_lookup": email_lookup,
|
| 600 |
+
"name_lookup": target_name,
|
| 601 |
+
"rep_data": {"is_active": 1}
|
| 602 |
+
}
|
| 603 |
+
|
| 604 |
+
# 3. Delete representative
|
| 605 |
+
del_match = re.search(r"\b(?:delete|remove|purge)\s+(?:rep|representative|user|member)\s+(.+)", query, re.I)
|
| 606 |
+
if del_match:
|
| 607 |
+
target = del_match.group(1).strip()
|
| 608 |
+
email_match = re.search(r"([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})", target)
|
| 609 |
+
email_lookup = email_match.group(1) if email_match else None
|
| 610 |
+
target_name = target
|
| 611 |
+
if email_lookup:
|
| 612 |
+
target_name = target.replace(email_lookup, "").strip()
|
| 613 |
+
return {
|
| 614 |
+
"action": "delete_rep_handler",
|
| 615 |
+
"email_lookup": email_lookup,
|
| 616 |
+
"name_lookup": target_name
|
| 617 |
+
}
|
| 618 |
+
|
| 619 |
+
# 4. Add representative / User / Role
|
| 620 |
+
add_rep_match = re.search(r"\b(?:add|create|register|new)\s+(?:rep|representative|user|team member|member|role|bd rep|admin|viewer)\b", query, re.I)
|
| 621 |
+
if add_rep_match:
|
| 622 |
+
email_match = re.search(r"([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})", query)
|
| 623 |
+
email = email_match.group(1).strip() if email_match else ""
|
| 624 |
+
# Extract name if present
|
| 625 |
+
cleaned_query = re.sub(r"\b(?:add|create|register|new)\s+(?:rep|representative|user|team member|member|role|bd rep|admin|viewer)\b", "", query, flags=re.I)
|
| 626 |
+
if email:
|
| 627 |
+
cleaned_query = cleaned_query.replace(email, "")
|
| 628 |
+
cleaned_query = re.sub(r"\b(?:with\s+)?email\b", "", cleaned_query, flags=re.I)
|
| 629 |
+
cleaned_query = re.sub(r"\bas\s+(?:BD Rep|Admin|Viewer)\b", "", cleaned_query, flags=re.I).strip(" ,.-:")
|
| 630 |
+
name = cleaned_query or (email.split("@")[0] if email else "")
|
| 631 |
+
role_name = "BD Rep"
|
| 632 |
+
if re.search(r"\badmin\b", query, re.I):
|
| 633 |
+
role_name = "Admin"
|
| 634 |
+
elif re.search(r"\bviewer\b", query, re.I):
|
| 635 |
+
role_name = "Viewer"
|
| 636 |
+
|
| 637 |
+
if not email or not name:
|
| 638 |
+
return _answer(
|
| 639 |
+
"To add an **internal team member (User/Representative)** who logs into ProspectIQ, please provide their **Name**, **Email**, and **Role** (e.g., `BD Rep` or `Viewer`).\n\n"
|
| 640 |
+
"Example: *Add rep Sarah Connor with email sarah@example.com as BD Rep*.\n\n"
|
| 641 |
+
"*(Note: If you meant to add an **external CRM Contact** at a client company, please say: Add contact [First Name] [Last Name] for company [Company Name]).*"
|
| 642 |
+
)
|
| 643 |
+
return {
|
| 644 |
+
"action": "add_rep_handler",
|
| 645 |
+
"name": name,
|
| 646 |
+
"email": email,
|
| 647 |
+
"role": role_name
|
| 648 |
+
}
|
| 649 |
+
|
| 650 |
+
# 5. Change role
|
| 651 |
+
role_match = re.search(r"\b(?:change|set)\s+(?:role|permission)\s+of\s+(.+?)\s+to\s+(Admin|BD Rep|Viewer)", query, re.I)
|
| 652 |
+
if role_match:
|
| 653 |
+
target = role_match.group(1).strip()
|
| 654 |
+
role_name = role_match.group(2).strip()
|
| 655 |
+
email_match = re.search(r"([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})", target)
|
| 656 |
+
email_lookup = email_match.group(1) if email_match else None
|
| 657 |
+
target_name = target
|
| 658 |
+
if email_lookup:
|
| 659 |
+
target_name = target.replace(email_lookup, "").strip()
|
| 660 |
+
return {
|
| 661 |
+
"action": "edit_rep_handler",
|
| 662 |
+
"email_lookup": email_lookup,
|
| 663 |
+
"name_lookup": target_name,
|
| 664 |
+
"rep_data": {"role": role_name}
|
| 665 |
+
}
|
| 666 |
+
|
| 667 |
+
qnorm = _norm(query)
|
| 668 |
+
companies, contacts = _context_entities(context)
|
| 669 |
+
stage_names = _stage_names(companies)
|
| 670 |
+
stage_value = _extract_known_value(query, stage_names)
|
| 671 |
+
intent = _infer_command_intent(query)
|
| 672 |
+
|
| 673 |
+
if re.search(r"\b(?:ocr|business card|scan card|card scan|extract text|read image)\b", query, re.I):
|
| 674 |
+
return None
|
| 675 |
+
|
| 676 |
+
if not intent and _has_word(query, ("delete", "remove")):
|
| 677 |
+
contact = _find_contact(query, contacts)
|
| 678 |
+
company = _find_company(query, companies)
|
| 679 |
+
if contact and not company:
|
| 680 |
+
intent = "delete_contact"
|
| 681 |
+
elif company and not contact:
|
| 682 |
+
intent = "delete_company"
|
| 683 |
+
elif company and contact:
|
| 684 |
+
return _answer("Please clarify whether you want to delete the company or the contact.")
|
| 685 |
+
|
| 686 |
+
if intent == "view_company_profile":
|
| 687 |
+
company = _find_company(query, companies)
|
| 688 |
+
if company:
|
| 689 |
+
return {"action": "view_company_profile", "company_id": company.get("id"), "company_name": company.get("name")}
|
| 690 |
+
if not re.search(r"\b(?:research|extract|enrich|company extractor|company research)\b", query, re.I):
|
| 691 |
+
return _answer("I could not find that company in CRM. Please use the exact company name from the database, or ask me to research it with Company Research.")
|
| 692 |
+
|
| 693 |
+
if intent == "filter_contacts":
|
| 694 |
+
return {"action": "filter_contacts", "filters": {}}
|
| 695 |
+
|
| 696 |
+
if intent == "add_task":
|
| 697 |
+
task_title = _extract_task_title(query)
|
| 698 |
+
if not task_title:
|
| 699 |
+
return _answer("Please include the task title. Example: Create task review supplier profile next Monday.")
|
| 700 |
+
payload = {"action": "add_task", "task_title": task_title}
|
| 701 |
+
date_value = _parse_task_date(query, client_time)
|
| 702 |
+
time_value = _parse_task_time(query)
|
| 703 |
+
if date_value:
|
| 704 |
+
payload["date"] = date_value
|
| 705 |
+
if time_value:
|
| 706 |
+
payload["time"] = time_value
|
| 707 |
+
return payload
|
| 708 |
+
|
| 709 |
+
if intent == "filter_companies":
|
| 710 |
+
is_crm_explicit = bool(re.search(r"\b(?:database|crm|saved|my companies|already in|in crm|from crm|in database|from database|stage|stages|pipeline|new stage|shortlisted|contacted|engaged|proposal|won|paused)\b", query, re.I))
|
| 711 |
+
if not is_crm_explicit and (re.search(r"\b(?:near|nearby|around|within|coordinates|latitude|longitude|geo finder|map search|prospect|prospecting)\b", query, re.I) or _has_word(query, ("find", "search", "prospect", "discover"))):
|
| 712 |
+
return {"action": "tool_route", "tool": "geo", "tool_input": {"query": query}, "auto_start": True, "message": "Opening Regional Prospecting to discover companies matching your search."}
|
| 713 |
+
|
| 714 |
+
if is_crm_explicit or not re.search(r"\b(?:near|nearby|around|within|coordinates|latitude|longitude|geo finder|map search|prospect|prospecting)\b", query, re.I):
|
| 715 |
+
industries = sorted({c.get("industry") for c in companies if c.get("industry")})
|
| 716 |
+
provinces = sorted({c.get("province") for c in companies if c.get("province")})
|
| 717 |
+
cities = sorted({c.get("city") for c in companies if c.get("city")})
|
| 718 |
+
filters = {}
|
| 719 |
+
industry = _extract_known_value(query, industries)
|
| 720 |
+
province = _extract_known_value(query, provinces)
|
| 721 |
+
city = _extract_known_value(query, cities)
|
| 722 |
+
if industry:
|
| 723 |
+
filters["industry"] = industry
|
| 724 |
+
if province:
|
| 725 |
+
filters["province"] = province
|
| 726 |
+
if city:
|
| 727 |
+
filters["city"] = city
|
| 728 |
+
if stage_value:
|
| 729 |
+
filters["stage"] = stage_value
|
| 730 |
+
|
| 731 |
+
filtered_comps = companies
|
| 732 |
+
if filters.get("industry"):
|
| 733 |
+
filtered_comps = [c for c in filtered_comps if _norm(c.get("industry")) == _norm(filters["industry"])]
|
| 734 |
+
if filters.get("province"):
|
| 735 |
+
filtered_comps = [c for c in filtered_comps if _norm(c.get("province")) == _norm(filters["province"])]
|
| 736 |
+
if filters.get("city"):
|
| 737 |
+
filtered_comps = [c for c in filtered_comps if _norm(c.get("city")) == _norm(filters["city"])]
|
| 738 |
+
if filters.get("stage"):
|
| 739 |
+
filtered_comps = [c for c in filtered_comps if _norm(c.get("stage")) == _norm(filters["stage"])]
|
| 740 |
+
|
| 741 |
+
top_match = re.search(r"\btop\s+(\d+)\b", query, re.I)
|
| 742 |
+
reply_text = ""
|
| 743 |
+
if top_match and filtered_comps:
|
| 744 |
+
n = min(int(top_match.group(1)), len(filtered_comps)) if top_match.group(1).isdigit() else 3
|
| 745 |
+
from services.ai_service import clean_int
|
| 746 |
+
sorted_comps = sorted(filtered_comps, key=lambda c: clean_int(c.get("size_staff") or c.get("maplempss_fit_score") or 0, 0, 0, 1_000_000_000), reverse=True)
|
| 747 |
+
top_list = sorted_comps[:n]
|
| 748 |
+
items_str = "\n".join(f"{idx+1}. **{c.get('name')}** (Industry: {c.get('industry') or 'N/A'}, Stage: {c.get('stage') or 'N/A'}, Staff: {c.get('size_staff') or 0})" for idx, c in enumerate(top_list))
|
| 749 |
+
reply_text = f"Here are the top {n} companies matching your criteria in the CRM database:\n\n{items_str}"
|
| 750 |
+
elif filtered_comps:
|
| 751 |
+
items_str = "\n".join(f"{idx+1}. **{c.get('name')}** (Industry: {c.get('industry') or 'N/A'}, Stage: {c.get('stage') or 'N/A'}, Location: {c.get('city') or 'N/A'}, {c.get('province') or 'N/A'})" for idx, c in enumerate(filtered_comps[:20]))
|
| 752 |
+
if len(filtered_comps) > 20:
|
| 753 |
+
items_str += f"\n\n*(And {len(filtered_comps) - 20} more companies shown in the CRM table)*"
|
| 754 |
+
if filters:
|
| 755 |
+
reply_text = f"Here are the {len(filtered_comps)} companies matching your filters in the CRM database:\n\n{items_str}"
|
| 756 |
+
else:
|
| 757 |
+
reply_text = f"Here are the {len(filtered_comps)} companies currently present in your CRM database:\n\n{items_str}"
|
| 758 |
+
else:
|
| 759 |
+
reply_text = "No companies matched your criteria in the CRM database."
|
| 760 |
+
|
| 761 |
+
view_type = "board" if re.search(r"\b(?:kanban|board)\b", query, re.I) else ("table" if re.search(r"\btable\b", query, re.I) else "")
|
| 762 |
+
res = {"action": "filter_companies", "filters": filters, "view_type": view_type}
|
| 763 |
+
if reply_text:
|
| 764 |
+
res["reply"] = reply_text
|
| 765 |
+
return res
|
| 766 |
+
|
| 767 |
+
if intent == "add_contact":
|
| 768 |
+
contact_data = _extract_contact_data(query, companies)
|
| 769 |
+
if isinstance(contact_data, dict) and contact_data.get("error"):
|
| 770 |
+
return _answer(contact_data["error"])
|
| 771 |
+
if contact_data:
|
| 772 |
+
return {"action": "add_contact", "contact_data": contact_data}
|
| 773 |
+
|
| 774 |
+
if intent == "add_company":
|
| 775 |
+
if re.search(r"\b(?:where|who|whose|CEO|VP|president|founder|doing|they do|business|staff size|around|revenue|\d+-\d+|\.|\,)\b", query, re.I) or len(query.split()) > 8:
|
| 776 |
+
return None
|
| 777 |
+
name = _extract_company_name_for_add(query)
|
| 778 |
+
if not name:
|
| 779 |
+
return _answer("I need the company name before I can add it to CRM. Please say the name directly, for example: Add Bikehackers India to CRM.")
|
| 780 |
+
company_data = {"name": name}
|
| 781 |
+
industry_match = re.search(r"\bindustry\s*(?:is|:)?\s*([^,]+)", query, re.I) or re.search(r"\b(?:doing|do|in)\s+([a-zA-Z\s]+?)\s+business\b", query, re.I)
|
| 782 |
+
if industry_match:
|
| 783 |
+
company_data["industry"] = _clean_text(industry_match.group(1))
|
| 784 |
+
size_match = re.search(r"\b(?:with|has)?\s*(\d[\d,]*)\s*(?:staff|employees|people)\b", query, re.I) or re.search(r"\b(?:staff|size|employees|team)\D*(\d+)(?:\s*-\s*(\d+))?", query, re.I)
|
| 785 |
+
if size_match:
|
| 786 |
+
if size_match.lastindex and size_match.lastindex >= 2 and size_match.group(2):
|
| 787 |
+
company_data["size_staff"] = int((int(size_match.group(1).replace(",", "")) + int(size_match.group(2).replace(",", ""))) / 2)
|
| 788 |
+
else:
|
| 789 |
+
company_data["size_staff"] = int(size_match.group(1).replace(",", ""))
|
| 790 |
+
provinces = sorted({c.get("province") for c in companies if c.get("province")})
|
| 791 |
+
cities = sorted({c.get("city") for c in companies if c.get("city")})
|
| 792 |
+
_apply_company_location(company_data, query, name, companies)
|
| 793 |
+
province = _extract_known_value(query, provinces)
|
| 794 |
+
city = _extract_known_value(query, cities)
|
| 795 |
+
if province:
|
| 796 |
+
company_data["province"] = province
|
| 797 |
+
if city:
|
| 798 |
+
company_data["city"] = city
|
| 799 |
+
if stage_value:
|
| 800 |
+
company_data["stage_id"] = _stage_id_for_name(companies, stage_value) or 1
|
| 801 |
+
return {"action": "add_company", "company_data": company_data}
|
| 802 |
+
|
| 803 |
+
if intent == "edit_company":
|
| 804 |
+
company = _find_company(query, companies)
|
| 805 |
+
if not company:
|
| 806 |
+
return _answer("Please specify the exact company to update. I could not confidently match that company in CRM.")
|
| 807 |
+
company_data = {}
|
| 808 |
+
if stage_value and re.search(r"\bstage\b", query, re.I):
|
| 809 |
+
stage_id = _stage_id_for_name(companies, stage_value)
|
| 810 |
+
if not stage_id:
|
| 811 |
+
return _answer(f"I could not match that stage. Valid stages are: {', '.join(stage_names)}.")
|
| 812 |
+
company_data["stage_id"] = stage_id
|
| 813 |
+
for field in ("industry", "city", "province", "website", "phone"):
|
| 814 |
+
match = re.search(rf"\b{field}\s+(?:to|as|is)\s+([^,]+)", query, re.I)
|
| 815 |
+
if match:
|
| 816 |
+
company_data[field] = _clean_text(match.group(1))
|
| 817 |
+
if not company_data:
|
| 818 |
+
return _answer("Please say what company field to update, for example: Set Apple Computer stage to Contacted.")
|
| 819 |
+
return {"action": "edit_company", "company_id": company.get("id"), "company_data": company_data}
|
| 820 |
+
|
| 821 |
+
if intent == "delete_company":
|
| 822 |
+
company = _find_company(query, companies)
|
| 823 |
+
if company:
|
| 824 |
+
return {"action": "delete_company", "company_id": company.get("id")}
|
| 825 |
+
if re.search(r"\bcompany\b", query, re.I):
|
| 826 |
+
return _answer("Please specify the exact company to delete. I could not confidently match it in CRM.")
|
| 827 |
+
|
| 828 |
+
if intent == "edit_contact":
|
| 829 |
+
contact = _find_contact(query, contacts)
|
| 830 |
+
if not contact:
|
| 831 |
+
return _answer("Please specify the exact contact to update. I could not confidently match that contact in CRM.")
|
| 832 |
+
contact_data = {}
|
| 833 |
+
for field in ("email", "phone", "title", "linkedin"):
|
| 834 |
+
if field == "linkedin":
|
| 835 |
+
match = re.search(r"\b(?:linkedin|linkedin\s*url|linkedin\s*id)\s+(?:to|as|is|:)\s+([^\s,]+)", query, re.I)
|
| 836 |
+
else:
|
| 837 |
+
match = re.search(rf"\b{field}\s+(?:to|as|is)\s+([^,]+)", query, re.I)
|
| 838 |
+
if match:
|
| 839 |
+
contact_data[field] = _clean_text(match.group(1))
|
| 840 |
+
if not contact_data:
|
| 841 |
+
return _answer("Please say what contact field to update, for example: Change John Smith email to john@example.com or Update John Smith linkedin to https://linkedin.com/in/john.")
|
| 842 |
+
return {"action": "edit_contact", "contact_id": contact.get("id"), "contact_data": contact_data}
|
| 843 |
+
|
| 844 |
+
if intent == "delete_contact":
|
| 845 |
+
contact = _find_contact(query, contacts)
|
| 846 |
+
if contact:
|
| 847 |
+
return {"action": "delete_contact", "contact_id": contact.get("id")}
|
| 848 |
+
return _answer("Please specify the exact contact to delete. I could not confidently match it in CRM.")
|
| 849 |
+
|
| 850 |
+
return None
|
| 851 |
+
|
| 852 |
+
@command_bp.route("", methods=["POST"])
|
| 853 |
+
def process_command():
|
| 854 |
+
data = request.json
|
| 855 |
+
query = data.get("query", "")
|
| 856 |
+
context = data.get("context", "")
|
| 857 |
+
client_time = data.get("client_time")
|
| 858 |
+
if not client_time:
|
| 859 |
+
import datetime
|
| 860 |
+
client_time = datetime.datetime.now().strftime("%a %b %d %Y %H:%M:%S")
|
| 861 |
+
|
| 862 |
+
deterministic_route = deterministic_command_route(query, context, client_time)
|
| 863 |
+
if deterministic_route:
|
| 864 |
+
action = deterministic_route.get("action")
|
| 865 |
+
if action in {"add_rep_handler", "edit_rep_handler", "delete_rep_handler"}:
|
| 866 |
+
if 'user_id' not in session:
|
| 867 |
+
return jsonify({"action": "answer", "reply": "You must be logged in to manage representatives."})
|
| 868 |
+
if session.get('user_role') != 'Admin':
|
| 869 |
+
return jsonify({"action": "answer", "reply": "Unauthorized. Only administrators can manage representatives."})
|
| 870 |
+
|
| 871 |
+
tenant_id = session.get('tenant_id')
|
| 872 |
+
if action == "add_rep_handler":
|
| 873 |
+
res = db_handle_rep_command("add_rep", deterministic_route, tenant_id)
|
| 874 |
+
return jsonify(res)
|
| 875 |
+
elif action == "edit_rep_handler":
|
| 876 |
+
email_lookup = deterministic_route.get("email_lookup")
|
| 877 |
+
name_lookup = deterministic_route.get("name_lookup")
|
| 878 |
+
target_user_id = None
|
| 879 |
+
|
| 880 |
+
conn = get_db_connection()
|
| 881 |
+
cursor = conn.cursor()
|
| 882 |
+
if email_lookup:
|
| 883 |
+
cursor.execute("SELECT id FROM users WHERE LOWER(email) = LOWER(?) AND tenant_id = ?", (email_lookup, tenant_id))
|
| 884 |
+
row = cursor.fetchone()
|
| 885 |
+
if row: target_user_id = row[0]
|
| 886 |
+
elif name_lookup:
|
| 887 |
+
cursor.execute("SELECT id FROM users WHERE LOWER(name) LIKE LOWER(?) AND tenant_id = ?", (f"%{name_lookup}%", tenant_id))
|
| 888 |
+
row = cursor.fetchone()
|
| 889 |
+
if row: target_user_id = row[0]
|
| 890 |
+
conn.close()
|
| 891 |
+
|
| 892 |
+
deterministic_route["target_user_id"] = target_user_id
|
| 893 |
+
res = db_handle_rep_command("edit_rep", deterministic_route, tenant_id)
|
| 894 |
+
return jsonify(res)
|
| 895 |
+
elif action == "delete_rep_handler":
|
| 896 |
+
email_lookup = deterministic_route.get("email_lookup")
|
| 897 |
+
name_lookup = deterministic_route.get("name_lookup")
|
| 898 |
+
target_user_id = None
|
| 899 |
+
|
| 900 |
+
conn = get_db_connection()
|
| 901 |
+
cursor = conn.cursor()
|
| 902 |
+
if email_lookup:
|
| 903 |
+
cursor.execute("SELECT id FROM users WHERE LOWER(email) = LOWER(?) AND tenant_id = ?", (email_lookup, tenant_id))
|
| 904 |
+
row = cursor.fetchone()
|
| 905 |
+
if row: target_user_id = row[0]
|
| 906 |
+
elif name_lookup:
|
| 907 |
+
cursor.execute("SELECT id FROM users WHERE LOWER(name) LIKE LOWER(?) AND tenant_id = ?", (f"%{name_lookup}%", tenant_id))
|
| 908 |
+
row = cursor.fetchone()
|
| 909 |
+
if row: target_user_id = row[0]
|
| 910 |
+
conn.close()
|
| 911 |
+
|
| 912 |
+
deterministic_route["target_user_id"] = target_user_id
|
| 913 |
+
res = db_handle_rep_command("delete_rep", deterministic_route, tenant_id)
|
| 914 |
+
return jsonify(res)
|
| 915 |
+
|
| 916 |
+
return jsonify(deterministic_route)
|
| 917 |
+
|
| 918 |
+
fallback_tool_route = heuristic_tool_route(query)
|
| 919 |
+
if fallback_tool_route and fallback_tool_route.get("confidence") == "rule":
|
| 920 |
+
fallback_tool_route["memory_id"] = save_tool_route(query, fallback_tool_route)
|
| 921 |
+
return jsonify(fallback_tool_route)
|
| 922 |
+
|
| 923 |
+
prompt = f"""
|
| 924 |
+
You are ProspectIQ's AI Command Bar assistant.
|
| 925 |
+
|
| 926 |
+
Current Date/Time: {client_time}
|
| 927 |
+
|
| 928 |
+
{context}
|
| 929 |
+
|
| 930 |
+
Determine the action to take based on the user's query. Valid actions:
|
| 931 |
+
- "view_company_profile": User wants to open, view, or show the profile page of a specific existing company. Look through the list of companies in the context. If you find a matching company (e.g. "Tesla" matching "Tesla Inc."), extract its "company_id" (integer) and "company_name" (exact name in database).
|
| 932 |
+
- "tool_route": User wants to use one of ProspectIQ's tools. Select this before CRM actions when the user is asking to scan, geocode/find nearby companies, or research/extract a company profile.
|
| 933 |
+
Valid tools:
|
| 934 |
+
1. "ocr" for Business Card Parser, OCR, business card scanning, card/image/document text extraction. Upload is required, so route to the tool; do not invent extracted data.
|
| 935 |
+
2. "geo" for Regional Prospecting, Geo Finder, geographic company discovery, nearby/location/company category search, map/coordinate searches.
|
| 936 |
+
3. "research" for Company Research, Company Extractor, company enrichment, company profile generation, extracting public information about a named company.
|
| 937 |
+
- "filter_companies": User wants to view, show, find, rank, or list companies already in CRM (including when they ask "list top 3 companies from database" or "top companies in CRM"). Extract any mentioned filters. Valid stages are EXACTLY: "New", "Shortlisted", "Research Done", "Contacted", "Engaged", "Meeting Scheduled", "Meeting Done", "Proposal Sent", "Won", "Paused / Not a Fit". Also extract `view_type` if they specify "table" or "kanban"/"board". If they ask for top/ranked companies from the database, also include `reply` with the formatted markdown list from the context.
|
| 938 |
+
- "add_company": User wants to add a new company. Carefully extract all descriptive details into "company_data":
|
| 939 |
+
* "name": Clean, exact short company name ONLY without descriptive clauses (e.g. if prompt says "called Abel music house where the CEO is Abel...", extract only "Abel music house").
|
| 940 |
+
* "industry": The industry or business type (e.g. if prompt says "they do Music labelling business", extract "Music labelling").
|
| 941 |
+
* "province" / "city" / "country": Geographic location (e.g. "Ontario" -> province: "Ontario", country: "Canada").
|
| 942 |
+
* "size_staff": Employee count as integer (e.g. if "around 50-70", use midpoint 60).
|
| 943 |
+
* "current_supplier_notes": Any leadership summary, meeting notes, background, products, or extra descriptive info mentioned in the query (e.g. if prompt says "We just met with a prospective vendor called NorthStar Dynamics... CEO is Elena, VP is Marcus...", set this to "We just met with a prospective vendor. CEO is Dr. Elena Rostova, VP of Procurement is Marcus Vance (m.vance@northstardynamics.ca, 905-555-0199)"). NEVER leave this empty or "N/A" when any background/notes are provided.
|
| 944 |
+
* "notes": Also copy the summary or meeting context here so it is logged in the Notes & Activity tab.
|
| 945 |
+
* "contacts": Array of any decision makers, executives, or contacts mentioned (e.g. if prompt says "CEO is Sarah Connor, VP is Kyle Reese", extract [{{"first_name": "Sarah", "last_name": "Connor", "title": "CEO", "is_primary": true}}, {{"first_name": "Kyle", "last_name": "Reese", "title": "VP", "is_primary": false}}]).
|
| 946 |
+
- "edit_company": User wants to edit a company. Extract "company_id" and "company_data" to update.
|
| 947 |
+
- "delete_company": User wants to delete a company. Extract "company_id".
|
| 948 |
+
- "filter_contacts": User wants to view, show, find, or list contacts.
|
| 949 |
+
- "add_contact": User wants to add a new contact (EXTERNAL buyer/client person). Extract "contact_data" (first_name, last_name, company_id, email, phone, linkedin).
|
| 950 |
+
- "edit_contact": User wants to edit a contact (e.g. updating email, phone, title, or linkedin URL/ID). Extract "contact_id" and "contact_data" (e.g. {{"linkedin": "https://linkedin.com/in/..."}}) to update.
|
| 951 |
+
- "delete_contact": User wants to delete a contact. Extract "contact_id".
|
| 952 |
+
- "add_task": If the user wants to add, create, or schedule a new task. Extract the "task_title". Also extract "date" (format YYYY-MM-DD), "time" (format HH:MM), and "notes" if the user mentions scheduling or specifies date/time/notes in the query. Unless and until date or time is explicitly mentioned (or if the user says unscheduled or unassigned), set "date" to null and "time" to null so that it stays an unassigned task that does not appear in the calendar.
|
| 953 |
+
- "edit_task": If the user wants to edit, update, reschedule, or postpone an existing task. Identify the "task_id" from the context.
|
| 954 |
+
- "complete_task": If the user wants to mark a task as completed, done, or finished. Identify the "task_id".
|
| 955 |
+
- "delete_task": If the user wants to delete or remove a task. Identify the "task_id".
|
| 956 |
+
- "add_rep": User wants to add/create/register a new representative (INTERNAL user/team member who logs into ProspectIQ). Extract "rep_data" (name, email, role, password).
|
| 957 |
+
- "edit_rep": User wants to edit/update a representative's credentials or status (active/inactive). Extract "email" and "rep_data" (name, role, is_active, password).
|
| 958 |
+
- "delete_rep": User wants to delete/remove a representative. Extract "email".
|
| 959 |
+
- "answer": For answering general questions, requests for summaries, clarifying ambiguous prompts, asking follow-up questions, suggesting MCQ options, or drafting emails.
|
| 960 |
+
|
| 961 |
+
CRITICAL CONVERSATIONAL & AMBIGUITY RULES:
|
| 962 |
+
1. Be Conversational & Clarifying: If the user's prompt is unclear, ambiguous, or missing required details (e.g. adding a contact without a name or company, or updating a stage without specifying which company), do NOT output a blunt error or make assumptions. Output "answer" asking friendly clarifying follow-up questions so they understand what is needed and can ask the right question.
|
| 963 |
+
2. Suggest MCQ Options for Ambiguous Requests: Whenever the query is ambiguous between multiple actions or options, suggest a clear Multiple-Choice Question (MCQ) numbered list (e.g., `1) Option A\n2) Option B\n3) Option C`) so the user can pick the option they meant.
|
| 964 |
+
3. Distinguish Between Contacts and Users/Representatives:
|
| 965 |
+
- External CRM Contacts (`add_contact` / `edit_contact` / `delete_contact` / `filter_contacts`): People working at client or prospect companies (buyers, managers, executives).
|
| 966 |
+
- Internal Users/Representatives (`add_rep` / `edit_rep` / `delete_rep`): Internal team members (BD Rep, Viewer, Admin) who log into ProspectIQ.
|
| 967 |
+
If the user says "add user", "add role", "add rep", or mentions login permissions/roles, this is an INTERNAL representative (`add_rep`), NOT a CRM contact. If it is unclear whether they mean an internal team member or an external CRM contact, ask a conversational follow-up with MCQ options:
|
| 968 |
+
"Would you like to add an **Internal Team Member (User/Rep)** who logs into ProspectIQ, or an **External CRM Contact** at a client company?\n1) **Internal Team Member** (Requires Name, Email, Role)\n2) **External CRM Contact** (Requires Name, Company, Email/Phone)"
|
| 969 |
+
4. CRM Database vs. Regional/External Prospecting:
|
| 970 |
+
- If the user asks to list, show, check, find, or rank companies and explicitly mentions "from database", "in CRM", "saved companies", "my companies", or "top companies in database/CRM", ALWAYS treat it as a CRM database request (`filter_companies` with `reply` synthesizing from the context `companies` list, or `answer`). NEVER output `tool_route` (`geo` or `research`) for requests referring to existing records already in the database/CRM.
|
| 971 |
+
- Conversely, if the user asks to "find", "search", "discover", or "prospect" companies across a location or industry (e.g., "Find aerospace companies and CNC shops in Mississauga Ontario" or "Find machine shops near Toronto") WITHOUT explicitly saying "in database" or "in CRM", you MUST output `tool_route` with `tool: "geo"` and `tool_input: {{"query": "<query>"}}` to open Regional Prospecting.
|
| 972 |
+
|
| 973 |
+
CRITICAL ROUTING RULE: If the query asks to "open", "show", "view", or "go to" the profile, card, info, or page of a company (e.g. "open the profile of Tesla"), check the list of companies in the context.
|
| 974 |
+
- If the company is already present in the database context (e.g., "Tesla Inc."), you MUST output "view_company_profile" with its "company_id" and "company_name". DO NOT output "tool_route" (research) for existing companies.
|
| 975 |
+
- If the company is NOT in the database context, and they want to extract/sourcing it, output "tool_route" with "research".
|
| 976 |
+
|
| 977 |
+
CRITICAL FILTER RULE: If the query says "show me", "view", or "list" followed by companies already in CRM (e.g. "Aerospace companies in CRM", "contacted companies", "top 3 companies in database"), you MUST output "filter_companies".
|
| 978 |
+
CRITICAL TOOL RULE: If the query explicitly mentions Business Card Parser, OCR, business card scan, Regional Prospecting, Geo Finder, Company Research, company extractor, nearby/around/within companies, coordinates, or external discovery, output "tool_route".
|
| 979 |
+
If the user asks a question about the data in the context provided, answer it usefully using the "answer" action.
|
| 980 |
+
|
| 981 |
+
Respond ONLY with a STRICTLY VALID JSON object. Do not include markdown formatting or extra text. Include only the fields relevant to the chosen action. Example formats:
|
| 982 |
+
|
| 983 |
+
For viewing/opening a company profile:
|
| 984 |
+
{{"action": "view_company_profile", "company_id": 123, "company_name": "Tesla Inc."}}
|
| 985 |
+
|
| 986 |
+
For filtering companies:
|
| 987 |
+
{{"action": "filter_companies", "filters": {{"industry": "...", "stage": "..."}}, "view_type": "table"}}
|
| 988 |
+
|
| 989 |
+
For adding a company:
|
| 990 |
+
{{"action": "add_company", "company_data": {{"name": "Abel music house", "industry": "Music labelling", "province": "Ontario", "country": "Canada", "size_staff": 60, "current_supplier_notes": "CEO is Abel, VP is Weeknd", "contacts": [{{"first_name": "Abel", "last_name": "-", "title": "CEO", "is_primary": true}}, {{"first_name": "Weeknd", "last_name": "-", "title": "VP", "is_primary": false}}]}}}}
|
| 991 |
+
|
| 992 |
+
For editing a company:
|
| 993 |
+
{{"action": "edit_company", "company_id": 123, "company_data": {{"name": "..."}}}}
|
| 994 |
+
|
| 995 |
+
For deleting a company:
|
| 996 |
+
{{"action": "delete_company", "company_id": 123}}
|
| 997 |
+
|
| 998 |
+
For adding a contact:
|
| 999 |
+
{{"action": "add_contact", "contact_data": {{"first_name": "...", "last_name": "...", "linkedin": "https://linkedin.com/in/..."}}}}
|
| 1000 |
+
|
| 1001 |
+
For editing a contact (e.g. adding or changing linkedin, email, phone):
|
| 1002 |
+
{{"action": "edit_contact", "contact_id": 12, "contact_data": {{"linkedin": "https://linkedin.com/in/..."}}}}
|
| 1003 |
+
|
| 1004 |
+
For adding a task (if unscheduled/unassigned or no date/time given, set date and time to null):
|
| 1005 |
+
{{"action": "add_task", "task_title": "...", "date": null, "time": null, "notes": "..."}}
|
| 1006 |
+
|
| 1007 |
+
For editing/updating/rescheduling a task:
|
| 1008 |
+
{{"action": "edit_task", "task_id": 12345, "task_title": "...", "date": "YYYY-MM-DD", "time": "HH:MM", "notes": "..."}}
|
| 1009 |
+
(Note: only include the fields that need to be changed/updated)
|
| 1010 |
+
|
| 1011 |
+
For completing a task:
|
| 1012 |
+
{{"action": "complete_task", "task_id": 12345}}
|
| 1013 |
+
|
| 1014 |
+
For deleting a task:
|
| 1015 |
+
{{"action": "delete_task", "task_id": 12345}}
|
| 1016 |
+
|
| 1017 |
+
For tool routing:
|
| 1018 |
+
{{"action": "tool_route", "tool": "geo", "tool_input": {{"query": "CNC machining companies near Windsor Ontario"}}, "auto_start": true, "message": "Opening Regional Prospecting and starting the search."}}
|
| 1019 |
+
{{"action": "tool_route", "tool": "research", "tool_input": {{"company": "Prestige Constructions India"}}, "auto_start": true, "message": "Opening Company Research and starting the research."}}
|
| 1020 |
+
{{"action": "tool_route", "tool": "ocr", "tool_input": {{"query": "business card scan"}}, "auto_start": false, "message": "Opening Business Card Parser. Upload a business card image to begin."}}
|
| 1021 |
+
|
| 1022 |
+
For answering:
|
| 1023 |
+
{{"action": "answer", "reply": "..."}}
|
| 1024 |
+
"""
|
| 1025 |
+
|
| 1026 |
+
# Format and include chat history if provided
|
| 1027 |
+
history = data.get("history", [])
|
| 1028 |
+
formatted_messages = [{"role": "system", "content": prompt}]
|
| 1029 |
+
for msg in history:
|
| 1030 |
+
r = msg.get("role")
|
| 1031 |
+
c = msg.get("content", "")
|
| 1032 |
+
if r == "bot":
|
| 1033 |
+
role_name = "assistant"
|
| 1034 |
+
# Format assistant response as JSON to keep LLM aligned with JSON constraints
|
| 1035 |
+
try:
|
| 1036 |
+
json.loads(c)
|
| 1037 |
+
content_str = c
|
| 1038 |
+
except Exception:
|
| 1039 |
+
content_str = json.dumps({"action": "answer", "reply": c})
|
| 1040 |
+
formatted_messages.append({"role": role_name, "content": content_str})
|
| 1041 |
+
elif r == "user":
|
| 1042 |
+
formatted_messages.append({"role": "user", "content": c})
|
| 1043 |
+
|
| 1044 |
+
formatted_messages.append({"role": "user", "content": query})
|
| 1045 |
+
|
| 1046 |
+
# Collect available API keys
|
| 1047 |
+
gemini_keys = [
|
| 1048 |
+
os.environ.get("GEMINI_API_KEY_prime_1"),
|
| 1049 |
+
os.environ.get("GEMINI_API_KEY_prime_2"),
|
| 1050 |
+
os.environ.get("GEMINI_API_KEY_prime_3"),
|
| 1051 |
+
]
|
| 1052 |
+
gemini_keys = [k for k in gemini_keys if k]
|
| 1053 |
+
|
| 1054 |
+
content = None
|
| 1055 |
+
errors = []
|
| 1056 |
+
|
| 1057 |
+
# 1. Try Gemini natively via interactions streaming
|
| 1058 |
+
if not content:
|
| 1059 |
+
for k in gemini_keys:
|
| 1060 |
+
if content:
|
| 1061 |
+
break
|
| 1062 |
+
try:
|
| 1063 |
+
from google import genai
|
| 1064 |
+
client = genai.Client(api_key=k)
|
| 1065 |
+
|
| 1066 |
+
# Convert structured messages to a string for the interactions input
|
| 1067 |
+
prompt_str = ""
|
| 1068 |
+
for m in formatted_messages:
|
| 1069 |
+
prompt_str += m["role"].upper() + ":\n" + m["content"] + "\n\n"
|
| 1070 |
+
|
| 1071 |
+
stream = client.interactions.create(
|
| 1072 |
+
model="gemini-2.5-flash",
|
| 1073 |
+
input=prompt_str.strip(),
|
| 1074 |
+
stream=True
|
| 1075 |
+
)
|
| 1076 |
+
|
| 1077 |
+
gemini_content = ""
|
| 1078 |
+
for event in stream:
|
| 1079 |
+
if getattr(event, "event_type", "") == "step.delta":
|
| 1080 |
+
if hasattr(event.delta, "text"):
|
| 1081 |
+
gemini_content += event.delta.text
|
| 1082 |
+
|
| 1083 |
+
if gemini_content:
|
| 1084 |
+
content = gemini_content
|
| 1085 |
+
except Exception as e:
|
| 1086 |
+
errors.append(f"Gemini streaming exception: {str(e)}")
|
| 1087 |
+
|
| 1088 |
+
# 2. Try Claude API with streaming fallback
|
| 1089 |
+
if not content:
|
| 1090 |
+
try:
|
| 1091 |
+
claude_key = os.environ.get("ANTHROPIC_API_KEY", "").strip()
|
| 1092 |
+
if claude_key:
|
| 1093 |
+
import anthropic
|
| 1094 |
+
client = anthropic.Anthropic(api_key=claude_key)
|
| 1095 |
+
|
| 1096 |
+
system_msg = ""
|
| 1097 |
+
anthropic_messages = []
|
| 1098 |
+
for msg in formatted_messages:
|
| 1099 |
+
if msg.get("role") == "system":
|
| 1100 |
+
system_msg = msg.get("content")
|
| 1101 |
+
else:
|
| 1102 |
+
anthropic_messages.append(msg)
|
| 1103 |
+
|
| 1104 |
+
kwargs = {
|
| 1105 |
+
"model": "claude-haiku-4-5",
|
| 1106 |
+
"max_tokens": 4096,
|
| 1107 |
+
"messages": anthropic_messages
|
| 1108 |
+
}
|
| 1109 |
+
if system_msg:
|
| 1110 |
+
kwargs["system"] = system_msg
|
| 1111 |
+
|
| 1112 |
+
claude_content = ""
|
| 1113 |
+
max_continuations = 5
|
| 1114 |
+
continuation_count = 0
|
| 1115 |
+
|
| 1116 |
+
while True:
|
| 1117 |
+
with client.messages.stream(**kwargs) as stream:
|
| 1118 |
+
for text in stream.text_stream:
|
| 1119 |
+
claude_content += text
|
| 1120 |
+
|
| 1121 |
+
final_msg = stream.get_final_message()
|
| 1122 |
+
stop_reason = final_msg.stop_reason
|
| 1123 |
+
|
| 1124 |
+
if stop_reason == "end_turn":
|
| 1125 |
+
break
|
| 1126 |
+
elif stop_reason == "max_tokens":
|
| 1127 |
+
continuation_count += 1
|
| 1128 |
+
if continuation_count >= max_continuations:
|
| 1129 |
+
break
|
| 1130 |
+
|
| 1131 |
+
kwargs["messages"].append({"role": "assistant", "content": final_msg.content})
|
| 1132 |
+
kwargs["messages"].append({
|
| 1133 |
+
"role": "user",
|
| 1134 |
+
"content": "Your previous response was cut off. Please continue exactly from where you left off. Do not include markdown or explanations, just the JSON."
|
| 1135 |
+
})
|
| 1136 |
+
elif stop_reason == "refusal":
|
| 1137 |
+
break
|
| 1138 |
+
else:
|
| 1139 |
+
break
|
| 1140 |
+
|
| 1141 |
+
if claude_content:
|
| 1142 |
+
content = claude_content
|
| 1143 |
+
else:
|
| 1144 |
+
errors.append("Claude API key not configured")
|
| 1145 |
+
except Exception as e:
|
| 1146 |
+
errors.append(f"Claude exception: {str(e)}")
|
| 1147 |
+
|
| 1148 |
+
if not content:
|
| 1149 |
+
if fallback_tool_route:
|
| 1150 |
+
fallback_tool_route["memory_id"] = save_tool_route(query, fallback_tool_route)
|
| 1151 |
+
return jsonify(fallback_tool_route)
|
| 1152 |
+
err_msg = " | ".join(errors[-3:])
|
| 1153 |
+
return jsonify({"error": f"All AI providers failed: {err_msg}", "reply": "I encountered an error connecting to my AI providers."}), 500
|
| 1154 |
+
|
| 1155 |
+
try:
|
| 1156 |
+
content = content.replace("```json", "").replace("```", "").strip()
|
| 1157 |
+
parsed = json.loads(content)
|
| 1158 |
+
|
| 1159 |
+
if parsed.get("action") in {"add_rep", "edit_rep", "delete_rep"}:
|
| 1160 |
+
if 'user_id' not in session:
|
| 1161 |
+
return jsonify({"action": "answer", "reply": "You must be logged in to manage representatives."})
|
| 1162 |
+
if session.get('user_role') != 'Admin':
|
| 1163 |
+
return jsonify({"action": "answer", "reply": "Unauthorized. Only administrators can manage representatives."})
|
| 1164 |
+
|
| 1165 |
+
tenant_id = session.get('tenant_id')
|
| 1166 |
+
if parsed.get("action") == "add_rep":
|
| 1167 |
+
rep_data = parsed.get("rep_data", {})
|
| 1168 |
+
res = db_handle_rep_command("add_rep", rep_data, tenant_id)
|
| 1169 |
+
return jsonify(res)
|
| 1170 |
+
elif parsed.get("action") == "edit_rep":
|
| 1171 |
+
email_lookup = parsed.get("email")
|
| 1172 |
+
rep_data = parsed.get("rep_data", {})
|
| 1173 |
+
|
| 1174 |
+
target_user_id = None
|
| 1175 |
+
conn = get_db_connection()
|
| 1176 |
+
cursor = conn.cursor()
|
| 1177 |
+
if email_lookup:
|
| 1178 |
+
cursor.execute("SELECT id FROM users WHERE LOWER(email) = LOWER(?) AND tenant_id = ?", (email_lookup, tenant_id))
|
| 1179 |
+
else:
|
| 1180 |
+
name_lookup = rep_data.get("name")
|
| 1181 |
+
if name_lookup:
|
| 1182 |
+
cursor.execute("SELECT id FROM users WHERE LOWER(name) LIKE LOWER(?) AND tenant_id = ?", (f"%{name_lookup}%", tenant_id))
|
| 1183 |
+
row = cursor.fetchone()
|
| 1184 |
+
if row: target_user_id = row[0]
|
| 1185 |
+
conn.close()
|
| 1186 |
+
|
| 1187 |
+
query_data = {"target_user_id": target_user_id, "rep_data": rep_data}
|
| 1188 |
+
res = db_handle_rep_command("edit_rep", query_data, tenant_id)
|
| 1189 |
+
return jsonify(res)
|
| 1190 |
+
elif parsed.get("action") == "delete_rep":
|
| 1191 |
+
email_lookup = parsed.get("email")
|
| 1192 |
+
|
| 1193 |
+
target_user_id = None
|
| 1194 |
+
conn = get_db_connection()
|
| 1195 |
+
cursor = conn.cursor()
|
| 1196 |
+
if email_lookup:
|
| 1197 |
+
cursor.execute("SELECT id FROM users WHERE LOWER(email) = LOWER(?) AND tenant_id = ?", (email_lookup, tenant_id))
|
| 1198 |
+
row = cursor.fetchone()
|
| 1199 |
+
if row: target_user_id = row[0]
|
| 1200 |
+
conn.close()
|
| 1201 |
+
|
| 1202 |
+
query_data = {"target_user_id": target_user_id}
|
| 1203 |
+
res = db_handle_rep_command("delete_rep", query_data, tenant_id)
|
| 1204 |
+
return jsonify(res)
|
| 1205 |
+
|
| 1206 |
+
if parsed.get("action") == "tool_route":
|
| 1207 |
+
routed = normalize_tool_route(parsed, query)
|
| 1208 |
+
if fallback_tool_route and fallback_tool_route.get("confidence") == "rule":
|
| 1209 |
+
routed = fallback_tool_route
|
| 1210 |
+
routed["memory_id"] = save_tool_route(query, routed)
|
| 1211 |
+
return jsonify(routed)
|
| 1212 |
+
return jsonify(parsed)
|
| 1213 |
+
except Exception as e:
|
| 1214 |
+
if fallback_tool_route:
|
| 1215 |
+
fallback_tool_route["memory_id"] = save_tool_route(query, fallback_tool_route)
|
| 1216 |
+
return jsonify(fallback_tool_route)
|
| 1217 |
+
return jsonify({"error": str(e), "reply": "I had trouble parsing the response from my AI service."}), 500
|
| 1218 |
+
|
| 1219 |
+
@command_bp.route("/memory", methods=["GET"])
|
| 1220 |
+
def command_memory():
|
| 1221 |
+
limit = clean_int(request.args.get("limit"), 20, 1, 80)
|
| 1222 |
+
# Use efficient DB-level filtering by kind instead of fetching all and filtering in Python
|
| 1223 |
+
items = db_list_interactions_by_kinds(["tool_route", "ocr", "geo", "research"], limit)
|
| 1224 |
+
memory = []
|
| 1225 |
+
for item in items:
|
| 1226 |
+
memory.append({
|
| 1227 |
+
"id": item.get("id"),
|
| 1228 |
+
"kind": item.get("kind"),
|
| 1229 |
+
"title": item.get("title"),
|
| 1230 |
+
"status": item.get("status"),
|
| 1231 |
+
"created_at": item.get("created_at"),
|
| 1232 |
+
"input": {
|
| 1233 |
+
"query": item.get("input", {}).get("query"),
|
| 1234 |
+
"filename": item.get("input", {}).get("filename"),
|
| 1235 |
+
"selected_tool": item.get("input", {}).get("selected_tool"),
|
| 1236 |
+
"route": item.get("input", {}).get("route"),
|
| 1237 |
+
"tool_input": item.get("input", {}).get("tool_input") or {},
|
| 1238 |
+
},
|
| 1239 |
+
"result": {
|
| 1240 |
+
"selected_tool": item.get("result", {}).get("selected_tool"),
|
| 1241 |
+
"tool_label": item.get("result", {}).get("tool_label"),
|
| 1242 |
+
"route": item.get("result", {}).get("route"),
|
| 1243 |
+
"message": item.get("result", {}).get("message"),
|
| 1244 |
+
},
|
| 1245 |
+
})
|
| 1246 |
+
return jsonify(memory)
|
routes/companies.py
ADDED
|
@@ -0,0 +1,942 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
from flask import Blueprint, jsonify, request
|
| 3 |
+
from db import get_db_connection, dict_from_row, get_request_user, get_user_access_level, is_supabase_active, sync_postgres_sequence, get_request_tenant_id
|
| 4 |
+
|
| 5 |
+
companies_bp = Blueprint('companies', __name__)
|
| 6 |
+
|
| 7 |
+
def insert_and_get_id(cursor, sql, params):
|
| 8 |
+
cursor.execute(sql, params)
|
| 9 |
+
return cursor.lastrowid
|
| 10 |
+
|
| 11 |
+
def safe_int(value, default):
|
| 12 |
+
if value is None or value == "":
|
| 13 |
+
return default
|
| 14 |
+
try:
|
| 15 |
+
return int(value)
|
| 16 |
+
except (TypeError, ValueError):
|
| 17 |
+
return default
|
| 18 |
+
|
| 19 |
+
def log_stage_change(cursor, company_id, to_stage_id, user_id, source="manual", from_stage_id=None):
|
| 20 |
+
if is_supabase_active():
|
| 21 |
+
sync_postgres_sequence("stage_changes")
|
| 22 |
+
if from_stage_id is None:
|
| 23 |
+
cursor.execute("""
|
| 24 |
+
INSERT INTO stage_changes (company_id, to_stage_id, changed_by_user_id, source)
|
| 25 |
+
VALUES (?, ?, ?, ?)
|
| 26 |
+
""", (company_id, to_stage_id, user_id, source))
|
| 27 |
+
else:
|
| 28 |
+
cursor.execute("""
|
| 29 |
+
INSERT INTO stage_changes (company_id, from_stage_id, to_stage_id, changed_by_user_id, source)
|
| 30 |
+
VALUES (?, ?, ?, ?, ?)
|
| 31 |
+
""", (company_id, from_stage_id, to_stage_id, user_id, source))
|
| 32 |
+
|
| 33 |
+
def extract_int(val, default=0):
|
| 34 |
+
if val is None:
|
| 35 |
+
return default
|
| 36 |
+
if isinstance(val, int):
|
| 37 |
+
return val
|
| 38 |
+
s = re.sub(r'[^\d]', '', str(val))
|
| 39 |
+
return int(s) if s else default
|
| 40 |
+
|
| 41 |
+
def extract_float(val, default=0.0):
|
| 42 |
+
if val is None:
|
| 43 |
+
return default
|
| 44 |
+
if isinstance(val, (int, float)):
|
| 45 |
+
return float(val)
|
| 46 |
+
match = re.search(r'\d+(\.\d+)?', str(val))
|
| 47 |
+
return float(match.group()) if match else default
|
| 48 |
+
|
| 49 |
+
def clean_profile_value(value):
|
| 50 |
+
value = re.sub(r"\s+", " ", str(value or "")).strip()
|
| 51 |
+
value = re.sub(r"^\s*[-*+]\s*", "", value).strip()
|
| 52 |
+
value = value.replace("**", "").replace("__", "").replace("`", "").strip()
|
| 53 |
+
if value.lower() in {"not publicly available", "not available", "n/a", "none"}:
|
| 54 |
+
return ""
|
| 55 |
+
return value
|
| 56 |
+
|
| 57 |
+
def clean_profile_heading(value):
|
| 58 |
+
value = re.sub(r"^\s*#{1,6}\s*", "", str(value or "").strip())
|
| 59 |
+
return clean_profile_value(value.rstrip(":"))
|
| 60 |
+
|
| 61 |
+
def split_profile_sections(text):
|
| 62 |
+
sections = {}
|
| 63 |
+
current = "Profile"
|
| 64 |
+
lines = []
|
| 65 |
+
for raw_line in str(text or "").splitlines():
|
| 66 |
+
line = raw_line.strip()
|
| 67 |
+
heading = clean_profile_heading(line)
|
| 68 |
+
is_heading = bool(
|
| 69 |
+
line
|
| 70 |
+
and len(heading) < 90
|
| 71 |
+
and (
|
| 72 |
+
line.startswith("#")
|
| 73 |
+
or re.match(r"^\*\*[^*]+:\*\*\s*$", line)
|
| 74 |
+
or (not line.startswith((" ", "\t", "-", "*", "+")) and not line.endswith(":"))
|
| 75 |
+
)
|
| 76 |
+
)
|
| 77 |
+
if is_heading:
|
| 78 |
+
if lines:
|
| 79 |
+
sections[current] = "\n".join(lines).strip()
|
| 80 |
+
current = heading
|
| 81 |
+
lines = []
|
| 82 |
+
else:
|
| 83 |
+
lines.append(raw_line)
|
| 84 |
+
if lines:
|
| 85 |
+
sections[current] = "\n".join(lines).strip()
|
| 86 |
+
return sections
|
| 87 |
+
|
| 88 |
+
def labeled_line_pattern(label):
|
| 89 |
+
return re.compile(
|
| 90 |
+
rf"^\s*(?:[-*+]\s*)?(?:\*\*)?\s*{re.escape(label)}\s*(?:\*\*)?\s*:\s*(.*?)\s*$",
|
| 91 |
+
re.I,
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
def looks_like_labeled_line(line):
|
| 95 |
+
return bool(re.match(r"^\s*(?:[-*+]\s*)?(?:\*\*)?\s*[A-Za-z][A-Za-z0-9 /&().'-]{1,80}\s*(?:\*\*)?\s*:", line))
|
| 96 |
+
|
| 97 |
+
def extract_labeled_value(text, label):
|
| 98 |
+
pattern = labeled_line_pattern(label)
|
| 99 |
+
for line in str(text or "").splitlines():
|
| 100 |
+
match = pattern.match(line)
|
| 101 |
+
if match:
|
| 102 |
+
return clean_profile_value(match.group(1))
|
| 103 |
+
return ""
|
| 104 |
+
|
| 105 |
+
def extract_multiline_labeled_value(text, label):
|
| 106 |
+
pattern = labeled_line_pattern(label)
|
| 107 |
+
collecting = False
|
| 108 |
+
values = []
|
| 109 |
+
for line in str(text or "").splitlines():
|
| 110 |
+
if not collecting:
|
| 111 |
+
match = pattern.match(line)
|
| 112 |
+
if match:
|
| 113 |
+
collecting = True
|
| 114 |
+
values.append(match.group(1))
|
| 115 |
+
continue
|
| 116 |
+
stripped = line.strip()
|
| 117 |
+
if stripped.startswith("#") or looks_like_labeled_line(line):
|
| 118 |
+
break
|
| 119 |
+
values.append(line)
|
| 120 |
+
return clean_profile_value("\n".join(values).strip(" \n\t:-"))
|
| 121 |
+
|
| 122 |
+
def infer_company_name(text):
|
| 123 |
+
for line in str(text or "").splitlines():
|
| 124 |
+
cleaned = clean_profile_heading(line)
|
| 125 |
+
match = re.match(r"Company Research\s*:\s*(.+)$", cleaned, re.I)
|
| 126 |
+
if match:
|
| 127 |
+
return clean_profile_value(match.group(1))
|
| 128 |
+
match = re.match(r"(.+?)\s*:\s*Company Profile\b", cleaned, re.I)
|
| 129 |
+
if match:
|
| 130 |
+
return clean_profile_value(match.group(1))
|
| 131 |
+
return ""
|
| 132 |
+
|
| 133 |
+
def parse_employee_count(value):
|
| 134 |
+
match = re.search(r"\d[\d,]*", str(value or ""))
|
| 135 |
+
return int(match.group(0).replace(",", "")) if match else 0
|
| 136 |
+
|
| 137 |
+
def normalize_website(value):
|
| 138 |
+
value = clean_profile_value(value)
|
| 139 |
+
if value and not re.match(r"^https?://", value, re.I):
|
| 140 |
+
return "https://" + value
|
| 141 |
+
return value
|
| 142 |
+
|
| 143 |
+
def parse_address(value):
|
| 144 |
+
value = clean_profile_value(value)
|
| 145 |
+
if not value:
|
| 146 |
+
return {"street": "", "city": "", "province": "", "postal_code": "", "country": ""}
|
| 147 |
+
parenthetical = re.search(r"\((.*?)\)", value)
|
| 148 |
+
address = parenthetical.group(1) if parenthetical else value
|
| 149 |
+
parts = [part.strip() for part in address.split(",") if part.strip()]
|
| 150 |
+
country = parts[-1] if parts else ""
|
| 151 |
+
province = parts[-2] if len(parts) >= 2 else ""
|
| 152 |
+
city = parts[-3] if len(parts) >= 3 else (parts[0] if parts else "")
|
| 153 |
+
street = ", ".join(parts[:-3]) if len(parts) > 3 else ""
|
| 154 |
+
postal_match = re.search(r"\b\d{5,6}\b", address)
|
| 155 |
+
return {
|
| 156 |
+
"street": street,
|
| 157 |
+
"city": city,
|
| 158 |
+
"province": province,
|
| 159 |
+
"postal_code": postal_match.group(0) if postal_match else "",
|
| 160 |
+
"country": country,
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
def split_name(name):
|
| 164 |
+
parts = clean_profile_value(name).split()
|
| 165 |
+
if not parts:
|
| 166 |
+
return "", ""
|
| 167 |
+
if len(parts) == 1:
|
| 168 |
+
return parts[0], "-"
|
| 169 |
+
return " ".join(parts[:-1]), parts[-1]
|
| 170 |
+
|
| 171 |
+
def parse_decision_makers(text):
|
| 172 |
+
contacts = []
|
| 173 |
+
primary_name = extract_labeled_value(text, "Primary Contact")
|
| 174 |
+
person_blocks = re.split(r"^\s*(?:\*\*)?\s*Person\s+\d+\s*:\s*(?:\*\*)?\s*$", str(text or ""), flags=re.I | re.M)
|
| 175 |
+
for block in person_blocks[1:]:
|
| 176 |
+
name = extract_labeled_value(block, "Name")
|
| 177 |
+
if not name:
|
| 178 |
+
continue
|
| 179 |
+
first_name, last_name = split_name(name)
|
| 180 |
+
contacts.append({
|
| 181 |
+
"first_name": first_name,
|
| 182 |
+
"last_name": last_name,
|
| 183 |
+
"title": extract_labeled_value(block, "Title"),
|
| 184 |
+
"email": extract_labeled_value(block, "Email (if available)") or extract_labeled_value(block, "Email"),
|
| 185 |
+
"phone": extract_labeled_value(block, "Phone (if available)") or extract_labeled_value(block, "Phone"),
|
| 186 |
+
"linkedin": extract_labeled_value(block, "LinkedIn URL"),
|
| 187 |
+
"is_primary": clean_profile_value(name).lower() == clean_profile_value(primary_name).lower(),
|
| 188 |
+
})
|
| 189 |
+
if contacts and not any(contact["is_primary"] for contact in contacts):
|
| 190 |
+
contacts[0]["is_primary"] = True
|
| 191 |
+
return contacts
|
| 192 |
+
|
| 193 |
+
def extract_contacts_from_descriptive_notes(text):
|
| 194 |
+
if not text:
|
| 195 |
+
return []
|
| 196 |
+
contacts = []
|
| 197 |
+
matches = re.findall(
|
| 198 |
+
r"\b(CEO|VP|Vice\s+President|President|Founder|Director|Manager|COO|CFO|CTO|Head\s+of\s+[A-Za-z]+)\b\s*(?:is|are|:|of\s+[A-Za-z]+\s+is)?\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+)+)",
|
| 199 |
+
str(text),
|
| 200 |
+
re.I,
|
| 201 |
+
)
|
| 202 |
+
for title, full_name in matches:
|
| 203 |
+
fn, ln = split_name(full_name.strip())
|
| 204 |
+
if fn or ln:
|
| 205 |
+
clean_t = title.strip().upper() if len(title.strip()) <= 3 else title.strip().title()
|
| 206 |
+
if not any(c["first_name"] == fn and c["last_name"] == ln for c in contacts):
|
| 207 |
+
contacts.append({
|
| 208 |
+
"first_name": fn,
|
| 209 |
+
"last_name": ln or "-",
|
| 210 |
+
"title": clean_t,
|
| 211 |
+
"is_primary": len(contacts) == 0 or clean_t in ("CEO", "PRESIDENT", "FOUNDER")
|
| 212 |
+
})
|
| 213 |
+
matches2 = re.findall(
|
| 214 |
+
r"\b([A-Z][a-z]+(?:\s+[A-Z][a-z]+)+)\s*\(\s*(CEO|VP|Vice\s+President|President|Founder|Director|Manager|COO|CFO|CTO)\s*\)",
|
| 215 |
+
str(text),
|
| 216 |
+
re.I,
|
| 217 |
+
)
|
| 218 |
+
for full_name, title in matches2:
|
| 219 |
+
fn, ln = split_name(full_name.strip())
|
| 220 |
+
if fn or ln:
|
| 221 |
+
clean_t = title.strip().upper() if len(title.strip()) <= 3 else title.strip().title()
|
| 222 |
+
if not any(c["first_name"] == fn and c["last_name"] == ln for c in contacts):
|
| 223 |
+
contacts.append({
|
| 224 |
+
"first_name": fn,
|
| 225 |
+
"last_name": ln or "-",
|
| 226 |
+
"title": clean_t,
|
| 227 |
+
"is_primary": len(contacts) == 0 or clean_t in ("CEO", "PRESIDENT", "FOUNDER")
|
| 228 |
+
})
|
| 229 |
+
if contacts and not any(c.get("is_primary") for c in contacts):
|
| 230 |
+
contacts[0]["is_primary"] = True
|
| 231 |
+
return contacts
|
| 232 |
+
|
| 233 |
+
def build_company_from_extractor(text):
|
| 234 |
+
basics = split_profile_sections(text).get("Company Basics", text)
|
| 235 |
+
address = parse_address(extract_labeled_value(basics, "Headquarters"))
|
| 236 |
+
company_name = (
|
| 237 |
+
extract_labeled_value(basics, "Company Name")
|
| 238 |
+
or extract_labeled_value(text, "Company Name")
|
| 239 |
+
or infer_company_name(text)
|
| 240 |
+
)
|
| 241 |
+
return {
|
| 242 |
+
"name": company_name,
|
| 243 |
+
"legal_name": company_name,
|
| 244 |
+
"website": normalize_website(extract_labeled_value(basics, "Website")),
|
| 245 |
+
"phone": extract_labeled_value(basics, "Phone"),
|
| 246 |
+
"industry": extract_labeled_value(basics, "Industry"),
|
| 247 |
+
"street": address["street"],
|
| 248 |
+
"city": address["city"],
|
| 249 |
+
"province": address["province"],
|
| 250 |
+
"postal_code": address["postal_code"],
|
| 251 |
+
"country": address["country"] or "India",
|
| 252 |
+
"size_staff": parse_employee_count(extract_labeled_value(basics, "Employee Count")),
|
| 253 |
+
"revenue_band": extract_labeled_value(basics, "Revenue Estimate") or "<1M",
|
| 254 |
+
"business_type": "other",
|
| 255 |
+
"products_made": extract_multiline_labeled_value(basics, "Products / Services"),
|
| 256 |
+
"parts_sourced": extract_labeled_value(basics, "Customer Segments"),
|
| 257 |
+
"current_supplier_notes": "\n\n".join(
|
| 258 |
+
part for part in [
|
| 259 |
+
extract_labeled_value(basics, "Business Description"),
|
| 260 |
+
extract_multiline_labeled_value(text, "Primary Contact Reason"),
|
| 261 |
+
extract_multiline_labeled_value(text, "Secondary Contact Reason"),
|
| 262 |
+
] if part
|
| 263 |
+
),
|
| 264 |
+
"maplempss_fit_score": 0.0,
|
| 265 |
+
}
|
| 266 |
+
|
| 267 |
+
def ensure_extractor_stage(cursor):
|
| 268 |
+
stage_name = "From Company Extractor"
|
| 269 |
+
cursor.execute("SELECT id FROM stages WHERE lower(name) = lower(?)", (stage_name,))
|
| 270 |
+
row = cursor.fetchone()
|
| 271 |
+
if row:
|
| 272 |
+
return row[0]
|
| 273 |
+
cursor.execute("SELECT COALESCE(MAX(order_index), 0) + 1 FROM stages")
|
| 274 |
+
order_index = cursor.fetchone()[0]
|
| 275 |
+
return insert_and_get_id(
|
| 276 |
+
cursor,
|
| 277 |
+
"INSERT INTO stages (name, order_index, color_bg, color_text) VALUES (?, ?, ?, ?)",
|
| 278 |
+
(stage_name, order_index, "#f8f9fa", "#000000"),
|
| 279 |
+
)
|
| 280 |
+
|
| 281 |
+
def store_extractor_ai_fields(cursor, company_id, text):
|
| 282 |
+
sections = split_profile_sections(text)
|
| 283 |
+
for section, body in sections.items():
|
| 284 |
+
if not body.strip():
|
| 285 |
+
continue
|
| 286 |
+
cursor.execute("""
|
| 287 |
+
INSERT INTO ai_field_values (company_id, field_name, value, source_url, confidence_score)
|
| 288 |
+
VALUES (?, ?, ?, ?, ?)
|
| 289 |
+
""", (company_id, f"Company Extractor: {section}", body[:12000], "company-extractor", 0.85))
|
| 290 |
+
|
| 291 |
+
def get_stage_id_by_name(cursor, stage_name):
|
| 292 |
+
cursor.execute("SELECT id FROM stages WHERE lower(name) = lower(?)", (stage_name,))
|
| 293 |
+
row = cursor.fetchone()
|
| 294 |
+
return row[0] if row else None
|
| 295 |
+
|
| 296 |
+
def phone_from_ocr(value):
|
| 297 |
+
if isinstance(value, dict):
|
| 298 |
+
return clean_profile_value(value.get("office") or value.get("mobile") or value.get("phone") or "")
|
| 299 |
+
return clean_profile_value(value)
|
| 300 |
+
|
| 301 |
+
def has_meaningful_ocr_company_data(company_data, address):
|
| 302 |
+
values = [
|
| 303 |
+
company_data.get("company_name"),
|
| 304 |
+
company_data.get("website"),
|
| 305 |
+
address.get("full_address"),
|
| 306 |
+
address.get("street"),
|
| 307 |
+
address.get("city"),
|
| 308 |
+
address.get("state_province") or address.get("state"),
|
| 309 |
+
address.get("postal_code"),
|
| 310 |
+
address.get("country"),
|
| 311 |
+
]
|
| 312 |
+
return any(clean_profile_value(value) for value in values)
|
| 313 |
+
|
| 314 |
+
def add_primary_contact_from_ocr(cursor, company_id, person):
|
| 315 |
+
first_name = clean_profile_value(person.get("first_name"))
|
| 316 |
+
last_name = clean_profile_value(person.get("last_name"))
|
| 317 |
+
if not first_name and not last_name:
|
| 318 |
+
return None
|
| 319 |
+
if not last_name:
|
| 320 |
+
last_name = "-"
|
| 321 |
+
social = person.get("social") if isinstance(person.get("social"), dict) else {}
|
| 322 |
+
cursor.execute("UPDATE contacts SET is_primary = 0 WHERE company_id = ?", (company_id,))
|
| 323 |
+
return insert_and_get_id(cursor, """
|
| 324 |
+
INSERT INTO contacts (
|
| 325 |
+
company_id, first_name, last_name, title, email, phone, linkedin, is_primary
|
| 326 |
+
) VALUES (?, ?, ?, ?, ?, ?, ?, 1)
|
| 327 |
+
""", (
|
| 328 |
+
company_id,
|
| 329 |
+
first_name,
|
| 330 |
+
last_name,
|
| 331 |
+
clean_profile_value(person.get("job_title")),
|
| 332 |
+
clean_profile_value(person.get("email")),
|
| 333 |
+
phone_from_ocr(person.get("phone")),
|
| 334 |
+
clean_profile_value(social.get("linkedin")),
|
| 335 |
+
))
|
| 336 |
+
|
| 337 |
+
@companies_bp.route("", methods=["GET"])
|
| 338 |
+
def get_companies():
|
| 339 |
+
user_id, role, _ = get_request_user()
|
| 340 |
+
conn = get_db_connection()
|
| 341 |
+
cursor = conn.cursor()
|
| 342 |
+
if role in ('Super Admin', 'Admin', 'Viewer'):
|
| 343 |
+
cursor.execute("""
|
| 344 |
+
SELECT c.*, s.name as stage_name, s.color_bg as stage_color_bg, s.color_text as stage_color_text, u.name as rep_name
|
| 345 |
+
FROM companies c
|
| 346 |
+
JOIN stages s ON c.stage_id = s.id
|
| 347 |
+
LEFT JOIN users u ON c.assigned_rep_id = u.id
|
| 348 |
+
ORDER BY c.maplempss_fit_score DESC, c.name ASC
|
| 349 |
+
""")
|
| 350 |
+
else:
|
| 351 |
+
cursor.execute("""
|
| 352 |
+
SELECT
|
| 353 |
+
c.id, c.tenant_id, c.name, c.legal_name, c.industry, c.street, c.city, c.province, c.postal_code, c.country,
|
| 354 |
+
c.business_type, c.stage_id, c.assigned_rep_id, c.created_by, c.updated_by, c.import_batch_id, c.created_at, c.updated_at,
|
| 355 |
+
s.name as stage_name, s.color_bg as stage_color_bg, s.color_text as stage_color_text, u.name as rep_name,
|
| 356 |
+
CASE WHEN c.assigned_rep_id = ? THEN c.website ELSE '[Restricted]' END as website,
|
| 357 |
+
CASE WHEN c.assigned_rep_id = ? THEN c.phone ELSE '[Restricted]' END as phone,
|
| 358 |
+
CASE WHEN c.assigned_rep_id = ? THEN c.size_staff ELSE NULL END as size_staff,
|
| 359 |
+
CASE WHEN c.assigned_rep_id = ? THEN c.revenue_band ELSE '[Restricted]' END as revenue_band,
|
| 360 |
+
CASE WHEN c.assigned_rep_id = ? THEN c.products_made ELSE '[Restricted]' END as products_made,
|
| 361 |
+
CASE WHEN c.assigned_rep_id = ? THEN c.parts_sourced ELSE '[Restricted]' END as parts_sourced,
|
| 362 |
+
CASE WHEN c.assigned_rep_id = ? THEN c.current_supplier_notes ELSE '[Restricted]' END as current_supplier_notes,
|
| 363 |
+
CASE WHEN c.assigned_rep_id = ? THEN c.maplempss_fit_score ELSE NULL END as maplempss_fit_score
|
| 364 |
+
FROM companies c
|
| 365 |
+
JOIN stages s ON c.stage_id = s.id
|
| 366 |
+
LEFT JOIN users u ON c.assigned_rep_id = u.id
|
| 367 |
+
ORDER BY c.maplempss_fit_score DESC, c.name ASC
|
| 368 |
+
""", (user_id, user_id, user_id, user_id, user_id, user_id, user_id, user_id))
|
| 369 |
+
rows = cursor.fetchall()
|
| 370 |
+
companies = [dict_from_row(row) for row in rows]
|
| 371 |
+
conn.close()
|
| 372 |
+
return jsonify(companies)
|
| 373 |
+
|
| 374 |
+
@companies_bp.route("", methods=["POST"])
|
| 375 |
+
def add_company():
|
| 376 |
+
data = request.json
|
| 377 |
+
if not data or not data.get("name"):
|
| 378 |
+
return jsonify({"error": "Company name is required"}), 400
|
| 379 |
+
|
| 380 |
+
user_id, role, _ = get_request_user()
|
| 381 |
+
tenant_id = get_request_tenant_id() or 1
|
| 382 |
+
if role == 'Viewer':
|
| 383 |
+
return jsonify({"error": "Viewers are not authorized to create companies."}), 403
|
| 384 |
+
|
| 385 |
+
conn = get_db_connection()
|
| 386 |
+
cursor = conn.cursor()
|
| 387 |
+
try:
|
| 388 |
+
assigned_rep = int(data.get("assigned_rep_id", user_id)) if data.get("assigned_rep_id") else user_id
|
| 389 |
+
notes_text = clean_profile_value(data.get("current_supplier_notes", ""))
|
| 390 |
+
if not notes_text or notes_text == "N/A":
|
| 391 |
+
notes_text = clean_profile_value(data.get("notes", ""))
|
| 392 |
+
contacts_list = data.get("contacts", []) if isinstance(data.get("contacts"), list) else []
|
| 393 |
+
if (not notes_text or notes_text == "N/A") and contacts_list:
|
| 394 |
+
contact_strs = []
|
| 395 |
+
for c in contacts_list:
|
| 396 |
+
if isinstance(c, dict) and (c.get("first_name") or c.get("last_name") or c.get("title")):
|
| 397 |
+
contact_strs.append(f"{c.get('title', 'Contact')} is {clean_profile_value(c.get('first_name', ''))} {clean_profile_value(c.get('last_name', ''))}".strip())
|
| 398 |
+
if contact_strs:
|
| 399 |
+
notes_text = "Leadership / Contacts: " + ", ".join(contact_strs)
|
| 400 |
+
if not notes_text or notes_text == "N/A":
|
| 401 |
+
notes_text = ""
|
| 402 |
+
|
| 403 |
+
company_id = insert_and_get_id(cursor, """
|
| 404 |
+
INSERT INTO companies (
|
| 405 |
+
tenant_id, name, legal_name, website, phone, nic_code, industry,
|
| 406 |
+
street, city, province, postal_code, country, size_staff, revenue_band,
|
| 407 |
+
business_type, products_made, parts_sourced, current_supplier_notes,
|
| 408 |
+
maplempss_fit_score, stage_id, assigned_rep_id
|
| 409 |
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
| 410 |
+
""", (
|
| 411 |
+
tenant_id,
|
| 412 |
+
data.get("name"),
|
| 413 |
+
data.get("legal_name", data.get("name")),
|
| 414 |
+
data.get("website", ""),
|
| 415 |
+
data.get("phone", ""),
|
| 416 |
+
data.get("nic_code", ""),
|
| 417 |
+
data.get("industry", ""),
|
| 418 |
+
data.get("street", ""),
|
| 419 |
+
data.get("city", ""),
|
| 420 |
+
data.get("province", ""),
|
| 421 |
+
data.get("postal_code", ""),
|
| 422 |
+
data.get("country", "Canada"),
|
| 423 |
+
extract_int(data.get("size_staff"), 0),
|
| 424 |
+
data.get("revenue_band", "<1M"),
|
| 425 |
+
data.get("business_type", "OEM"),
|
| 426 |
+
data.get("products_made", ""),
|
| 427 |
+
data.get("parts_sourced", ""),
|
| 428 |
+
notes_text,
|
| 429 |
+
extract_float(data.get("maplempss_fit_score"), 0.0),
|
| 430 |
+
safe_int(data.get("stage_id"), 1),
|
| 431 |
+
assigned_rep
|
| 432 |
+
))
|
| 433 |
+
conn.commit()
|
| 434 |
+
|
| 435 |
+
log_stage_change(cursor, company_id, safe_int(data.get("stage_id"), 1), user_id, "manual")
|
| 436 |
+
|
| 437 |
+
contacts_to_add = data.get("contacts", []) if isinstance(data.get("contacts"), list) else []
|
| 438 |
+
if not contacts_to_add and data.get("current_supplier_notes"):
|
| 439 |
+
contacts_to_add = extract_contacts_from_descriptive_notes(data.get("current_supplier_notes"))
|
| 440 |
+
if not contacts_to_add and data.get("notes"):
|
| 441 |
+
contacts_to_add = extract_contacts_from_descriptive_notes(data.get("notes"))
|
| 442 |
+
|
| 443 |
+
for idx_c, contact in enumerate(contacts_to_add):
|
| 444 |
+
if isinstance(contact, dict) and (contact.get("first_name") or contact.get("last_name") or contact.get("name")):
|
| 445 |
+
fn = clean_profile_value(contact.get("first_name"))
|
| 446 |
+
ln = clean_profile_value(contact.get("last_name"))
|
| 447 |
+
if not fn and not ln:
|
| 448 |
+
fn, ln = split_name(clean_profile_value(contact.get("name")))
|
| 449 |
+
if not ln:
|
| 450 |
+
ln = "-"
|
| 451 |
+
is_prim = 1 if (contact.get("is_primary") or idx_c == 0) else 0
|
| 452 |
+
if is_prim == 1 and idx_c > 0:
|
| 453 |
+
cursor.execute("UPDATE contacts SET is_primary = 0 WHERE company_id = ?", (company_id,))
|
| 454 |
+
cursor.execute("""
|
| 455 |
+
INSERT INTO contacts (
|
| 456 |
+
company_id, first_name, last_name, title, email, phone, linkedin, is_primary
|
| 457 |
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
| 458 |
+
""", (
|
| 459 |
+
company_id, fn, ln,
|
| 460 |
+
clean_profile_value(contact.get("title", "")),
|
| 461 |
+
clean_profile_value(contact.get("email", "")),
|
| 462 |
+
clean_profile_value(contact.get("phone", "")),
|
| 463 |
+
clean_profile_value(contact.get("linkedin", "")),
|
| 464 |
+
is_prim
|
| 465 |
+
))
|
| 466 |
+
|
| 467 |
+
if notes_text and notes_text != "N/A":
|
| 468 |
+
cursor.execute("""
|
| 469 |
+
INSERT INTO activities (company_id, user_id, type, outcome, notes, next_action, next_action_date)
|
| 470 |
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
| 471 |
+
""", (company_id, user_id, "Note", "Logged during onboarding", notes_text, "", None))
|
| 472 |
+
cursor.execute("""
|
| 473 |
+
INSERT INTO notes (company_id, user_id, body, is_pinned)
|
| 474 |
+
VALUES (?, ?, ?, ?)
|
| 475 |
+
""", (company_id, user_id, notes_text, 0))
|
| 476 |
+
|
| 477 |
+
conn.commit()
|
| 478 |
+
conn.close()
|
| 479 |
+
return jsonify({"success": True, "id": company_id})
|
| 480 |
+
except Exception as e:
|
| 481 |
+
conn.close()
|
| 482 |
+
return jsonify({"error": str(e)}), 500
|
| 483 |
+
|
| 484 |
+
@companies_bp.route("/from-extractor", methods=["POST"])
|
| 485 |
+
def add_company_from_extractor():
|
| 486 |
+
user_id, role, _ = get_request_user()
|
| 487 |
+
tenant_id = get_request_tenant_id() or 1
|
| 488 |
+
if role == 'Viewer':
|
| 489 |
+
return jsonify({"error": "Viewers are not authorized to import profiles."}), 403
|
| 490 |
+
|
| 491 |
+
data = request.json or {}
|
| 492 |
+
profile_text = str(data.get("profile_text") or "").strip()
|
| 493 |
+
profile_path = str(data.get("profile_path") or "").strip()
|
| 494 |
+
if not profile_text:
|
| 495 |
+
return jsonify({"error": "Company Research profile text is required"}), 400
|
| 496 |
+
|
| 497 |
+
company = build_company_from_extractor(profile_text)
|
| 498 |
+
if not company.get("name"):
|
| 499 |
+
return jsonify({"error": "Could not find a company name in the extractor profile"}), 400
|
| 500 |
+
|
| 501 |
+
contacts = parse_decision_makers(profile_text)
|
| 502 |
+
conn = get_db_connection()
|
| 503 |
+
cursor = conn.cursor()
|
| 504 |
+
try:
|
| 505 |
+
stage_id = ensure_extractor_stage(cursor)
|
| 506 |
+
company_id = insert_and_get_id(cursor, """
|
| 507 |
+
INSERT INTO companies (
|
| 508 |
+
tenant_id, name, legal_name, website, phone, nic_code, industry,
|
| 509 |
+
street, city, province, postal_code, country, size_staff, revenue_band,
|
| 510 |
+
business_type, products_made, parts_sourced, current_supplier_notes,
|
| 511 |
+
maplempss_fit_score, stage_id, assigned_rep_id, created_by, updated_by
|
| 512 |
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
| 513 |
+
""", (
|
| 514 |
+
tenant_id,
|
| 515 |
+
company.get("name"),
|
| 516 |
+
company.get("legal_name") or company.get("name"),
|
| 517 |
+
company.get("website", ""),
|
| 518 |
+
company.get("phone", ""),
|
| 519 |
+
"",
|
| 520 |
+
company.get("industry", ""),
|
| 521 |
+
company.get("street", ""),
|
| 522 |
+
company.get("city", ""),
|
| 523 |
+
company.get("province", ""),
|
| 524 |
+
company.get("postal_code", ""),
|
| 525 |
+
company.get("country", "India"),
|
| 526 |
+
extract_int(company.get("size_staff"), 0),
|
| 527 |
+
company.get("revenue_band", "<1M"),
|
| 528 |
+
company.get("business_type", "other"),
|
| 529 |
+
company.get("products_made", ""),
|
| 530 |
+
company.get("parts_sourced", ""),
|
| 531 |
+
company.get("current_supplier_notes", ""),
|
| 532 |
+
extract_float(company.get("maplempss_fit_score"), 0.0),
|
| 533 |
+
stage_id,
|
| 534 |
+
user_id,
|
| 535 |
+
user_id,
|
| 536 |
+
user_id,
|
| 537 |
+
))
|
| 538 |
+
log_stage_change(cursor, company_id, stage_id, user_id, "ai_confirmed")
|
| 539 |
+
|
| 540 |
+
for contact in contacts:
|
| 541 |
+
if contact.get("is_primary"):
|
| 542 |
+
cursor.execute("UPDATE contacts SET is_primary = 0 WHERE company_id = ?", (company_id,))
|
| 543 |
+
cursor.execute("""
|
| 544 |
+
INSERT INTO contacts (
|
| 545 |
+
company_id, first_name, last_name, title, email, phone, linkedin, is_primary
|
| 546 |
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
| 547 |
+
""", (
|
| 548 |
+
company_id,
|
| 549 |
+
contact["first_name"],
|
| 550 |
+
contact["last_name"],
|
| 551 |
+
contact.get("title", ""),
|
| 552 |
+
contact.get("email", ""),
|
| 553 |
+
contact.get("phone", ""),
|
| 554 |
+
contact.get("linkedin", ""),
|
| 555 |
+
1 if contact.get("is_primary") else 0,
|
| 556 |
+
))
|
| 557 |
+
|
| 558 |
+
note_body = "Imported from Company Extractor."
|
| 559 |
+
if profile_path:
|
| 560 |
+
note_body += f"\nSource profile: {profile_path}"
|
| 561 |
+
note_body += "\n\n" + profile_text[:12000]
|
| 562 |
+
cursor.execute("""
|
| 563 |
+
INSERT INTO notes (company_id, user_id, body, is_pinned)
|
| 564 |
+
VALUES (?, ?, ?, 1)
|
| 565 |
+
""", (company_id, user_id, note_body))
|
| 566 |
+
store_extractor_ai_fields(cursor, company_id, profile_text)
|
| 567 |
+
conn.commit()
|
| 568 |
+
conn.close()
|
| 569 |
+
return jsonify({
|
| 570 |
+
"success": True,
|
| 571 |
+
"company_id": company_id,
|
| 572 |
+
"contacts_added": len(contacts),
|
| 573 |
+
"stage": "From Company Extractor",
|
| 574 |
+
})
|
| 575 |
+
except Exception as e:
|
| 576 |
+
conn.rollback()
|
| 577 |
+
conn.close()
|
| 578 |
+
return jsonify({"error": str(e)}), 500
|
| 579 |
+
|
| 580 |
+
@companies_bp.route("/from-ocr", methods=["POST"])
|
| 581 |
+
def add_company_from_ocr():
|
| 582 |
+
user_id, role, _ = get_request_user()
|
| 583 |
+
tenant_id = get_request_tenant_id() or 1
|
| 584 |
+
if role == 'Viewer':
|
| 585 |
+
return jsonify({"error": "Viewers are not authorized to import Business Card Parser cards."}), 403
|
| 586 |
+
|
| 587 |
+
data = request.json or {}
|
| 588 |
+
card = data.get("card") if isinstance(data.get("card"), dict) else {}
|
| 589 |
+
company_data = card.get("company") if isinstance(card.get("company"), dict) else {}
|
| 590 |
+
person = card.get("person") if isinstance(card.get("person"), dict) else {}
|
| 591 |
+
address = company_data.get("address") if isinstance(company_data.get("address"), dict) else {}
|
| 592 |
+
|
| 593 |
+
if not has_meaningful_ocr_company_data(company_data, address):
|
| 594 |
+
return jsonify({
|
| 595 |
+
"error": "Business Card Parser did not find enough company information to add this to CRM. Please enter at least a Company Name, Website, or Location before adding."
|
| 596 |
+
}), 400
|
| 597 |
+
|
| 598 |
+
company_name = clean_profile_value(company_data.get("company_name"))
|
| 599 |
+
if not company_name:
|
| 600 |
+
full_name = " ".join(part for part in [
|
| 601 |
+
clean_profile_value(person.get("first_name")),
|
| 602 |
+
clean_profile_value(person.get("last_name")),
|
| 603 |
+
] if part)
|
| 604 |
+
company_name = full_name or clean_profile_value(data.get("title")) or "Business Card Imported Company"
|
| 605 |
+
|
| 606 |
+
conn = get_db_connection()
|
| 607 |
+
cursor = conn.cursor()
|
| 608 |
+
try:
|
| 609 |
+
stage_id = get_stage_id_by_name(cursor, "New") or 1
|
| 610 |
+
company_id = insert_and_get_id(cursor, """
|
| 611 |
+
INSERT INTO companies (
|
| 612 |
+
tenant_id, name, legal_name, website, phone, nic_code, industry,
|
| 613 |
+
street, city, province, postal_code, country, size_staff, revenue_band,
|
| 614 |
+
business_type, products_made, parts_sourced, current_supplier_notes,
|
| 615 |
+
maplempss_fit_score, stage_id, assigned_rep_id, created_by, updated_by
|
| 616 |
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
| 617 |
+
""", (
|
| 618 |
+
tenant_id,
|
| 619 |
+
company_name,
|
| 620 |
+
company_name,
|
| 621 |
+
normalize_website(company_data.get("website")),
|
| 622 |
+
phone_from_ocr(person.get("phone")),
|
| 623 |
+
"",
|
| 624 |
+
"",
|
| 625 |
+
clean_profile_value(address.get("street")),
|
| 626 |
+
clean_profile_value(address.get("city")),
|
| 627 |
+
clean_profile_value(address.get("state_province") or address.get("state")),
|
| 628 |
+
clean_profile_value(address.get("postal_code")),
|
| 629 |
+
clean_profile_value(address.get("country")) or "USA",
|
| 630 |
+
0,
|
| 631 |
+
"<1M",
|
| 632 |
+
"other",
|
| 633 |
+
"",
|
| 634 |
+
"",
|
| 635 |
+
"Imported from Business Card Parser.\n\n" + clean_profile_value(address.get("full_address")),
|
| 636 |
+
extract_float(card.get("metadata", {}).get("confidence_score") if isinstance(card.get("metadata"), dict) else 0.0),
|
| 637 |
+
stage_id,
|
| 638 |
+
user_id,
|
| 639 |
+
user_id,
|
| 640 |
+
user_id,
|
| 641 |
+
))
|
| 642 |
+
log_stage_change(cursor, company_id, stage_id, user_id, "ai_confirmed")
|
| 643 |
+
|
| 644 |
+
contact_id = add_primary_contact_from_ocr(cursor, company_id, person)
|
| 645 |
+
cursor.execute("""
|
| 646 |
+
INSERT INTO notes (company_id, user_id, body, is_pinned)
|
| 647 |
+
VALUES (?, ?, ?, 1)
|
| 648 |
+
""", (company_id, user_id, "Imported from Business Card Parser.\n\n" + str(card)[:12000]))
|
| 649 |
+
conn.commit()
|
| 650 |
+
conn.close()
|
| 651 |
+
return jsonify({
|
| 652 |
+
"success": True,
|
| 653 |
+
"company_id": company_id,
|
| 654 |
+
"contact_id": contact_id,
|
| 655 |
+
"stage": "New",
|
| 656 |
+
})
|
| 657 |
+
except Exception as e:
|
| 658 |
+
conn.rollback()
|
| 659 |
+
conn.close()
|
| 660 |
+
return jsonify({"error": str(e)}), 500
|
| 661 |
+
|
| 662 |
+
@companies_bp.route("/<int:company_id>", methods=["GET"])
|
| 663 |
+
def get_company_details(company_id):
|
| 664 |
+
user_id, role, _ = get_request_user()
|
| 665 |
+
conn = get_db_connection()
|
| 666 |
+
level = get_user_access_level(conn, user_id, role, company_id)
|
| 667 |
+
|
| 668 |
+
if level == 'none':
|
| 669 |
+
conn.close()
|
| 670 |
+
return jsonify({"error": "Company not found"}), 404
|
| 671 |
+
|
| 672 |
+
cursor = conn.cursor()
|
| 673 |
+
cursor.execute("""
|
| 674 |
+
SELECT c.*, s.name as stage_name, s.color_bg as stage_color_bg, s.color_text as stage_color_text
|
| 675 |
+
FROM companies c
|
| 676 |
+
JOIN stages s ON c.stage_id = s.id
|
| 677 |
+
WHERE c.id = ?
|
| 678 |
+
""", (company_id,))
|
| 679 |
+
comp_row = cursor.fetchone()
|
| 680 |
+
if not comp_row:
|
| 681 |
+
conn.close()
|
| 682 |
+
return jsonify({"error": "Company not found"}), 404
|
| 683 |
+
company = dict_from_row(comp_row)
|
| 684 |
+
|
| 685 |
+
if level == 'basic':
|
| 686 |
+
redacted_company = {
|
| 687 |
+
"id": company["id"],
|
| 688 |
+
"tenant_id": company["tenant_id"],
|
| 689 |
+
"name": company["name"],
|
| 690 |
+
"legal_name": company["name"],
|
| 691 |
+
"website": "[Restricted]",
|
| 692 |
+
"phone": "[Restricted]",
|
| 693 |
+
"nic_code": "",
|
| 694 |
+
"industry": company["industry"],
|
| 695 |
+
"street": "",
|
| 696 |
+
"city": company["city"],
|
| 697 |
+
"province": company["province"],
|
| 698 |
+
"postal_code": "",
|
| 699 |
+
"country": company["country"],
|
| 700 |
+
"size_staff": None,
|
| 701 |
+
"revenue_band": "[Restricted]",
|
| 702 |
+
"business_type": "other",
|
| 703 |
+
"products_made": "[Restricted]",
|
| 704 |
+
"parts_sourced": "[Restricted]",
|
| 705 |
+
"current_supplier_notes": "[Restricted]",
|
| 706 |
+
"maplempss_fit_score": None,
|
| 707 |
+
"stage_id": company["stage_id"],
|
| 708 |
+
"assigned_rep_id": company["assigned_rep_id"],
|
| 709 |
+
"stage_name": company["stage_name"],
|
| 710 |
+
"stage_color_bg": company["stage_color_bg"],
|
| 711 |
+
"stage_color_text": company["stage_color_text"],
|
| 712 |
+
}
|
| 713 |
+
conn.close()
|
| 714 |
+
return jsonify({
|
| 715 |
+
"company": redacted_company,
|
| 716 |
+
"contacts": [],
|
| 717 |
+
"activities": [],
|
| 718 |
+
"notes": [],
|
| 719 |
+
"ai_fields": [],
|
| 720 |
+
"read_only": True,
|
| 721 |
+
"restricted": True
|
| 722 |
+
})
|
| 723 |
+
|
| 724 |
+
# Load remaining fields sequentially on the same connection
|
| 725 |
+
cursor.execute("SELECT * FROM contacts WHERE company_id = ? ORDER BY is_primary DESC", (company_id,))
|
| 726 |
+
contacts = [dict_from_row(r) for r in cursor.fetchall()]
|
| 727 |
+
|
| 728 |
+
cursor.execute("""
|
| 729 |
+
SELECT a.*, u.name as user_name
|
| 730 |
+
FROM activities a
|
| 731 |
+
LEFT JOIN users u ON a.user_id = u.id
|
| 732 |
+
WHERE a.company_id = ?
|
| 733 |
+
ORDER BY a.occurred_at DESC
|
| 734 |
+
""", (company_id,))
|
| 735 |
+
activities = [dict_from_row(r) for r in cursor.fetchall()]
|
| 736 |
+
|
| 737 |
+
cursor.execute("""
|
| 738 |
+
SELECT n.*, u.name as user_name
|
| 739 |
+
FROM notes n
|
| 740 |
+
LEFT JOIN users u ON n.user_id = u.id
|
| 741 |
+
WHERE n.company_id = ?
|
| 742 |
+
ORDER BY n.is_pinned DESC, n.created_at DESC
|
| 743 |
+
""", (company_id,))
|
| 744 |
+
notes = [dict_from_row(r) for r in cursor.fetchall()]
|
| 745 |
+
|
| 746 |
+
cursor.execute("SELECT * FROM ai_field_values WHERE company_id = ?", (company_id,))
|
| 747 |
+
ai_fields = [dict_from_row(r) for r in cursor.fetchall()]
|
| 748 |
+
conn.close()
|
| 749 |
+
|
| 750 |
+
return jsonify({
|
| 751 |
+
"company": company,
|
| 752 |
+
"contacts": contacts,
|
| 753 |
+
"activities": activities,
|
| 754 |
+
"notes": notes,
|
| 755 |
+
"ai_fields": ai_fields,
|
| 756 |
+
"read_only": (level == 'viewer'),
|
| 757 |
+
"restricted": False
|
| 758 |
+
})
|
| 759 |
+
|
| 760 |
+
@companies_bp.route("/<int:company_id>", methods=["POST"])
|
| 761 |
+
def update_company(company_id):
|
| 762 |
+
data = request.json
|
| 763 |
+
if not data or not data.get("name"):
|
| 764 |
+
return jsonify({"error": "Company name is required"}), 400
|
| 765 |
+
|
| 766 |
+
user_id, role, _ = get_request_user()
|
| 767 |
+
conn = get_db_connection()
|
| 768 |
+
level = get_user_access_level(conn, user_id, role, company_id)
|
| 769 |
+
if level in ('basic', 'viewer'):
|
| 770 |
+
conn.close()
|
| 771 |
+
return jsonify({"error": "You are not authorized to edit this company."}), 403
|
| 772 |
+
|
| 773 |
+
cursor = conn.cursor()
|
| 774 |
+
try:
|
| 775 |
+
cursor.execute("SELECT stage_id FROM companies WHERE id = ?", (company_id,))
|
| 776 |
+
row = cursor.fetchone()
|
| 777 |
+
if not row:
|
| 778 |
+
conn.close()
|
| 779 |
+
return jsonify({"error": "Company not found"}), 404
|
| 780 |
+
old_stage_id = row[0]
|
| 781 |
+
new_stage_id = safe_int(data.get("stage_id"), old_stage_id)
|
| 782 |
+
|
| 783 |
+
cursor.execute("""
|
| 784 |
+
UPDATE companies SET
|
| 785 |
+
name = ?, legal_name = ?, website = ?, phone = ?, nic_code = ?, industry = ?,
|
| 786 |
+
street = ?, city = ?, province = ?, postal_code = ?, country = ?, size_staff = ?,
|
| 787 |
+
revenue_band = ?, business_type = ?, products_made = ?, parts_sourced = ?,
|
| 788 |
+
current_supplier_notes = ?, maplempss_fit_score = ?, stage_id = ?,
|
| 789 |
+
updated_at = CURRENT_TIMESTAMP
|
| 790 |
+
WHERE id = ?
|
| 791 |
+
""", (
|
| 792 |
+
data.get("name"), data.get("legal_name", data.get("name")), data.get("website", ""),
|
| 793 |
+
data.get("phone", ""), data.get("nic_code", ""), data.get("industry", ""),
|
| 794 |
+
data.get("street", ""), data.get("city", ""), data.get("province", ""),
|
| 795 |
+
data.get("postal_code", ""), data.get("country", "Canada"),
|
| 796 |
+
extract_int(data.get("size_staff"), 0), data.get("revenue_band", "<1M"),
|
| 797 |
+
data.get("business_type", "OEM"), data.get("products_made", ""),
|
| 798 |
+
data.get("parts_sourced", ""), data.get("current_supplier_notes", ""),
|
| 799 |
+
extract_float(data.get("maplempss_fit_score"), 0.0), new_stage_id, company_id
|
| 800 |
+
))
|
| 801 |
+
|
| 802 |
+
if old_stage_id != new_stage_id:
|
| 803 |
+
log_stage_change(cursor, company_id, new_stage_id, user_id, "manual", old_stage_id)
|
| 804 |
+
|
| 805 |
+
conn.commit()
|
| 806 |
+
conn.close()
|
| 807 |
+
return jsonify({"success": True})
|
| 808 |
+
except Exception as e:
|
| 809 |
+
conn.close()
|
| 810 |
+
return jsonify({"error": str(e)}), 500
|
| 811 |
+
|
| 812 |
+
@companies_bp.route("/<int:company_id>/stage", methods=["POST"])
|
| 813 |
+
def update_company_stage(company_id):
|
| 814 |
+
data = request.json
|
| 815 |
+
if not data or not data.get("stage_id"):
|
| 816 |
+
return jsonify({"error": "Stage ID is required"}), 400
|
| 817 |
+
|
| 818 |
+
new_stage_id = safe_int(data.get("stage_id"), None)
|
| 819 |
+
if new_stage_id is None:
|
| 820 |
+
return jsonify({"error": "Stage ID is required"}), 400
|
| 821 |
+
user_id, role, _ = get_request_user()
|
| 822 |
+
conn = get_db_connection()
|
| 823 |
+
level = get_user_access_level(conn, user_id, role, company_id)
|
| 824 |
+
if level in ('basic', 'viewer'):
|
| 825 |
+
conn.close()
|
| 826 |
+
return jsonify({"error": "You are not authorized to update this company's stage."}), 403
|
| 827 |
+
|
| 828 |
+
cursor = conn.cursor()
|
| 829 |
+
cursor.execute("SELECT stage_id FROM companies WHERE id = ?", (company_id,))
|
| 830 |
+
row = cursor.fetchone()
|
| 831 |
+
if not row:
|
| 832 |
+
conn.close()
|
| 833 |
+
return jsonify({"error": "Company not found"}), 404
|
| 834 |
+
old_stage_id = row[0]
|
| 835 |
+
|
| 836 |
+
cursor.execute("UPDATE companies SET stage_id = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", (new_stage_id, company_id))
|
| 837 |
+
|
| 838 |
+
log_stage_change(cursor, company_id, new_stage_id, user_id, "manual", old_stage_id)
|
| 839 |
+
|
| 840 |
+
conn.commit()
|
| 841 |
+
conn.close()
|
| 842 |
+
return jsonify({"success": True})
|
| 843 |
+
|
| 844 |
+
@companies_bp.route("/<int:company_id>/notes", methods=["POST"])
|
| 845 |
+
def add_company_note(company_id):
|
| 846 |
+
data = request.json
|
| 847 |
+
if not data or not data.get("body"):
|
| 848 |
+
return jsonify({"error": "Note text is required"}), 400
|
| 849 |
+
|
| 850 |
+
user_id, role, _ = get_request_user()
|
| 851 |
+
conn = get_db_connection()
|
| 852 |
+
level = get_user_access_level(conn, user_id, role, company_id)
|
| 853 |
+
if level in ('basic', 'viewer'):
|
| 854 |
+
conn.close()
|
| 855 |
+
return jsonify({"error": "You are not authorized to add notes for this company."}), 403
|
| 856 |
+
|
| 857 |
+
cursor = conn.cursor()
|
| 858 |
+
cursor.execute("""
|
| 859 |
+
INSERT INTO notes (company_id, user_id, body, is_pinned)
|
| 860 |
+
VALUES (?, ?, ?, ?)
|
| 861 |
+
""", (company_id, user_id, data["body"], 1 if data.get("is_pinned") else 0))
|
| 862 |
+
conn.commit()
|
| 863 |
+
conn.close()
|
| 864 |
+
return jsonify({"success": True})
|
| 865 |
+
|
| 866 |
+
@companies_bp.route("/<int:company_id>/activities", methods=["POST"])
|
| 867 |
+
def add_company_activity(company_id):
|
| 868 |
+
data = request.json
|
| 869 |
+
if not data or not data.get("type"):
|
| 870 |
+
return jsonify({"error": "Activity type is required"}), 400
|
| 871 |
+
|
| 872 |
+
user_id, role, _ = get_request_user()
|
| 873 |
+
conn = get_db_connection()
|
| 874 |
+
level = get_user_access_level(conn, user_id, role, company_id)
|
| 875 |
+
if level in ('basic', 'viewer'):
|
| 876 |
+
conn.close()
|
| 877 |
+
return jsonify({"error": "You are not authorized to log activities for this company."}), 403
|
| 878 |
+
|
| 879 |
+
cursor = conn.cursor()
|
| 880 |
+
cursor.execute("""
|
| 881 |
+
INSERT INTO activities (company_id, user_id, type, outcome, notes, next_action, next_action_date)
|
| 882 |
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
| 883 |
+
""", (
|
| 884 |
+
company_id, user_id, data["type"], data.get("outcome", ""), data.get("notes", ""),
|
| 885 |
+
data.get("next_action", ""), data.get("next_action_date", None)
|
| 886 |
+
))
|
| 887 |
+
conn.commit()
|
| 888 |
+
conn.close()
|
| 889 |
+
return jsonify({"success": True})
|
| 890 |
+
|
| 891 |
+
@companies_bp.route("/delete", methods=["POST"])
|
| 892 |
+
def delete_companies():
|
| 893 |
+
data = request.json
|
| 894 |
+
if not data or not data.get("ids"):
|
| 895 |
+
return jsonify({"error": "No IDs provided"}), 400
|
| 896 |
+
ids = [int(x) for x in data["ids"]]
|
| 897 |
+
user_id, role, _ = get_request_user()
|
| 898 |
+
conn = get_db_connection()
|
| 899 |
+
for company_id in ids:
|
| 900 |
+
level = get_user_access_level(conn, user_id, role, company_id)
|
| 901 |
+
if level in ('basic', 'viewer'):
|
| 902 |
+
conn.close()
|
| 903 |
+
return jsonify({"error": f"You are not authorized to delete company ID {company_id}."}), 403
|
| 904 |
+
|
| 905 |
+
cursor = conn.cursor()
|
| 906 |
+
try:
|
| 907 |
+
placeholders = ",".join("?" for _ in ids)
|
| 908 |
+
cursor.execute(f"DELETE FROM companies WHERE id IN ({placeholders})", ids)
|
| 909 |
+
conn.commit()
|
| 910 |
+
conn.close()
|
| 911 |
+
return jsonify({"success": True})
|
| 912 |
+
except Exception as e:
|
| 913 |
+
conn.close()
|
| 914 |
+
return jsonify({"error": str(e)}), 500
|
| 915 |
+
|
| 916 |
+
@companies_bp.route("/assign", methods=["POST"])
|
| 917 |
+
def assign_companies():
|
| 918 |
+
data = request.json
|
| 919 |
+
if not data or not data.get("ids"):
|
| 920 |
+
return jsonify({"error": "No IDs provided"}), 400
|
| 921 |
+
ids = [int(x) for x in data["ids"]]
|
| 922 |
+
assigned_rep_id = data.get("assigned_rep_id")
|
| 923 |
+
if assigned_rep_id is not None and assigned_rep_id != "":
|
| 924 |
+
assigned_rep_id = int(assigned_rep_id)
|
| 925 |
+
else:
|
| 926 |
+
assigned_rep_id = None
|
| 927 |
+
|
| 928 |
+
user_id, role, _ = get_request_user()
|
| 929 |
+
if role not in ("Super Admin", "Admin"):
|
| 930 |
+
return jsonify({"error": "Only Admins can assign companies to representatives."}), 403
|
| 931 |
+
|
| 932 |
+
conn = get_db_connection()
|
| 933 |
+
cursor = conn.cursor()
|
| 934 |
+
try:
|
| 935 |
+
placeholders = ",".join("?" for _ in ids)
|
| 936 |
+
cursor.execute(f"UPDATE companies SET assigned_rep_id = ? WHERE id IN ({placeholders})", [assigned_rep_id] + ids)
|
| 937 |
+
conn.commit()
|
| 938 |
+
conn.close()
|
| 939 |
+
return jsonify({"success": True})
|
| 940 |
+
except Exception as e:
|
| 941 |
+
conn.close()
|
| 942 |
+
return jsonify({"error": str(e)}), 500
|
routes/contacts.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from flask import Blueprint, jsonify, request
|
| 2 |
+
from db import get_db_connection, dict_from_row, get_request_user, get_user_access_level, is_supabase_active
|
| 3 |
+
|
| 4 |
+
contacts_bp = Blueprint('contacts', __name__)
|
| 5 |
+
|
| 6 |
+
def insert_and_get_id(cursor, sql, params):
|
| 7 |
+
cursor.execute(sql, params)
|
| 8 |
+
return cursor.lastrowid
|
| 9 |
+
|
| 10 |
+
@contacts_bp.route("", methods=["GET"])
|
| 11 |
+
def get_contacts():
|
| 12 |
+
user_id, role, _ = get_request_user()
|
| 13 |
+
conn = get_db_connection()
|
| 14 |
+
cursor = conn.cursor()
|
| 15 |
+
if role in ('Super Admin', 'Admin', 'Viewer'):
|
| 16 |
+
cursor.execute("""
|
| 17 |
+
SELECT co.*, cp.name as company_name
|
| 18 |
+
FROM contacts co
|
| 19 |
+
JOIN companies cp ON co.company_id = cp.id
|
| 20 |
+
ORDER BY co.first_name ASC
|
| 21 |
+
""")
|
| 22 |
+
else:
|
| 23 |
+
cursor.execute("""
|
| 24 |
+
SELECT co.*, cp.name as company_name
|
| 25 |
+
FROM contacts co
|
| 26 |
+
JOIN companies cp ON co.company_id = cp.id
|
| 27 |
+
WHERE cp.assigned_rep_id = ?
|
| 28 |
+
ORDER BY co.first_name ASC
|
| 29 |
+
""", (user_id,))
|
| 30 |
+
rows = cursor.fetchall()
|
| 31 |
+
contacts = [dict_from_row(row) for row in rows]
|
| 32 |
+
conn.close()
|
| 33 |
+
return jsonify(contacts)
|
| 34 |
+
|
| 35 |
+
@contacts_bp.route("", methods=["POST"])
|
| 36 |
+
def add_contact():
|
| 37 |
+
data = request.json
|
| 38 |
+
if not data or not data.get("first_name") or not data.get("last_name") or not data.get("company_id"):
|
| 39 |
+
return jsonify({"error": "First name, Last name, and Company ID are required"}), 400
|
| 40 |
+
|
| 41 |
+
company_id = int(data["company_id"])
|
| 42 |
+
user_id, role, _ = get_request_user()
|
| 43 |
+
conn = get_db_connection()
|
| 44 |
+
level = get_user_access_level(conn, user_id, role, company_id)
|
| 45 |
+
if level in ('basic', 'viewer'):
|
| 46 |
+
conn.close()
|
| 47 |
+
return jsonify({"error": "You are not authorized to add contacts to this company."}), 403
|
| 48 |
+
|
| 49 |
+
cursor = conn.cursor()
|
| 50 |
+
try:
|
| 51 |
+
is_primary = 1 if data.get("is_primary") else 0
|
| 52 |
+
|
| 53 |
+
if is_primary == 1:
|
| 54 |
+
cursor.execute("UPDATE contacts SET is_primary = 0 WHERE company_id = ?", (company_id,))
|
| 55 |
+
|
| 56 |
+
contact_id = insert_and_get_id(cursor, """
|
| 57 |
+
INSERT INTO contacts (
|
| 58 |
+
company_id, first_name, last_name, title, email, phone, linkedin, is_primary
|
| 59 |
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
| 60 |
+
""", (
|
| 61 |
+
company_id, data["first_name"], data["last_name"], data.get("title", ""),
|
| 62 |
+
data.get("email", ""), data.get("phone", ""), data.get("linkedin", ""), is_primary
|
| 63 |
+
))
|
| 64 |
+
conn.commit()
|
| 65 |
+
conn.close()
|
| 66 |
+
return jsonify({"success": True, "id": contact_id})
|
| 67 |
+
except Exception as e:
|
| 68 |
+
conn.close()
|
| 69 |
+
return jsonify({"error": str(e)}), 500
|
| 70 |
+
|
| 71 |
+
@contacts_bp.route("/<int:contact_id>", methods=["POST"])
|
| 72 |
+
def update_contact(contact_id):
|
| 73 |
+
data = request.json
|
| 74 |
+
if not data or not data.get("first_name") or not data.get("last_name") or not data.get("company_id"):
|
| 75 |
+
return jsonify({"error": "First name, Last name, and Company ID are required"}), 400
|
| 76 |
+
|
| 77 |
+
company_id = int(data["company_id"])
|
| 78 |
+
user_id, role, _ = get_request_user()
|
| 79 |
+
conn = get_db_connection()
|
| 80 |
+
level = get_user_access_level(conn, user_id, role, company_id)
|
| 81 |
+
if level in ('basic', 'viewer'):
|
| 82 |
+
conn.close()
|
| 83 |
+
return jsonify({"error": "You are not authorized to edit contacts for this company."}), 403
|
| 84 |
+
|
| 85 |
+
cursor = conn.cursor()
|
| 86 |
+
try:
|
| 87 |
+
is_primary = 1 if data.get("is_primary") else 0
|
| 88 |
+
|
| 89 |
+
if is_primary == 1:
|
| 90 |
+
cursor.execute("UPDATE contacts SET is_primary = 0 WHERE company_id = ? AND id != ?", (company_id, contact_id))
|
| 91 |
+
|
| 92 |
+
cursor.execute("""
|
| 93 |
+
UPDATE contacts SET
|
| 94 |
+
company_id = ?, first_name = ?, last_name = ?, title = ?, email = ?,
|
| 95 |
+
phone = ?, linkedin = ?, is_primary = ?
|
| 96 |
+
WHERE id = ?
|
| 97 |
+
""", (
|
| 98 |
+
company_id, data["first_name"], data["last_name"], data.get("title", ""),
|
| 99 |
+
data.get("email", ""), data.get("phone", ""), data.get("linkedin", ""),
|
| 100 |
+
is_primary, contact_id
|
| 101 |
+
))
|
| 102 |
+
conn.commit()
|
| 103 |
+
conn.close()
|
| 104 |
+
return jsonify({"success": True})
|
| 105 |
+
except Exception as e:
|
| 106 |
+
conn.close()
|
| 107 |
+
return jsonify({"error": str(e)}), 500
|
| 108 |
+
|
| 109 |
+
@contacts_bp.route("/delete", methods=["POST"])
|
| 110 |
+
def delete_contacts():
|
| 111 |
+
data = request.json
|
| 112 |
+
if not data or not data.get("ids"):
|
| 113 |
+
return jsonify({"error": "No IDs provided"}), 400
|
| 114 |
+
ids = [int(x) for x in data["ids"]]
|
| 115 |
+
user_id, role, _ = get_request_user()
|
| 116 |
+
conn = get_db_connection()
|
| 117 |
+
cursor = conn.cursor()
|
| 118 |
+
for contact_id in ids:
|
| 119 |
+
cursor.execute("SELECT company_id FROM contacts WHERE id = ?", (contact_id,))
|
| 120 |
+
row = cursor.fetchone()
|
| 121 |
+
if not row:
|
| 122 |
+
continue
|
| 123 |
+
level = get_user_access_level(conn, user_id, role, row[0])
|
| 124 |
+
if level in ('basic', 'viewer'):
|
| 125 |
+
conn.close()
|
| 126 |
+
return jsonify({"error": f"You are not authorized to delete contact ID {contact_id}."}), 403
|
| 127 |
+
try:
|
| 128 |
+
placeholders = ",".join("?" for _ in ids)
|
| 129 |
+
cursor.execute(f"DELETE FROM contacts WHERE id IN ({placeholders})", ids)
|
| 130 |
+
conn.commit()
|
| 131 |
+
conn.close()
|
| 132 |
+
return jsonify({"success": True})
|
| 133 |
+
except Exception as e:
|
| 134 |
+
conn.close()
|
| 135 |
+
return jsonify({"error": str(e)}), 500
|
routes/learning.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Flask routes for the Learning section RAG chatbot."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from flask import Blueprint, jsonify, request
|
| 6 |
+
|
| 7 |
+
from db import get_request_user
|
| 8 |
+
|
| 9 |
+
from services.rag_service import (
|
| 10 |
+
add_document,
|
| 11 |
+
add_youtube_url,
|
| 12 |
+
add_video_file,
|
| 13 |
+
create_collection,
|
| 14 |
+
create_session,
|
| 15 |
+
delete_collection,
|
| 16 |
+
delete_document,
|
| 17 |
+
delete_session,
|
| 18 |
+
get_session_messages,
|
| 19 |
+
list_collections,
|
| 20 |
+
list_documents,
|
| 21 |
+
list_sessions,
|
| 22 |
+
query_collection,
|
| 23 |
+
rename_collection,
|
| 24 |
+
rename_session,
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
learning_bp = Blueprint("learning", __name__)
|
| 28 |
+
|
| 29 |
+
# ── Collections ───────────────────────────────────────────────────────────
|
| 30 |
+
@learning_bp.post("/collections")
|
| 31 |
+
def api_create_collection():
|
| 32 |
+
payload = request.get_json(force=True, silent=True) or {}
|
| 33 |
+
name = str(payload.get("name") or "").strip()
|
| 34 |
+
if not name:
|
| 35 |
+
return jsonify({"error": "Collection name is required."}), 400
|
| 36 |
+
visibility = str(payload.get("visibility") or "private").strip()
|
| 37 |
+
user_id_val, _, _ = get_request_user()
|
| 38 |
+
try:
|
| 39 |
+
return jsonify(create_collection(name, user_id=str(user_id_val), visibility=visibility))
|
| 40 |
+
except Exception as exc:
|
| 41 |
+
return jsonify({"error": str(exc)}), 500
|
| 42 |
+
|
| 43 |
+
@learning_bp.get("/collections")
|
| 44 |
+
def api_list_collections():
|
| 45 |
+
try:
|
| 46 |
+
user_id_val, role_val, _ = get_request_user()
|
| 47 |
+
return jsonify(list_collections(user_id=str(user_id_val), user_role=str(role_val)))
|
| 48 |
+
except Exception as exc:
|
| 49 |
+
return jsonify({"error": str(exc)}), 500
|
| 50 |
+
|
| 51 |
+
@learning_bp.delete("/collections/<collection_id>")
|
| 52 |
+
def api_delete_collection(collection_id: str):
|
| 53 |
+
try:
|
| 54 |
+
delete_collection(collection_id)
|
| 55 |
+
return jsonify({"success": True})
|
| 56 |
+
except ValueError as exc:
|
| 57 |
+
return jsonify({"error": str(exc)}), 404
|
| 58 |
+
except Exception as exc:
|
| 59 |
+
return jsonify({"error": str(exc)}), 500
|
| 60 |
+
|
| 61 |
+
@learning_bp.put("/collections/<collection_id>")
|
| 62 |
+
def api_rename_collection(collection_id: str):
|
| 63 |
+
payload = request.get_json(force=True, silent=True) or {}
|
| 64 |
+
name = str(payload.get("name") or "").strip()
|
| 65 |
+
if not name:
|
| 66 |
+
return jsonify({"error": "New name is required."}), 400
|
| 67 |
+
try:
|
| 68 |
+
return jsonify(rename_collection(collection_id, name))
|
| 69 |
+
except ValueError as exc:
|
| 70 |
+
return jsonify({"error": str(exc)}), 404
|
| 71 |
+
except Exception as exc:
|
| 72 |
+
return jsonify({"error": str(exc)}), 500
|
| 73 |
+
|
| 74 |
+
# ── Documents ─────────────────────────────────────────────────────────────
|
| 75 |
+
@learning_bp.post("/collections/<collection_id>/upload")
|
| 76 |
+
def api_upload_documents(collection_id: str):
|
| 77 |
+
files = request.files.getlist("files")
|
| 78 |
+
if not files or all(f.filename == "" for f in files):
|
| 79 |
+
return jsonify({"error": "No files uploaded."}), 400
|
| 80 |
+
results = []
|
| 81 |
+
errors = []
|
| 82 |
+
for f in files:
|
| 83 |
+
if not f.filename:
|
| 84 |
+
continue
|
| 85 |
+
try:
|
| 86 |
+
doc_info = add_document(collection_id, f)
|
| 87 |
+
results.append(doc_info)
|
| 88 |
+
except Exception as exc:
|
| 89 |
+
errors.append({"filename": f.filename, "error": str(exc)})
|
| 90 |
+
resp = {"documents": results}
|
| 91 |
+
if errors:
|
| 92 |
+
resp["errors"] = errors
|
| 93 |
+
status = 200 if results else 400
|
| 94 |
+
return jsonify(resp), status
|
| 95 |
+
|
| 96 |
+
@learning_bp.get("/collections/<collection_id>/documents")
|
| 97 |
+
def api_list_documents(collection_id: str):
|
| 98 |
+
try:
|
| 99 |
+
return jsonify(list_documents(collection_id))
|
| 100 |
+
except ValueError as exc:
|
| 101 |
+
return jsonify({"error": str(exc)}), 404
|
| 102 |
+
except Exception as exc:
|
| 103 |
+
return jsonify({"error": str(exc)}), 500
|
| 104 |
+
|
| 105 |
+
@learning_bp.delete("/collections/<collection_id>/documents/<doc_id>")
|
| 106 |
+
def api_delete_document(collection_id: str, doc_id: str):
|
| 107 |
+
try:
|
| 108 |
+
delete_document(collection_id, doc_id)
|
| 109 |
+
return jsonify({"success": True})
|
| 110 |
+
except ValueError as exc:
|
| 111 |
+
return jsonify({"error": str(exc)}), 404
|
| 112 |
+
except Exception as exc:
|
| 113 |
+
return jsonify({"error": str(exc)}), 500
|
| 114 |
+
|
| 115 |
+
# ── YouTube URL ────────────────────────────────────────────────────────────
|
| 116 |
+
@learning_bp.post("/collections/<collection_id>/add-url")
|
| 117 |
+
def api_add_youtube_url(collection_id: str):
|
| 118 |
+
payload = request.get_json(force=True, silent=True) or {}
|
| 119 |
+
url = str(payload.get("url") or "").strip()
|
| 120 |
+
if not url:
|
| 121 |
+
return jsonify({"error": "URL is required."}), 400
|
| 122 |
+
try:
|
| 123 |
+
result = add_youtube_url(collection_id, url)
|
| 124 |
+
return jsonify(result)
|
| 125 |
+
except ValueError as exc:
|
| 126 |
+
return jsonify({"error": str(exc)}), 400
|
| 127 |
+
except Exception as exc:
|
| 128 |
+
return jsonify({"error": str(exc)}), 500
|
| 129 |
+
|
| 130 |
+
# ── MP4 Video upload ──────────────────────��───────────────────────────────
|
| 131 |
+
@learning_bp.post("/collections/<collection_id>/upload-video")
|
| 132 |
+
def api_upload_video(collection_id: str):
|
| 133 |
+
f = request.files.get("video")
|
| 134 |
+
if not f or not f.filename:
|
| 135 |
+
return jsonify({"error": "No video file uploaded."}), 400
|
| 136 |
+
try:
|
| 137 |
+
result = add_video_file(collection_id, f)
|
| 138 |
+
return jsonify(result)
|
| 139 |
+
except ValueError as exc:
|
| 140 |
+
return jsonify({"error": str(exc)}), 400
|
| 141 |
+
except Exception as exc:
|
| 142 |
+
return jsonify({"error": str(exc)}), 500
|
| 143 |
+
|
| 144 |
+
# ── Sessions ──────────────────────────────────────────────────────────────
|
| 145 |
+
@learning_bp.post("/collections/<collection_id>/sessions")
|
| 146 |
+
def api_create_session(collection_id: str):
|
| 147 |
+
payload = request.get_json(force=True, silent=True) or {}
|
| 148 |
+
name = str(payload.get("name") or "").strip() or None
|
| 149 |
+
try:
|
| 150 |
+
return jsonify(create_session(collection_id, name))
|
| 151 |
+
except ValueError as exc:
|
| 152 |
+
return jsonify({"error": str(exc)}), 404
|
| 153 |
+
except Exception as exc:
|
| 154 |
+
return jsonify({"error": str(exc)}), 500
|
| 155 |
+
|
| 156 |
+
@learning_bp.get("/collections/<collection_id>/sessions")
|
| 157 |
+
def api_list_sessions(collection_id: str):
|
| 158 |
+
try:
|
| 159 |
+
return jsonify(list_sessions(collection_id))
|
| 160 |
+
except ValueError as exc:
|
| 161 |
+
return jsonify({"error": str(exc)}), 404
|
| 162 |
+
except Exception as exc:
|
| 163 |
+
return jsonify({"error": str(exc)}), 500
|
| 164 |
+
|
| 165 |
+
@learning_bp.get("/collections/<collection_id>/sessions/<session_id>/messages")
|
| 166 |
+
def api_get_session_messages(collection_id: str, session_id: str):
|
| 167 |
+
try:
|
| 168 |
+
return jsonify(get_session_messages(collection_id, session_id))
|
| 169 |
+
except ValueError as exc:
|
| 170 |
+
return jsonify({"error": str(exc)}), 404
|
| 171 |
+
except Exception as exc:
|
| 172 |
+
return jsonify({"error": str(exc)}), 500
|
| 173 |
+
|
| 174 |
+
@learning_bp.delete("/collections/<collection_id>/sessions/<session_id>")
|
| 175 |
+
def api_delete_session(collection_id: str, session_id: str):
|
| 176 |
+
try:
|
| 177 |
+
delete_session(collection_id, session_id)
|
| 178 |
+
return jsonify({"success": True})
|
| 179 |
+
except ValueError as exc:
|
| 180 |
+
return jsonify({"error": str(exc)}), 404
|
| 181 |
+
except Exception as exc:
|
| 182 |
+
return jsonify({"error": str(exc)}), 500
|
| 183 |
+
|
| 184 |
+
@learning_bp.put("/collections/<collection_id>/sessions/<session_id>")
|
| 185 |
+
def api_rename_session(collection_id: str, session_id: str):
|
| 186 |
+
payload = request.get_json(force=True, silent=True) or {}
|
| 187 |
+
name = str(payload.get("name") or "").strip()
|
| 188 |
+
if not name:
|
| 189 |
+
return jsonify({"error": "New name is required."}), 400
|
| 190 |
+
try:
|
| 191 |
+
return jsonify(rename_session(collection_id, session_id, name))
|
| 192 |
+
except ValueError as exc:
|
| 193 |
+
return jsonify({"error": str(exc)}), 404
|
| 194 |
+
except Exception as exc:
|
| 195 |
+
return jsonify({"error": str(exc)}), 500
|
| 196 |
+
|
| 197 |
+
# ── Chat (RAG query) ─────────────────────────────────────────────────────
|
| 198 |
+
@learning_bp.post("/collections/<collection_id>/chat")
|
| 199 |
+
def api_chat(collection_id: str):
|
| 200 |
+
payload = request.get_json(force=True, silent=True) or {}
|
| 201 |
+
question = str(payload.get("question") or "").strip()
|
| 202 |
+
session_id = str(payload.get("session_id") or "").strip() or None
|
| 203 |
+
if not question:
|
| 204 |
+
return jsonify({"error": "Question is required."}), 400
|
| 205 |
+
try:
|
| 206 |
+
result = query_collection(collection_id, question, session_id=session_id)
|
| 207 |
+
return jsonify(result)
|
| 208 |
+
except ValueError as exc:
|
| 209 |
+
return jsonify({"error": str(exc)}), 404
|
| 210 |
+
except Exception as exc:
|
| 211 |
+
return jsonify({"error": str(exc)}), 500
|
| 212 |
+
|
| 213 |
+
# ── Tasks ─────────────────────────────────────
|
| 214 |
+
@learning_bp.get("/tasks/<task_id>")
|
| 215 |
+
def check_task_status(task_id: str):
|
| 216 |
+
from celery_app import celery_app
|
| 217 |
+
task = celery_app.AsyncResult(task_id)
|
| 218 |
+
if task.state == 'PENDING':
|
| 219 |
+
response = {'status': 'processing'}
|
| 220 |
+
elif task.state != 'FAILURE':
|
| 221 |
+
response = {'status': task.state, 'result': task.result}
|
| 222 |
+
else:
|
| 223 |
+
response = {'status': 'error', 'error': str(task.info)}
|
| 224 |
+
return jsonify(response)
|
routes/stt.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from flask import Blueprint, jsonify
|
| 2 |
+
import requests
|
| 3 |
+
import os
|
| 4 |
+
|
| 5 |
+
stt_bp = Blueprint('stt', __name__)
|
| 6 |
+
|
| 7 |
+
ASSEMBLYAI_API_KEY = os.environ.get('ASSEMBLYAI_API_KEY', '9f7a6f9a621c45c29b70afd380ca63f0')
|
| 8 |
+
|
| 9 |
+
@stt_bp.route('/api/stt/token', methods=['POST'])
|
| 10 |
+
def get_stt_token():
|
| 11 |
+
"""Generate a temporary authentication token for AssemblyAI real-time STT."""
|
| 12 |
+
try:
|
| 13 |
+
response = requests.get(
|
| 14 |
+
'https://streaming.assemblyai.com/v3/token',
|
| 15 |
+
headers={'Authorization': ASSEMBLYAI_API_KEY},
|
| 16 |
+
params={'expires_in_seconds': 600}
|
| 17 |
+
)
|
| 18 |
+
if response.status_code == 200:
|
| 19 |
+
data = response.json()
|
| 20 |
+
return jsonify({'token': data['token']})
|
| 21 |
+
else:
|
| 22 |
+
return jsonify({'error': 'Failed to generate token', 'details': response.text}), response.status_code
|
| 23 |
+
except Exception as e:
|
| 24 |
+
return jsonify({'error': str(e)}), 500
|
routes/system.py
ADDED
|
@@ -0,0 +1,518 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
from flask import Blueprint, jsonify, request
|
| 3 |
+
from db import get_db_connection, dict_from_row, get_request_user, get_user_access_level, get_request_tenant_id
|
| 4 |
+
from services.ai_service import base_company_context_status, save_base_company_context
|
| 5 |
+
|
| 6 |
+
stages_bp = Blueprint('stages', __name__)
|
| 7 |
+
activities_bp = Blueprint('activities', __name__)
|
| 8 |
+
stats_bp = Blueprint('stats', __name__)
|
| 9 |
+
columns_bp = Blueprint('columns', __name__)
|
| 10 |
+
settings_bp = Blueprint('settings', __name__)
|
| 11 |
+
|
| 12 |
+
# =====================================================================
|
| 13 |
+
# Settings Routes
|
| 14 |
+
# =====================================================================
|
| 15 |
+
@settings_bp.route("/company-context", methods=["GET"])
|
| 16 |
+
def get_company_context_setting():
|
| 17 |
+
return jsonify(base_company_context_status(include_content=True))
|
| 18 |
+
|
| 19 |
+
@settings_bp.route("/company-context", methods=["POST"])
|
| 20 |
+
def save_company_context_setting():
|
| 21 |
+
uploaded_file = request.files.get("file")
|
| 22 |
+
raw_content = ""
|
| 23 |
+
|
| 24 |
+
if uploaded_file and uploaded_file.filename:
|
| 25 |
+
if not uploaded_file.filename.lower().endswith((".md", ".markdown", ".txt")):
|
| 26 |
+
return jsonify({"error": "Upload a Markdown or text file."}), 400
|
| 27 |
+
raw_content = uploaded_file.read().decode("utf-8-sig", errors="replace")
|
| 28 |
+
else:
|
| 29 |
+
payload = request.get_json(force=True, silent=True) or {}
|
| 30 |
+
raw_content = payload.get("content", "")
|
| 31 |
+
|
| 32 |
+
try:
|
| 33 |
+
return jsonify(save_base_company_context(raw_content))
|
| 34 |
+
except ValueError as exc:
|
| 35 |
+
return jsonify({"error": str(exc)}), 400
|
| 36 |
+
except Exception as exc:
|
| 37 |
+
return jsonify({"error": str(exc)}), 500
|
| 38 |
+
|
| 39 |
+
# =====================================================================
|
| 40 |
+
# Users Route
|
| 41 |
+
# =====================================================================
|
| 42 |
+
@settings_bp.route("/users", methods=["GET"])
|
| 43 |
+
def get_users():
|
| 44 |
+
from db import cache_get, cache_set
|
| 45 |
+
cached_users = cache_get("prospectiq:users")
|
| 46 |
+
if cached_users is not None:
|
| 47 |
+
return jsonify(cached_users)
|
| 48 |
+
|
| 49 |
+
conn = get_db_connection()
|
| 50 |
+
cursor = conn.cursor()
|
| 51 |
+
cursor.execute("SELECT id, name, role, email FROM users ORDER BY id ASC")
|
| 52 |
+
rows = cursor.fetchall()
|
| 53 |
+
users = [dict_from_row(row) for row in rows]
|
| 54 |
+
conn.close()
|
| 55 |
+
|
| 56 |
+
cache_set("prospectiq:users", users, timeout=3600)
|
| 57 |
+
return jsonify(users)
|
| 58 |
+
|
| 59 |
+
# =====================================================================
|
| 60 |
+
# Stages Routes
|
| 61 |
+
# =====================================================================
|
| 62 |
+
@stages_bp.route("", methods=["GET"])
|
| 63 |
+
def get_stages():
|
| 64 |
+
from db import cache_get, cache_set
|
| 65 |
+
cached_stages = cache_get("prospectiq:stages")
|
| 66 |
+
if cached_stages is not None:
|
| 67 |
+
return jsonify(cached_stages)
|
| 68 |
+
|
| 69 |
+
conn = get_db_connection()
|
| 70 |
+
cursor = conn.cursor()
|
| 71 |
+
cursor.execute("SELECT * FROM stages ORDER BY order_index ASC")
|
| 72 |
+
rows = cursor.fetchall()
|
| 73 |
+
stages = [dict_from_row(row) for row in rows]
|
| 74 |
+
conn.close()
|
| 75 |
+
|
| 76 |
+
cache_set("prospectiq:stages", stages, timeout=3600)
|
| 77 |
+
return jsonify(stages)
|
| 78 |
+
|
| 79 |
+
# =====================================================================
|
| 80 |
+
# Activities / Notes Routes
|
| 81 |
+
# =====================================================================
|
| 82 |
+
@activities_bp.route("/<int:note_id>/toggle_pin", methods=["POST"])
|
| 83 |
+
def toggle_note_pin(note_id):
|
| 84 |
+
user_id, role, _ = get_request_user()
|
| 85 |
+
conn = get_db_connection()
|
| 86 |
+
cursor = conn.cursor()
|
| 87 |
+
try:
|
| 88 |
+
cursor.execute("SELECT company_id FROM notes WHERE id = ?", (note_id,))
|
| 89 |
+
note_row = cursor.fetchone()
|
| 90 |
+
if not note_row:
|
| 91 |
+
conn.close()
|
| 92 |
+
return jsonify({"error": "Note not found"}), 404
|
| 93 |
+
|
| 94 |
+
level = get_user_access_level(conn, user_id, role, note_row[0])
|
| 95 |
+
if level in ('basic', 'viewer'):
|
| 96 |
+
conn.close()
|
| 97 |
+
return jsonify({"error": "You are not authorized to pin/unpin notes for this company."}), 403
|
| 98 |
+
|
| 99 |
+
cursor.execute("SELECT is_pinned FROM notes WHERE id = ?", (note_id,))
|
| 100 |
+
row = cursor.fetchone()
|
| 101 |
+
new_status = 1 if row[0] == 0 else 0
|
| 102 |
+
cursor.execute("UPDATE notes SET is_pinned = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", (new_status, note_id))
|
| 103 |
+
conn.commit()
|
| 104 |
+
conn.close()
|
| 105 |
+
return jsonify({"success": True, "is_pinned": new_status})
|
| 106 |
+
except Exception as e:
|
| 107 |
+
conn.close()
|
| 108 |
+
return jsonify({"error": str(e)}), 500
|
| 109 |
+
|
| 110 |
+
@activities_bp.route("/immediate-actions", methods=["GET"])
|
| 111 |
+
def get_immediate_actions():
|
| 112 |
+
user_id, role, _ = get_request_user()
|
| 113 |
+
conn = get_db_connection()
|
| 114 |
+
cursor = conn.cursor()
|
| 115 |
+
try:
|
| 116 |
+
if role in ('Super Admin', 'Admin', 'Viewer'):
|
| 117 |
+
cursor.execute("""
|
| 118 |
+
SELECT a.id, a.next_action as title, a.next_action_date, c.name as company_name, c.id as company_id
|
| 119 |
+
FROM activities a
|
| 120 |
+
JOIN companies c ON a.company_id = c.id
|
| 121 |
+
WHERE a.next_action IS NOT NULL AND a.next_action != ''
|
| 122 |
+
ORDER BY a.occurred_at DESC
|
| 123 |
+
""")
|
| 124 |
+
else:
|
| 125 |
+
cursor.execute("""
|
| 126 |
+
SELECT a.id, a.next_action as title, a.next_action_date, c.name as company_name, c.id as company_id
|
| 127 |
+
FROM activities a
|
| 128 |
+
JOIN companies c ON a.company_id = c.id
|
| 129 |
+
WHERE a.next_action IS NOT NULL AND a.next_action != '' AND c.assigned_rep_id = ?
|
| 130 |
+
ORDER BY a.occurred_at DESC
|
| 131 |
+
""", (user_id,))
|
| 132 |
+
rows = cursor.fetchall()
|
| 133 |
+
actions = [dict_from_row(row) for row in rows]
|
| 134 |
+
conn.close()
|
| 135 |
+
return jsonify(actions)
|
| 136 |
+
except Exception as e:
|
| 137 |
+
conn.close()
|
| 138 |
+
return jsonify({"error": str(e)}), 500
|
| 139 |
+
|
| 140 |
+
@activities_bp.route("/immediate-actions/<int:activity_id>/complete", methods=["POST"])
|
| 141 |
+
def complete_immediate_action(activity_id):
|
| 142 |
+
user_id, role, _ = get_request_user()
|
| 143 |
+
conn = get_db_connection()
|
| 144 |
+
cursor = conn.cursor()
|
| 145 |
+
try:
|
| 146 |
+
cursor.execute("SELECT company_id FROM activities WHERE id = ?", (activity_id,))
|
| 147 |
+
act_row = cursor.fetchone()
|
| 148 |
+
if not act_row:
|
| 149 |
+
conn.close()
|
| 150 |
+
return jsonify({"error": "Activity not found"}), 404
|
| 151 |
+
|
| 152 |
+
level = get_user_access_level(conn, user_id, role, act_row[0])
|
| 153 |
+
if level in ('basic', 'viewer'):
|
| 154 |
+
conn.close()
|
| 155 |
+
return jsonify({"error": "You are not authorized to modify actions for this company."}), 403
|
| 156 |
+
|
| 157 |
+
cursor.execute("""
|
| 158 |
+
UPDATE activities
|
| 159 |
+
SET next_action = '', next_action_date = NULL
|
| 160 |
+
WHERE id = ?
|
| 161 |
+
""", (activity_id,))
|
| 162 |
+
conn.commit()
|
| 163 |
+
conn.close()
|
| 164 |
+
return jsonify({"success": True})
|
| 165 |
+
except Exception as e:
|
| 166 |
+
conn.close()
|
| 167 |
+
return jsonify({"error": str(e)}), 500
|
| 168 |
+
|
| 169 |
+
@activities_bp.route("/recent", methods=["GET"])
|
| 170 |
+
def get_recent_activities():
|
| 171 |
+
user_id, role, _ = get_request_user()
|
| 172 |
+
conn = get_db_connection()
|
| 173 |
+
cursor = conn.cursor()
|
| 174 |
+
try:
|
| 175 |
+
if role in ('Super Admin', 'Admin', 'Viewer'):
|
| 176 |
+
cursor.execute("""
|
| 177 |
+
SELECT a.id, a.type, a.outcome, a.notes, a.occurred_at, c.name as company_name, c.id as company_id
|
| 178 |
+
FROM activities a
|
| 179 |
+
JOIN companies c ON a.company_id = c.id
|
| 180 |
+
ORDER BY a.occurred_at DESC
|
| 181 |
+
LIMIT 15
|
| 182 |
+
""")
|
| 183 |
+
else:
|
| 184 |
+
cursor.execute("""
|
| 185 |
+
SELECT a.id, a.type, a.outcome, a.notes, a.occurred_at, c.name as company_name, c.id as company_id
|
| 186 |
+
FROM activities a
|
| 187 |
+
JOIN companies c ON a.company_id = c.id
|
| 188 |
+
WHERE c.assigned_rep_id = ?
|
| 189 |
+
ORDER BY a.occurred_at DESC
|
| 190 |
+
LIMIT 15
|
| 191 |
+
""", (user_id,))
|
| 192 |
+
rows = cursor.fetchall()
|
| 193 |
+
activities = [dict_from_row(row) for row in rows]
|
| 194 |
+
conn.close()
|
| 195 |
+
return jsonify(activities)
|
| 196 |
+
except Exception as e:
|
| 197 |
+
conn.close()
|
| 198 |
+
return jsonify({"error": str(e)}), 500
|
| 199 |
+
|
| 200 |
+
# =====================================================================
|
| 201 |
+
# Stats Routes
|
| 202 |
+
# =====================================================================
|
| 203 |
+
@stats_bp.route("", methods=["GET"])
|
| 204 |
+
def get_stats():
|
| 205 |
+
user_id, role, _ = get_request_user()
|
| 206 |
+
conn = get_db_connection()
|
| 207 |
+
cursor = conn.cursor()
|
| 208 |
+
|
| 209 |
+
# Filter stats to respect assigned scope for BD reps
|
| 210 |
+
if role in ('Super Admin', 'Admin', 'Viewer'):
|
| 211 |
+
cursor.execute("""
|
| 212 |
+
SELECT s.name, COUNT(c.id) as count
|
| 213 |
+
FROM stages s
|
| 214 |
+
LEFT JOIN companies c ON s.id = c.stage_id
|
| 215 |
+
GROUP BY s.id
|
| 216 |
+
ORDER BY s.order_index ASC
|
| 217 |
+
""")
|
| 218 |
+
pipeline = [{"stage": r[0], "count": r[1]} for r in cursor.fetchall()]
|
| 219 |
+
|
| 220 |
+
cursor.execute("SELECT COUNT(*) FROM companies")
|
| 221 |
+
total_companies = cursor.fetchone()[0]
|
| 222 |
+
|
| 223 |
+
cursor.execute("SELECT COUNT(*) FROM activities WHERE type = 'Cold Call'")
|
| 224 |
+
calls = cursor.fetchone()[0]
|
| 225 |
+
|
| 226 |
+
cursor.execute("SELECT COUNT(*) FROM activities WHERE type = 'Email'")
|
| 227 |
+
emails = cursor.fetchone()[0]
|
| 228 |
+
|
| 229 |
+
cursor.execute("SELECT COUNT(*) FROM activities WHERE type = 'Meeting'")
|
| 230 |
+
meetings = cursor.fetchone()[0]
|
| 231 |
+
else:
|
| 232 |
+
cursor.execute("""
|
| 233 |
+
SELECT s.name, COUNT(c.id) as count
|
| 234 |
+
FROM stages s
|
| 235 |
+
LEFT JOIN companies c ON s.id = c.stage_id AND c.assigned_rep_id = ?
|
| 236 |
+
GROUP BY s.id
|
| 237 |
+
ORDER BY s.order_index ASC
|
| 238 |
+
""", (user_id,))
|
| 239 |
+
pipeline = [{"stage": r[0], "count": r[1]} for r in cursor.fetchall()]
|
| 240 |
+
|
| 241 |
+
cursor.execute("SELECT COUNT(*) FROM companies WHERE assigned_rep_id = ?", (user_id,))
|
| 242 |
+
total_companies = cursor.fetchone()[0]
|
| 243 |
+
|
| 244 |
+
cursor.execute("SELECT COUNT(*) FROM activities WHERE type = 'Cold Call' AND user_id = ?", (user_id,))
|
| 245 |
+
calls = cursor.fetchone()[0]
|
| 246 |
+
|
| 247 |
+
cursor.execute("SELECT COUNT(*) FROM activities WHERE type = 'Email' AND user_id = ?", (user_id,))
|
| 248 |
+
emails = cursor.fetchone()[0]
|
| 249 |
+
|
| 250 |
+
cursor.execute("SELECT COUNT(*) FROM activities WHERE type = 'Meeting' AND user_id = ?", (user_id,))
|
| 251 |
+
meetings = cursor.fetchone()[0]
|
| 252 |
+
|
| 253 |
+
conn.close()
|
| 254 |
+
return jsonify({
|
| 255 |
+
"total_companies": total_companies,
|
| 256 |
+
"calls": calls,
|
| 257 |
+
"emails": emails,
|
| 258 |
+
"meetings": meetings,
|
| 259 |
+
"pipeline": pipeline
|
| 260 |
+
})
|
| 261 |
+
|
| 262 |
+
# =====================================================================
|
| 263 |
+
# Dynamic Columns Routes
|
| 264 |
+
# =====================================================================
|
| 265 |
+
@columns_bp.route("/columns/<string:table_name>", methods=["GET"])
|
| 266 |
+
def get_columns(table_name):
|
| 267 |
+
if table_name not in ["companies", "contacts"]:
|
| 268 |
+
return jsonify({"error": "Invalid table"}), 400
|
| 269 |
+
from db import is_supabase_active, cache_get, cache_set
|
| 270 |
+
|
| 271 |
+
# Check cache first
|
| 272 |
+
cache_key = f"prospectiq:columns:{table_name}"
|
| 273 |
+
cached_columns = cache_get(cache_key)
|
| 274 |
+
if cached_columns is not None:
|
| 275 |
+
return jsonify(cached_columns)
|
| 276 |
+
|
| 277 |
+
conn = get_db_connection()
|
| 278 |
+
cursor = conn.cursor()
|
| 279 |
+
if is_supabase_active():
|
| 280 |
+
cursor.execute("""
|
| 281 |
+
SELECT
|
| 282 |
+
ordinal_position AS cid,
|
| 283 |
+
column_name AS name,
|
| 284 |
+
data_type AS type,
|
| 285 |
+
CASE WHEN is_nullable = 'NO' THEN 1 ELSE 0 END AS notnull,
|
| 286 |
+
column_default AS dflt_value,
|
| 287 |
+
CASE WHEN column_name = 'id' THEN 1 ELSE 0 END AS pk
|
| 288 |
+
FROM information_schema.columns
|
| 289 |
+
WHERE table_name = %s
|
| 290 |
+
ORDER BY ordinal_position
|
| 291 |
+
""", (table_name,))
|
| 292 |
+
else:
|
| 293 |
+
cursor.execute(f"PRAGMA table_info({table_name})")
|
| 294 |
+
columns = [dict(r) for r in cursor.fetchall()]
|
| 295 |
+
conn.close()
|
| 296 |
+
|
| 297 |
+
# Cache schema for 1 hour
|
| 298 |
+
cache_set(cache_key, columns, timeout=3600)
|
| 299 |
+
return jsonify(columns)
|
| 300 |
+
|
| 301 |
+
@columns_bp.route("/columns/<string:table_name>/add", methods=["POST"])
|
| 302 |
+
def add_column(table_name):
|
| 303 |
+
if table_name not in ["companies", "contacts"]:
|
| 304 |
+
return jsonify({"error": "Invalid table"}), 400
|
| 305 |
+
|
| 306 |
+
user_id, role, _ = get_request_user()
|
| 307 |
+
if role not in ('Super Admin', 'Admin'):
|
| 308 |
+
return jsonify({"error": "Only admins can add custom columns."}), 403
|
| 309 |
+
|
| 310 |
+
data = request.json
|
| 311 |
+
if not data or not data.get("name"):
|
| 312 |
+
return jsonify({"error": "Column name is required"}), 400
|
| 313 |
+
column_name = data["name"]
|
| 314 |
+
clean_name = re.sub(r'[^a-zA-Z0-9_]', '', column_name)
|
| 315 |
+
if not clean_name:
|
| 316 |
+
return jsonify({"error": "Invalid column name"}), 400
|
| 317 |
+
|
| 318 |
+
conn = get_db_connection()
|
| 319 |
+
cursor = conn.cursor()
|
| 320 |
+
try:
|
| 321 |
+
cursor.execute(f"ALTER TABLE {table_name} ADD COLUMN {clean_name} TEXT")
|
| 322 |
+
conn.commit()
|
| 323 |
+
conn.close()
|
| 324 |
+
|
| 325 |
+
# Invalidate cached schema
|
| 326 |
+
from db import cache_delete
|
| 327 |
+
cache_delete(f"prospectiq:columns:{table_name}")
|
| 328 |
+
|
| 329 |
+
return jsonify({"success": True, "name": clean_name})
|
| 330 |
+
except Exception as e:
|
| 331 |
+
conn.close()
|
| 332 |
+
return jsonify({"error": str(e)}), 500
|
| 333 |
+
|
| 334 |
+
@columns_bp.route("/update_cell", methods=["POST"])
|
| 335 |
+
def update_cell():
|
| 336 |
+
data = request.json
|
| 337 |
+
if not data or not data.get("table") or not data.get("id") or not data.get("column"):
|
| 338 |
+
return jsonify({"error": "Missing required fields"}), 400
|
| 339 |
+
|
| 340 |
+
table = data["table"]
|
| 341 |
+
row_id = int(data["id"])
|
| 342 |
+
column = data["column"]
|
| 343 |
+
value = data.get("value")
|
| 344 |
+
|
| 345 |
+
if table not in ["companies", "contacts"]:
|
| 346 |
+
return jsonify({"error": "Invalid table"}), 400
|
| 347 |
+
|
| 348 |
+
clean_column = re.sub(r'[^a-zA-Z0-9_]', '', column)
|
| 349 |
+
if not clean_column:
|
| 350 |
+
return jsonify({"error": "Invalid column name"}), 400
|
| 351 |
+
|
| 352 |
+
user_id, role, _ = get_request_user()
|
| 353 |
+
conn = get_db_connection()
|
| 354 |
+
|
| 355 |
+
if table == 'companies':
|
| 356 |
+
level = get_user_access_level(conn, user_id, role, row_id)
|
| 357 |
+
if level in ('basic', 'viewer'):
|
| 358 |
+
conn.close()
|
| 359 |
+
return jsonify({"error": "You are not authorized to edit this company."}), 403
|
| 360 |
+
elif table == 'contacts':
|
| 361 |
+
cursor = conn.cursor()
|
| 362 |
+
cursor.execute("SELECT company_id FROM contacts WHERE id = ?", (row_id,))
|
| 363 |
+
row = cursor.fetchone()
|
| 364 |
+
if row:
|
| 365 |
+
level = get_user_access_level(conn, user_id, role, row[0])
|
| 366 |
+
if level in ('basic', 'viewer'):
|
| 367 |
+
conn.close()
|
| 368 |
+
return jsonify({"error": "You are not authorized to edit this contact."}), 403
|
| 369 |
+
|
| 370 |
+
cursor = conn.cursor()
|
| 371 |
+
try:
|
| 372 |
+
val_to_save = value
|
| 373 |
+
if clean_column == 'assigned_rep_id':
|
| 374 |
+
if value == '' or value is None:
|
| 375 |
+
val_to_save = None
|
| 376 |
+
else:
|
| 377 |
+
val_to_save = int(value)
|
| 378 |
+
cursor.execute(f"UPDATE {table} SET {clean_column} = ? WHERE id = ?", (val_to_save, row_id))
|
| 379 |
+
conn.commit()
|
| 380 |
+
conn.close()
|
| 381 |
+
return jsonify({"success": True})
|
| 382 |
+
except Exception as e:
|
| 383 |
+
conn.close()
|
| 384 |
+
return jsonify({"error": str(e)}), 500
|
| 385 |
+
|
| 386 |
+
# =====================================================================
|
| 387 |
+
# Bootstrap Endpoint — returns ALL initial page data in one request
|
| 388 |
+
# Eliminates 5 sequential round-trips to Supabase on page load
|
| 389 |
+
# =====================================================================
|
| 390 |
+
bootstrap_bp = Blueprint('bootstrap', __name__)
|
| 391 |
+
|
| 392 |
+
@bootstrap_bp.route("", methods=["GET"])
|
| 393 |
+
def api_bootstrap():
|
| 394 |
+
"""Return stages, companies, contacts, and column schemas in a single response.
|
| 395 |
+
Executes sequentially on a single DB connection to eliminate network connection pool
|
| 396 |
+
and TLS handshake overhead when communicating with Supabase.
|
| 397 |
+
"""
|
| 398 |
+
from db import is_supabase_active, cache_get, cache_set
|
| 399 |
+
user_id, role, _ = get_request_user()
|
| 400 |
+
|
| 401 |
+
# 1. Retrieve cached stages & columns to avoid database round-trips entirely
|
| 402 |
+
stages = cache_get("prospectiq:stages")
|
| 403 |
+
cols_companies = cache_get("prospectiq:columns:companies")
|
| 404 |
+
cols_contacts = cache_get("prospectiq:columns:contacts")
|
| 405 |
+
|
| 406 |
+
# 2. Open single connection to retrieve uncached and dynamic transactional data
|
| 407 |
+
conn = get_db_connection()
|
| 408 |
+
cursor = conn.cursor()
|
| 409 |
+
try:
|
| 410 |
+
# Fetch stages if not cached
|
| 411 |
+
if stages is None:
|
| 412 |
+
cursor.execute("SELECT * FROM stages ORDER BY order_index ASC")
|
| 413 |
+
stages = [dict_from_row(r) for r in cursor.fetchall()]
|
| 414 |
+
cache_set("prospectiq:stages", stages, timeout=3600)
|
| 415 |
+
|
| 416 |
+
# Fetch companies
|
| 417 |
+
tenant_id = get_request_tenant_id()
|
| 418 |
+
if role in ('Super Admin', 'Admin', 'Viewer'):
|
| 419 |
+
cursor.execute("""
|
| 420 |
+
SELECT c.*, s.name as stage_name, s.color_bg as stage_color_bg, s.color_text as stage_color_text, u.name as rep_name
|
| 421 |
+
FROM companies c
|
| 422 |
+
JOIN stages s ON c.stage_id = s.id
|
| 423 |
+
LEFT JOIN users u ON c.assigned_rep_id = u.id
|
| 424 |
+
WHERE c.tenant_id = ?
|
| 425 |
+
ORDER BY c.maplempss_fit_score DESC, c.name ASC
|
| 426 |
+
""", (tenant_id,))
|
| 427 |
+
else:
|
| 428 |
+
cursor.execute("""
|
| 429 |
+
SELECT
|
| 430 |
+
c.id, c.tenant_id, c.name, c.legal_name, c.industry, c.street, c.city, c.province, c.postal_code, c.country,
|
| 431 |
+
c.business_type, c.stage_id, c.assigned_rep_id, c.created_by, c.updated_by, c.import_batch_id, c.created_at, c.updated_at,
|
| 432 |
+
s.name as stage_name, s.color_bg as stage_color_bg, s.color_text as stage_color_text, u.name as rep_name,
|
| 433 |
+
CASE WHEN c.assigned_rep_id = ? THEN c.website ELSE '[Restricted]' END as website,
|
| 434 |
+
CASE WHEN c.assigned_rep_id = ? THEN c.phone ELSE '[Restricted]' END as phone,
|
| 435 |
+
CASE WHEN c.assigned_rep_id = ? THEN c.size_staff ELSE NULL END as size_staff,
|
| 436 |
+
CASE WHEN c.assigned_rep_id = ? THEN c.revenue_band ELSE '[Restricted]' END as revenue_band,
|
| 437 |
+
CASE WHEN c.assigned_rep_id = ? THEN c.products_made ELSE '[Restricted]' END as products_made,
|
| 438 |
+
CASE WHEN c.assigned_rep_id = ? THEN c.parts_sourced ELSE '[Restricted]' END as parts_sourced,
|
| 439 |
+
CASE WHEN c.assigned_rep_id = ? THEN c.current_supplier_notes ELSE '[Restricted]' END as current_supplier_notes,
|
| 440 |
+
CASE WHEN c.assigned_rep_id = ? THEN c.maplempss_fit_score ELSE NULL END as maplempss_fit_score
|
| 441 |
+
FROM companies c
|
| 442 |
+
JOIN stages s ON c.stage_id = s.id
|
| 443 |
+
LEFT JOIN users u ON c.assigned_rep_id = u.id
|
| 444 |
+
WHERE c.tenant_id = ?
|
| 445 |
+
ORDER BY c.maplempss_fit_score DESC, c.name ASC
|
| 446 |
+
""", (user_id, user_id, user_id, user_id, user_id, user_id, user_id, user_id, tenant_id))
|
| 447 |
+
companies = [dict_from_row(r) for r in cursor.fetchall()]
|
| 448 |
+
|
| 449 |
+
# Fetch contacts
|
| 450 |
+
if role in ('Super Admin', 'Admin', 'Viewer'):
|
| 451 |
+
cursor.execute("""
|
| 452 |
+
SELECT co.*, cp.name as company_name
|
| 453 |
+
FROM contacts co
|
| 454 |
+
JOIN companies cp ON co.company_id = cp.id
|
| 455 |
+
WHERE cp.tenant_id = ?
|
| 456 |
+
ORDER BY co.first_name ASC
|
| 457 |
+
""", (tenant_id,))
|
| 458 |
+
else:
|
| 459 |
+
cursor.execute("""
|
| 460 |
+
SELECT co.*, cp.name as company_name
|
| 461 |
+
FROM contacts co
|
| 462 |
+
JOIN companies cp ON co.company_id = cp.id
|
| 463 |
+
WHERE cp.tenant_id = ? AND cp.assigned_rep_id = ?
|
| 464 |
+
ORDER BY co.first_name ASC
|
| 465 |
+
""", (tenant_id, user_id))
|
| 466 |
+
contacts = [dict_from_row(r) for r in cursor.fetchall()]
|
| 467 |
+
|
| 468 |
+
# Fetch company columns schema if not cached
|
| 469 |
+
if cols_companies is None:
|
| 470 |
+
if is_supabase_active():
|
| 471 |
+
cursor.execute("""
|
| 472 |
+
SELECT
|
| 473 |
+
ordinal_position AS cid,
|
| 474 |
+
column_name AS name,
|
| 475 |
+
data_type AS type,
|
| 476 |
+
CASE WHEN is_nullable = 'NO' THEN 1 ELSE 0 END AS notnull,
|
| 477 |
+
column_default AS dflt_value,
|
| 478 |
+
CASE WHEN column_name = 'id' THEN 1 ELSE 0 END AS pk
|
| 479 |
+
FROM information_schema.columns
|
| 480 |
+
WHERE table_name = %s
|
| 481 |
+
ORDER BY ordinal_position
|
| 482 |
+
""", ("companies",))
|
| 483 |
+
else:
|
| 484 |
+
cursor.execute("PRAGMA table_info(companies)")
|
| 485 |
+
cols_companies = [dict(r) for r in cursor.fetchall()]
|
| 486 |
+
cache_set("prospectiq:columns:companies", cols_companies, timeout=3600)
|
| 487 |
+
|
| 488 |
+
# Fetch contacts columns schema if not cached
|
| 489 |
+
if cols_contacts is None:
|
| 490 |
+
if is_supabase_active():
|
| 491 |
+
cursor.execute("""
|
| 492 |
+
SELECT
|
| 493 |
+
ordinal_position AS cid,
|
| 494 |
+
column_name AS name,
|
| 495 |
+
data_type AS type,
|
| 496 |
+
CASE WHEN is_nullable = 'NO' THEN 1 ELSE 0 END AS notnull,
|
| 497 |
+
column_default AS dflt_value,
|
| 498 |
+
CASE WHEN column_name = 'id' THEN 1 ELSE 0 END AS pk
|
| 499 |
+
FROM information_schema.columns
|
| 500 |
+
WHERE table_name = %s
|
| 501 |
+
ORDER BY ordinal_position
|
| 502 |
+
""", ("contacts",))
|
| 503 |
+
else:
|
| 504 |
+
cursor.execute("PRAGMA table_info(contacts)")
|
| 505 |
+
cols_contacts = [dict(r) for r in cursor.fetchall()]
|
| 506 |
+
cache_set("prospectiq:columns:contacts", cols_contacts, timeout=3600)
|
| 507 |
+
|
| 508 |
+
finally:
|
| 509 |
+
conn.close()
|
| 510 |
+
|
| 511 |
+
result = {
|
| 512 |
+
"stages": stages,
|
| 513 |
+
"companies": companies,
|
| 514 |
+
"contacts": contacts,
|
| 515 |
+
"columns_companies": cols_companies,
|
| 516 |
+
"columns_contacts": cols_contacts,
|
| 517 |
+
}
|
| 518 |
+
return jsonify(result)
|
schema.sql
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-- ProspectIQ SQLite Database Schema for Cycle 1
|
| 2 |
+
|
| 3 |
+
CREATE TABLE IF NOT EXISTS tenants (
|
| 4 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 5 |
+
name TEXT NOT NULL,
|
| 6 |
+
brand_color TEXT,
|
| 7 |
+
logo_url TEXT,
|
| 8 |
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
| 9 |
+
);
|
| 10 |
+
|
| 11 |
+
CREATE TABLE IF NOT EXISTS permissions (
|
| 12 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 13 |
+
code TEXT UNIQUE NOT NULL,
|
| 14 |
+
description TEXT
|
| 15 |
+
);
|
| 16 |
+
|
| 17 |
+
CREATE TABLE IF NOT EXISTS roles (
|
| 18 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 19 |
+
tenant_id INTEGER NOT NULL,
|
| 20 |
+
name TEXT NOT NULL,
|
| 21 |
+
description TEXT,
|
| 22 |
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
| 23 |
+
FOREIGN KEY(tenant_id) REFERENCES tenants(id) ON DELETE CASCADE,
|
| 24 |
+
UNIQUE(tenant_id, name)
|
| 25 |
+
);
|
| 26 |
+
|
| 27 |
+
CREATE TABLE IF NOT EXISTS role_permissions (
|
| 28 |
+
role_id INTEGER NOT NULL,
|
| 29 |
+
permission_id INTEGER NOT NULL,
|
| 30 |
+
PRIMARY KEY(role_id, permission_id),
|
| 31 |
+
FOREIGN KEY(role_id) REFERENCES roles(id) ON DELETE CASCADE,
|
| 32 |
+
FOREIGN KEY(permission_id) REFERENCES permissions(id) ON DELETE CASCADE
|
| 33 |
+
);
|
| 34 |
+
|
| 35 |
+
CREATE TABLE IF NOT EXISTS users (
|
| 36 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 37 |
+
tenant_id INTEGER NOT NULL,
|
| 38 |
+
email TEXT UNIQUE NOT NULL,
|
| 39 |
+
name TEXT NOT NULL,
|
| 40 |
+
password_hash TEXT,
|
| 41 |
+
role_id INTEGER,
|
| 42 |
+
role TEXT NOT NULL CHECK(role IN ('Super Admin', 'Admin', 'BD Rep', 'Viewer')),
|
| 43 |
+
is_active INTEGER DEFAULT 1,
|
| 44 |
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
| 45 |
+
FOREIGN KEY(tenant_id) REFERENCES tenants(id),
|
| 46 |
+
FOREIGN KEY(role_id) REFERENCES roles(id)
|
| 47 |
+
);
|
| 48 |
+
|
| 49 |
+
CREATE TABLE IF NOT EXISTS stages (
|
| 50 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 51 |
+
name TEXT NOT NULL,
|
| 52 |
+
order_index INTEGER NOT NULL UNIQUE,
|
| 53 |
+
color_bg TEXT,
|
| 54 |
+
color_text TEXT,
|
| 55 |
+
is_active INTEGER DEFAULT 1
|
| 56 |
+
);
|
| 57 |
+
|
| 58 |
+
CREATE TABLE IF NOT EXISTS companies (
|
| 59 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 60 |
+
tenant_id INTEGER NOT NULL,
|
| 61 |
+
name TEXT NOT NULL,
|
| 62 |
+
legal_name TEXT,
|
| 63 |
+
website TEXT,
|
| 64 |
+
phone TEXT,
|
| 65 |
+
nic_code TEXT,
|
| 66 |
+
industry TEXT,
|
| 67 |
+
street TEXT,
|
| 68 |
+
city TEXT,
|
| 69 |
+
province TEXT,
|
| 70 |
+
postal_code TEXT,
|
| 71 |
+
country TEXT,
|
| 72 |
+
size_staff INTEGER,
|
| 73 |
+
revenue_band TEXT,
|
| 74 |
+
business_type TEXT CHECK(business_type IN ('OEM', 'distributor', 'other', 'OEM / distributor', 'OEM / Distributor')),
|
| 75 |
+
products_made TEXT,
|
| 76 |
+
parts_sourced TEXT,
|
| 77 |
+
current_supplier_notes TEXT,
|
| 78 |
+
maplempss_fit_score REAL DEFAULT 0.0,
|
| 79 |
+
stage_id INTEGER NOT NULL,
|
| 80 |
+
assigned_rep_id INTEGER,
|
| 81 |
+
created_by INTEGER,
|
| 82 |
+
updated_by INTEGER,
|
| 83 |
+
import_batch_id INTEGER,
|
| 84 |
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
| 85 |
+
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
| 86 |
+
FOREIGN KEY(tenant_id) REFERENCES tenants(id),
|
| 87 |
+
FOREIGN KEY(stage_id) REFERENCES stages(id),
|
| 88 |
+
FOREIGN KEY(assigned_rep_id) REFERENCES users(id),
|
| 89 |
+
FOREIGN KEY(created_by) REFERENCES users(id),
|
| 90 |
+
FOREIGN KEY(updated_by) REFERENCES users(id)
|
| 91 |
+
);
|
| 92 |
+
|
| 93 |
+
CREATE TABLE IF NOT EXISTS contacts (
|
| 94 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 95 |
+
company_id INTEGER NOT NULL,
|
| 96 |
+
first_name TEXT NOT NULL,
|
| 97 |
+
last_name TEXT NOT NULL,
|
| 98 |
+
title TEXT,
|
| 99 |
+
email TEXT,
|
| 100 |
+
phone TEXT,
|
| 101 |
+
linkedin TEXT,
|
| 102 |
+
is_primary INTEGER DEFAULT 0 CHECK(is_primary IN (0, 1)),
|
| 103 |
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
| 104 |
+
FOREIGN KEY(company_id) REFERENCES companies(id) ON DELETE CASCADE
|
| 105 |
+
);
|
| 106 |
+
|
| 107 |
+
CREATE TABLE IF NOT EXISTS activities (
|
| 108 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 109 |
+
company_id INTEGER NOT NULL,
|
| 110 |
+
contact_id INTEGER,
|
| 111 |
+
user_id INTEGER NOT NULL,
|
| 112 |
+
type TEXT NOT NULL CHECK(type IN ('Cold Call', 'Email', 'LinkedIn Message', 'Meeting', 'Follow-Up', 'Note')),
|
| 113 |
+
outcome TEXT,
|
| 114 |
+
notes TEXT,
|
| 115 |
+
next_action TEXT,
|
| 116 |
+
next_action_date TEXT,
|
| 117 |
+
occurred_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
| 118 |
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
| 119 |
+
FOREIGN KEY(company_id) REFERENCES companies(id) ON DELETE CASCADE,
|
| 120 |
+
FOREIGN KEY(contact_id) REFERENCES contacts(id) ON DELETE SET NULL,
|
| 121 |
+
FOREIGN KEY(user_id) REFERENCES users(id)
|
| 122 |
+
);
|
| 123 |
+
|
| 124 |
+
CREATE TABLE IF NOT EXISTS notes (
|
| 125 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 126 |
+
company_id INTEGER NOT NULL,
|
| 127 |
+
contact_id INTEGER,
|
| 128 |
+
user_id INTEGER NOT NULL,
|
| 129 |
+
body TEXT NOT NULL,
|
| 130 |
+
is_pinned INTEGER DEFAULT 0 CHECK(is_pinned IN (0, 1)),
|
| 131 |
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
| 132 |
+
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
| 133 |
+
FOREIGN KEY(company_id) REFERENCES companies(id) ON DELETE CASCADE,
|
| 134 |
+
FOREIGN KEY(contact_id) REFERENCES contacts(id) ON DELETE SET NULL,
|
| 135 |
+
FOREIGN KEY(user_id) REFERENCES users(id)
|
| 136 |
+
);
|
| 137 |
+
|
| 138 |
+
CREATE TABLE IF NOT EXISTS stage_changes (
|
| 139 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 140 |
+
company_id INTEGER NOT NULL,
|
| 141 |
+
from_stage_id INTEGER,
|
| 142 |
+
to_stage_id INTEGER NOT NULL,
|
| 143 |
+
changed_by_user_id INTEGER NOT NULL,
|
| 144 |
+
source TEXT NOT NULL CHECK(source IN ('manual', 'ai_suggested', 'ai_confirmed')),
|
| 145 |
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
| 146 |
+
FOREIGN KEY(company_id) REFERENCES companies(id) ON DELETE CASCADE,
|
| 147 |
+
FOREIGN KEY(from_stage_id) REFERENCES stages(id),
|
| 148 |
+
FOREIGN KEY(to_stage_id) REFERENCES stages(id),
|
| 149 |
+
FOREIGN KEY(changed_by_user_id) REFERENCES users(id)
|
| 150 |
+
);
|
| 151 |
+
|
| 152 |
+
CREATE TABLE IF NOT EXISTS ai_field_values (
|
| 153 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 154 |
+
company_id INTEGER NOT NULL,
|
| 155 |
+
field_name TEXT NOT NULL,
|
| 156 |
+
value TEXT,
|
| 157 |
+
source_url TEXT,
|
| 158 |
+
confidence_score REAL,
|
| 159 |
+
enriched_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
| 160 |
+
confirmed_by_user_id INTEGER,
|
| 161 |
+
confirmed_at TIMESTAMP,
|
| 162 |
+
FOREIGN KEY(company_id) REFERENCES companies(id) ON DELETE CASCADE,
|
| 163 |
+
FOREIGN KEY(confirmed_by_user_id) REFERENCES users(id)
|
| 164 |
+
);
|
| 165 |
+
|
| 166 |
+
CREATE TABLE IF NOT EXISTS import_batches (
|
| 167 |
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
| 168 |
+
user_id INTEGER NOT NULL,
|
| 169 |
+
filename TEXT,
|
| 170 |
+
status TEXT,
|
| 171 |
+
total_rows INTEGER,
|
| 172 |
+
added INTEGER,
|
| 173 |
+
updated INTEGER,
|
| 174 |
+
skipped INTEGER,
|
| 175 |
+
errored INTEGER,
|
| 176 |
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
| 177 |
+
completed_at TIMESTAMP,
|
| 178 |
+
FOREIGN KEY(user_id) REFERENCES users(id)
|
| 179 |
+
);
|
| 180 |
+
|
| 181 |
+
-- Indices for fast searching and filtering
|
| 182 |
+
CREATE INDEX IF NOT EXISTS idx_companies_stage ON companies(stage_id);
|
| 183 |
+
CREATE INDEX IF NOT EXISTS idx_companies_tenant ON companies(tenant_id);
|
| 184 |
+
CREATE INDEX IF NOT EXISTS idx_companies_rep ON companies(assigned_rep_id);
|
| 185 |
+
CREATE INDEX IF NOT EXISTS idx_contacts_company ON contacts(company_id);
|
| 186 |
+
CREATE INDEX IF NOT EXISTS idx_activities_company ON activities(company_id);
|
| 187 |
+
CREATE INDEX IF NOT EXISTS idx_notes_company ON notes(company_id);
|
scratch_check.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sqlite3
|
| 2 |
+
import json
|
| 3 |
+
|
| 4 |
+
conn = sqlite3.connect('interactions.db')
|
| 5 |
+
cursor = conn.cursor()
|
| 6 |
+
cursor.execute("SELECT output_json, logs_json FROM interactions WHERE kind='geo' ORDER BY created_at DESC LIMIT 1")
|
| 7 |
+
res = cursor.fetchone()
|
| 8 |
+
|
| 9 |
+
if res:
|
| 10 |
+
print("LOGS:")
|
| 11 |
+
logs = json.loads(res[1]) if res[1] else []
|
| 12 |
+
for log in logs[-20:]:
|
| 13 |
+
print(log)
|
| 14 |
+
|
| 15 |
+
print("\nOUTPUT_JSON (result):")
|
| 16 |
+
output = json.loads(res[0]) if res[0] else {}
|
| 17 |
+
print(json.dumps(output.get("result", "")[:1000], indent=2))
|
| 18 |
+
|
| 19 |
+
stages = output.get("stages", [])
|
| 20 |
+
if stages:
|
| 21 |
+
for stage in stages:
|
| 22 |
+
print(f"\nStage: {stage.get('step')}")
|
| 23 |
+
data = stage.get("data", "")
|
| 24 |
+
if isinstance(data, str):
|
| 25 |
+
print(data[:500] + "..." if len(data) > 500 else data)
|
scratch_check_app.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import sys
|
| 2 |
+
sys.path.append('.')
|
| 3 |
+
from services.ai_service import db_list_interactions
|
| 4 |
+
import json
|
| 5 |
+
|
| 6 |
+
# Get the last geo interaction
|
| 7 |
+
interactions = db_list_interactions(limit=1, kind="geo", include_full=True)
|
| 8 |
+
if interactions:
|
| 9 |
+
last = interactions[0]
|
| 10 |
+
print(f"Title: {last.get('title')}")
|
| 11 |
+
print(f"Status: {last.get('status')}")
|
| 12 |
+
print("----- RESULT -----")
|
| 13 |
+
result = last.get('result', {})
|
| 14 |
+
if isinstance(result, dict):
|
| 15 |
+
print(json.dumps(result.get('result', ''), indent=2))
|
| 16 |
+
|
| 17 |
+
stages = result.get('stages', [])
|
| 18 |
+
for stage in stages:
|
| 19 |
+
print(f"\nStage: {stage.get('step')}")
|
| 20 |
+
data = stage.get("data", "")
|
| 21 |
+
if isinstance(data, str):
|
| 22 |
+
print(data[:500] + "..." if len(data) > 500 else data)
|
| 23 |
+
else:
|
| 24 |
+
print(result)
|
| 25 |
+
else:
|
| 26 |
+
print("No interactions found.")
|
scripts/deploy-azure.ps1
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
param(
|
| 2 |
+
[string]$ResourceGroup = "ProspectIQ_centralindia",
|
| 3 |
+
[string]$ContainerApp = "prospectiq",
|
| 4 |
+
[string]$Registry = "prospectiqacr57421",
|
| 5 |
+
[string]$ImageName = "prospectiq",
|
| 6 |
+
[string]$Tag = ""
|
| 7 |
+
)
|
| 8 |
+
|
| 9 |
+
$ErrorActionPreference = "Stop"
|
| 10 |
+
|
| 11 |
+
if (-not $Tag) {
|
| 12 |
+
$gitSha = git rev-parse --short HEAD
|
| 13 |
+
$Tag = if ($gitSha) { $gitSha.Trim() } else { "latest" }
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
$loginServer = "$Registry.azurecr.io"
|
| 17 |
+
$image = "$loginServer/$ImageName`:$Tag"
|
| 18 |
+
$latest = "$loginServer/$ImageName`:latest"
|
| 19 |
+
|
| 20 |
+
Write-Host "Checking Azure login..."
|
| 21 |
+
az account show --query "{subscription:name,user:user.name}" -o table
|
| 22 |
+
|
| 23 |
+
Write-Host "Logging in to Azure Container Registry: $Registry"
|
| 24 |
+
az acr login --name $Registry
|
| 25 |
+
|
| 26 |
+
Write-Host "Building Docker image: $image"
|
| 27 |
+
docker build -t $image -t $latest .
|
| 28 |
+
|
| 29 |
+
Write-Host "Pushing Docker image: $image"
|
| 30 |
+
docker push $image
|
| 31 |
+
|
| 32 |
+
Write-Host "Pushing Docker image: $latest"
|
| 33 |
+
docker push $latest
|
| 34 |
+
|
| 35 |
+
Write-Host "Updating Azure Container App: $ContainerApp"
|
| 36 |
+
az containerapp update `
|
| 37 |
+
--name $ContainerApp `
|
| 38 |
+
--resource-group $ResourceGroup `
|
| 39 |
+
--image $image
|
| 40 |
+
|
| 41 |
+
$fqdn = az containerapp show `
|
| 42 |
+
--name $ContainerApp `
|
| 43 |
+
--resource-group $ResourceGroup `
|
| 44 |
+
--query properties.configuration.ingress.fqdn `
|
| 45 |
+
-o tsv
|
| 46 |
+
|
| 47 |
+
Write-Host ""
|
| 48 |
+
Write-Host "Deployment complete."
|
| 49 |
+
Write-Host "URL: https://$fqdn"
|
services/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Services package for ProspectIQ."""
|
services/ai_service.py
ADDED
|
@@ -0,0 +1,998 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
import contextlib
|
| 3 |
+
import json
|
| 4 |
+
import os
|
| 5 |
+
import re
|
| 6 |
+
import sqlite3
|
| 7 |
+
import subprocess
|
| 8 |
+
import sys
|
| 9 |
+
import threading
|
| 10 |
+
import time
|
| 11 |
+
import uuid
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
from typing import Any, Callable, TypeVar
|
| 14 |
+
|
| 15 |
+
# =====================================================================
|
| 16 |
+
# Paths & Lock Configuration
|
| 17 |
+
# =====================================================================
|
| 18 |
+
SERVICES_DIR = Path(__file__).resolve().parent
|
| 19 |
+
ROOT = SERVICES_DIR.parent
|
| 20 |
+
RESEARCH_DIR = SERVICES_DIR / "research"
|
| 21 |
+
PIPELINE_SCRIPT = RESEARCH_DIR / "pipeline.py"
|
| 22 |
+
RESEARCH_OUTPUT_DIR = RESEARCH_DIR / "pipe_output"
|
| 23 |
+
DATABASE_PATH = ROOT / "prospectiq_history.db"
|
| 24 |
+
SETTINGS_DIR = ROOT / "settings"
|
| 25 |
+
BASE_COMPANY_CONTEXT_PATH = SETTINGS_DIR / "base_company_context.md"
|
| 26 |
+
|
| 27 |
+
DB_LOCK = threading.Lock()
|
| 28 |
+
JOBS_LOCK = threading.Lock()
|
| 29 |
+
JOBS: dict[str, dict[str, Any]] = {}
|
| 30 |
+
JOB_CACHE_TTL = 24 * 60 * 60
|
| 31 |
+
|
| 32 |
+
T = TypeVar("T")
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
# =====================================================================
|
| 36 |
+
# API Keys Utility Section (Failover & Fallback)
|
| 37 |
+
# =====================================================================
|
| 38 |
+
DEFAULT_ENV_PATHS = (ROOT / ".env", Path.cwd() / ".env")
|
| 39 |
+
PAID_GEMINI_ENV_NAMES = (
|
| 40 |
+
"GEMINI_API_KEY_prime_1",
|
| 41 |
+
"GEMINI_API_KEY_prime_2",
|
| 42 |
+
"GEMINI_API_KEY_prime_3",
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def load_dotenv_values() -> dict[str, str]:
|
| 47 |
+
values: dict[str, str] = {}
|
| 48 |
+
for path in DEFAULT_ENV_PATHS:
|
| 49 |
+
if not path.exists():
|
| 50 |
+
continue
|
| 51 |
+
for raw_line in path.read_text(encoding="utf-8-sig", errors="replace").splitlines():
|
| 52 |
+
line = raw_line.strip()
|
| 53 |
+
if not line or line.startswith("#") or "=" not in line:
|
| 54 |
+
continue
|
| 55 |
+
name, value = line.split("=", 1)
|
| 56 |
+
values[name.strip()] = value.strip().strip('"').strip("'")
|
| 57 |
+
return values
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def env_value(name: str, dotenv_values: dict[str, str] | None = None) -> str:
|
| 61 |
+
values = dotenv_values if dotenv_values is not None else load_dotenv_values()
|
| 62 |
+
return os.environ.get(name, "").strip() or values.get(name, "").strip()
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def numbered_env_names(prefix: str, values: dict[str, str]) -> list[str]:
|
| 66 |
+
names = {
|
| 67 |
+
*[name for name in values if re.fullmatch(rf"{re.escape(prefix)}_\d+", name)],
|
| 68 |
+
*[name for name in os.environ if re.fullmatch(rf"{re.escape(prefix)}_\d+", name)],
|
| 69 |
+
}
|
| 70 |
+
return sorted(names, key=lambda name: int(name.rsplit("_", 1)[1]))
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def api_keys(primary_name: str, *preferred_names: str) -> list[str]:
|
| 74 |
+
values = load_dotenv_values()
|
| 75 |
+
keys: list[str] = []
|
| 76 |
+
|
| 77 |
+
for name in (*preferred_names, primary_name):
|
| 78 |
+
key = env_value(name, values)
|
| 79 |
+
if key and key not in keys:
|
| 80 |
+
keys.append(key)
|
| 81 |
+
|
| 82 |
+
for name in numbered_env_names(primary_name, values):
|
| 83 |
+
key = env_value(name, values)
|
| 84 |
+
if key and key not in keys:
|
| 85 |
+
keys.append(key)
|
| 86 |
+
|
| 87 |
+
return keys
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def gemini_keys(*preferred_names: str) -> list[str]:
|
| 91 |
+
values = load_dotenv_values()
|
| 92 |
+
keys: list[str] = []
|
| 93 |
+
for name in PAID_GEMINI_ENV_NAMES:
|
| 94 |
+
key = env_value(name, values)
|
| 95 |
+
if key and key not in keys:
|
| 96 |
+
keys.append(key)
|
| 97 |
+
return keys
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def groq_keys() -> list[str]:
|
| 101 |
+
return api_keys("GROQ_API_KEY")
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def with_gemini_key_fallback(preferred_names: tuple[str, ...], operation: Callable[[str], T]) -> T:
|
| 105 |
+
keys = gemini_keys(*preferred_names)
|
| 106 |
+
if not keys:
|
| 107 |
+
names = ", ".join(PAID_GEMINI_ENV_NAMES)
|
| 108 |
+
raise RuntimeError(f"No Gemini API keys configured. Set one of: {names}.")
|
| 109 |
+
|
| 110 |
+
failures: list[str] = []
|
| 111 |
+
for index, key in enumerate(keys, start=1):
|
| 112 |
+
try:
|
| 113 |
+
return operation(key)
|
| 114 |
+
except Exception as exc:
|
| 115 |
+
failures.append(f"key {index}/{len(keys)}: {type(exc).__name__}: {exc}")
|
| 116 |
+
|
| 117 |
+
raise RuntimeError("All configured Gemini API keys failed. " + " | ".join(failures))
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
# =====================================================================
|
| 121 |
+
# Database operations for prospectiq_history.db
|
| 122 |
+
# =====================================================================
|
| 123 |
+
|
| 124 |
+
def now() -> str:
|
| 125 |
+
return time.strftime("%Y-%m-%d %H:%M:%S")
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def to_json(value: Any) -> str:
|
| 129 |
+
return json.dumps(value, ensure_ascii=False, default=lambda val: str(val) if isinstance(val, Path) else str(val))
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def from_json(value: str | None, default: Any) -> Any:
|
| 133 |
+
if not value:
|
| 134 |
+
return default
|
| 135 |
+
try:
|
| 136 |
+
return json.loads(value)
|
| 137 |
+
except json.JSONDecodeError:
|
| 138 |
+
return default
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def get_db():
|
| 142 |
+
from db import get_db_connection, pg_pool
|
| 143 |
+
# If PG connection parameters are specified, use PostgresConnectionPool
|
| 144 |
+
if pg_pool is not None or (os.environ.get("SUPABASE_DB_HOST") and os.environ.get("SUPABASE_DB_PASSWORD")):
|
| 145 |
+
try:
|
| 146 |
+
return get_db_connection()
|
| 147 |
+
except Exception as e:
|
| 148 |
+
print(f"Error redirecting get_db to PostgreSQL: {e}")
|
| 149 |
+
|
| 150 |
+
connection = sqlite3.connect(DATABASE_PATH, timeout=30)
|
| 151 |
+
connection.row_factory = sqlite3.Row
|
| 152 |
+
return connection
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
@contextlib.contextmanager
|
| 156 |
+
def db_session():
|
| 157 |
+
from db import is_supabase_active
|
| 158 |
+
if is_supabase_active():
|
| 159 |
+
with get_db() as db:
|
| 160 |
+
yield db
|
| 161 |
+
else:
|
| 162 |
+
with DB_LOCK, get_db() as db:
|
| 163 |
+
yield db
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def init_database() -> None:
|
| 167 |
+
with db_session() as db:
|
| 168 |
+
db.execute(
|
| 169 |
+
"""
|
| 170 |
+
CREATE TABLE IF NOT EXISTS interactions (
|
| 171 |
+
id TEXT PRIMARY KEY,
|
| 172 |
+
kind TEXT NOT NULL,
|
| 173 |
+
title TEXT NOT NULL,
|
| 174 |
+
status TEXT NOT NULL,
|
| 175 |
+
input_json TEXT NOT NULL DEFAULT '{}',
|
| 176 |
+
output_json TEXT NOT NULL DEFAULT '{}',
|
| 177 |
+
result_text TEXT NOT NULL DEFAULT '',
|
| 178 |
+
logs_json TEXT NOT NULL DEFAULT '[]',
|
| 179 |
+
error TEXT NOT NULL DEFAULT '',
|
| 180 |
+
run_dir TEXT NOT NULL DEFAULT '',
|
| 181 |
+
profile_path TEXT NOT NULL DEFAULT '',
|
| 182 |
+
command_json TEXT NOT NULL DEFAULT '[]',
|
| 183 |
+
returncode INTEGER,
|
| 184 |
+
pid INTEGER,
|
| 185 |
+
created_at TEXT NOT NULL,
|
| 186 |
+
started_at TEXT NOT NULL DEFAULT '',
|
| 187 |
+
finished_at TEXT NOT NULL DEFAULT '',
|
| 188 |
+
updated_at TEXT NOT NULL
|
| 189 |
+
)
|
| 190 |
+
"""
|
| 191 |
+
)
|
| 192 |
+
db.execute("CREATE INDEX IF NOT EXISTS idx_interactions_created_at ON interactions(created_at DESC)")
|
| 193 |
+
db.execute("CREATE INDEX IF NOT EXISTS idx_interactions_kind ON interactions(kind)")
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def result_text_from_job(job: dict[str, Any]) -> str:
|
| 197 |
+
result = job.get("result")
|
| 198 |
+
if isinstance(result, dict):
|
| 199 |
+
for key in ("text", "result", "answer", "profile_text"):
|
| 200 |
+
value = result.get(key)
|
| 201 |
+
if isinstance(value, str):
|
| 202 |
+
return value
|
| 203 |
+
if isinstance(result, str):
|
| 204 |
+
return result
|
| 205 |
+
return ""
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
def profile_path_from_job(job: dict[str, Any]) -> str:
|
| 209 |
+
result = job.get("result")
|
| 210 |
+
if isinstance(result, dict):
|
| 211 |
+
return str(result.get("path") or result.get("profile_path") or "")
|
| 212 |
+
return ""
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
def db_insert_interaction(job: dict[str, Any], input_payload: dict[str, Any] | None = None) -> None:
|
| 216 |
+
with db_session() as db:
|
| 217 |
+
db.execute(
|
| 218 |
+
"""
|
| 219 |
+
INSERT OR REPLACE INTO interactions (
|
| 220 |
+
id, kind, title, status, input_json, output_json, result_text, logs_json,
|
| 221 |
+
error, run_dir, profile_path, command_json, returncode, pid,
|
| 222 |
+
created_at, started_at, finished_at, updated_at
|
| 223 |
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
| 224 |
+
""",
|
| 225 |
+
(
|
| 226 |
+
job["id"],
|
| 227 |
+
job["kind"],
|
| 228 |
+
job["title"],
|
| 229 |
+
job["status"],
|
| 230 |
+
to_json(input_payload or {}),
|
| 231 |
+
to_json(job.get("result") or {}),
|
| 232 |
+
result_text_from_job(job),
|
| 233 |
+
to_json(job.get("logs") or []),
|
| 234 |
+
job.get("error", ""),
|
| 235 |
+
job.get("run_dir", ""),
|
| 236 |
+
profile_path_from_job(job),
|
| 237 |
+
to_json(job.get("command") or []),
|
| 238 |
+
job.get("returncode"),
|
| 239 |
+
job.get("pid"),
|
| 240 |
+
job.get("created_at", now()),
|
| 241 |
+
job.get("started_at", ""),
|
| 242 |
+
job.get("finished_at", ""),
|
| 243 |
+
now(),
|
| 244 |
+
),
|
| 245 |
+
)
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
def db_update_interaction(job_id: str, **changes: Any) -> None:
|
| 249 |
+
allowed = {
|
| 250 |
+
"status",
|
| 251 |
+
"error",
|
| 252 |
+
"run_dir",
|
| 253 |
+
"returncode",
|
| 254 |
+
"pid",
|
| 255 |
+
"started_at",
|
| 256 |
+
"finished_at",
|
| 257 |
+
}
|
| 258 |
+
assignments: list[str] = []
|
| 259 |
+
values: list[Any] = []
|
| 260 |
+
for key, value in changes.items():
|
| 261 |
+
if key in allowed:
|
| 262 |
+
assignments.append(f"{key} = ?")
|
| 263 |
+
values.append(value)
|
| 264 |
+
elif key == "result":
|
| 265 |
+
assignments.extend(["output_json = ?", "result_text = ?", "profile_path = ?"])
|
| 266 |
+
temp_job = {"result": value}
|
| 267 |
+
values.extend([to_json(value or {}), result_text_from_job(temp_job), profile_path_from_job(temp_job)])
|
| 268 |
+
elif key == "logs":
|
| 269 |
+
assignments.append("logs_json = ?")
|
| 270 |
+
values.append(to_json(value or []))
|
| 271 |
+
elif key == "command":
|
| 272 |
+
assignments.append("command_json = ?")
|
| 273 |
+
values.append(to_json(value or []))
|
| 274 |
+
|
| 275 |
+
if not assignments:
|
| 276 |
+
return
|
| 277 |
+
|
| 278 |
+
assignments.append("updated_at = ?")
|
| 279 |
+
values.append(now())
|
| 280 |
+
values.append(job_id)
|
| 281 |
+
with db_session() as db:
|
| 282 |
+
db.execute(f"UPDATE interactions SET {', '.join(assignments)} WHERE id = ?", values)
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
def db_get_interaction(job_id: str) -> dict[str, Any] | None:
|
| 286 |
+
with db_session() as db:
|
| 287 |
+
row = db.execute("SELECT * FROM interactions WHERE id = ?", (job_id,)).fetchone()
|
| 288 |
+
return interaction_from_row(row) if row else None
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
def delete_job_cache(job_id: str) -> None:
|
| 292 |
+
try:
|
| 293 |
+
from db import cache_delete
|
| 294 |
+
cache_delete(job_cache_key(job_id))
|
| 295 |
+
except Exception:
|
| 296 |
+
pass
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
def db_prune_geo_history(max_age_minutes: int = 20) -> int:
|
| 300 |
+
"""Remove stale active Geo rows from history storage."""
|
| 301 |
+
max_age_minutes = max(1, int(max_age_minutes or 20))
|
| 302 |
+
cutoff = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(time.time() - (max_age_minutes * 60)))
|
| 303 |
+
|
| 304 |
+
with db_session() as db:
|
| 305 |
+
rows = db.execute(
|
| 306 |
+
"""
|
| 307 |
+
SELECT id FROM interactions
|
| 308 |
+
WHERE kind = ?
|
| 309 |
+
AND status IN (?, ?)
|
| 310 |
+
AND COALESCE(NULLIF(started_at, ''), created_at) <= ?
|
| 311 |
+
""",
|
| 312 |
+
("geo", "queued", "running", cutoff),
|
| 313 |
+
).fetchall()
|
| 314 |
+
stale_ids = [str(row["id"]) for row in rows]
|
| 315 |
+
if stale_ids:
|
| 316 |
+
placeholders = ", ".join(["?"] * len(stale_ids))
|
| 317 |
+
db.execute(f"DELETE FROM interactions WHERE id IN ({placeholders})", stale_ids)
|
| 318 |
+
|
| 319 |
+
if stale_ids:
|
| 320 |
+
with JOBS_LOCK:
|
| 321 |
+
for job_id in stale_ids:
|
| 322 |
+
JOBS.pop(job_id, None)
|
| 323 |
+
for job_id in stale_ids:
|
| 324 |
+
delete_job_cache(job_id)
|
| 325 |
+
|
| 326 |
+
return len(stale_ids)
|
| 327 |
+
|
| 328 |
+
|
| 329 |
+
def job_cache_key(job_id: str) -> str:
|
| 330 |
+
return f"prospectiq:job:{job_id}"
|
| 331 |
+
|
| 332 |
+
|
| 333 |
+
def cache_job_state(job: dict[str, Any] | None) -> None:
|
| 334 |
+
if not job or not job.get("id"):
|
| 335 |
+
return
|
| 336 |
+
try:
|
| 337 |
+
from db import cache_set
|
| 338 |
+
cache_set(job_cache_key(str(job["id"])), job, timeout=JOB_CACHE_TTL)
|
| 339 |
+
except Exception:
|
| 340 |
+
pass
|
| 341 |
+
|
| 342 |
+
|
| 343 |
+
def get_cached_job_state(job_id: str) -> dict[str, Any] | None:
|
| 344 |
+
try:
|
| 345 |
+
from db import cache_get
|
| 346 |
+
cached = cache_get(job_cache_key(job_id))
|
| 347 |
+
return cached if isinstance(cached, dict) else None
|
| 348 |
+
except Exception:
|
| 349 |
+
return None
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
def get_job_state(job_id: str) -> dict[str, Any] | None:
|
| 353 |
+
cached = get_cached_job_state(job_id)
|
| 354 |
+
if cached:
|
| 355 |
+
if not cached.get("logs"):
|
| 356 |
+
saved = db_get_interaction(job_id)
|
| 357 |
+
if saved and saved.get("logs"):
|
| 358 |
+
cached["logs"] = saved.get("logs") or []
|
| 359 |
+
if not cached.get("result") and saved.get("result"):
|
| 360 |
+
cached["result"] = saved.get("result")
|
| 361 |
+
cache_job_state(cached)
|
| 362 |
+
return cached
|
| 363 |
+
saved = db_get_interaction(job_id)
|
| 364 |
+
if saved:
|
| 365 |
+
cache_job_state(saved)
|
| 366 |
+
return saved
|
| 367 |
+
with JOBS_LOCK:
|
| 368 |
+
job = JOBS.get(job_id)
|
| 369 |
+
return dict(job) if job else None
|
| 370 |
+
|
| 371 |
+
|
| 372 |
+
def db_list_interactions(limit: int = 80, kind: str = "", include_full: bool = False) -> list[dict[str, Any]]:
|
| 373 |
+
"""List interactions. By default selects only lightweight columns for fast listing.
|
| 374 |
+
Set include_full=True to also fetch input_json, output_json, logs_json (slow over network).
|
| 375 |
+
"""
|
| 376 |
+
limit = max(1, min(int(limit), 250))
|
| 377 |
+
kind = kind.strip()
|
| 378 |
+
|
| 379 |
+
if include_full:
|
| 380 |
+
cols = "*"
|
| 381 |
+
else:
|
| 382 |
+
# Only fetch lightweight columns — skip logs_json, input_json, output_json
|
| 383 |
+
# which can be very large and slow to transfer from Supabase
|
| 384 |
+
cols = "id, kind, title, status, result_text, error, run_dir, profile_path, returncode, pid, created_at, started_at, finished_at, updated_at"
|
| 385 |
+
|
| 386 |
+
with db_session() as db:
|
| 387 |
+
if kind:
|
| 388 |
+
if kind == "geo":
|
| 389 |
+
rows = db.execute(
|
| 390 |
+
f"SELECT {cols} FROM interactions WHERE kind = ? AND status != ? ORDER BY created_at DESC, id DESC LIMIT ?",
|
| 391 |
+
(kind, "queued", limit),
|
| 392 |
+
).fetchall()
|
| 393 |
+
else:
|
| 394 |
+
rows = db.execute(
|
| 395 |
+
f"SELECT {cols} FROM interactions WHERE kind = ? ORDER BY created_at DESC, id DESC LIMIT ?",
|
| 396 |
+
(kind, limit),
|
| 397 |
+
).fetchall()
|
| 398 |
+
else:
|
| 399 |
+
rows = db.execute(
|
| 400 |
+
f"SELECT {cols} FROM interactions ORDER BY created_at DESC, id DESC LIMIT ?",
|
| 401 |
+
(limit,),
|
| 402 |
+
).fetchall()
|
| 403 |
+
return [interaction_from_row(row, include_logs=False, lightweight=not include_full) for row in rows]
|
| 404 |
+
|
| 405 |
+
|
| 406 |
+
def db_list_interactions_by_kinds(kinds: list[str], limit: int = 80) -> list[dict[str, Any]]:
|
| 407 |
+
"""Efficiently list interactions filtered to specific kinds with input_json and output_json.
|
| 408 |
+
Uses a single DB query with WHERE kind IN (...) instead of fetching all and filtering in Python.
|
| 409 |
+
"""
|
| 410 |
+
if not kinds:
|
| 411 |
+
return []
|
| 412 |
+
limit = max(1, min(int(limit), 250))
|
| 413 |
+
# Lightweight columns plus input_json and output_json for memory endpoint
|
| 414 |
+
cols = "id, kind, title, status, input_json, output_json, result_text, error, run_dir, profile_path, returncode, pid, created_at, started_at, finished_at, updated_at"
|
| 415 |
+
placeholders = ", ".join(["?"] * len(kinds))
|
| 416 |
+
with db_session() as db:
|
| 417 |
+
rows = db.execute(
|
| 418 |
+
f"SELECT {cols} FROM interactions WHERE kind IN ({placeholders}) ORDER BY created_at DESC, id DESC LIMIT ?",
|
| 419 |
+
(*kinds, limit),
|
| 420 |
+
).fetchall()
|
| 421 |
+
return [interaction_from_row(row, include_logs=False, lightweight=False) for row in rows]
|
| 422 |
+
|
| 423 |
+
|
| 424 |
+
def interaction_from_row(row, include_logs: bool = True, lightweight: bool = False) -> dict[str, Any]:
|
| 425 |
+
item = {
|
| 426 |
+
"id": row["id"],
|
| 427 |
+
"kind": row["kind"],
|
| 428 |
+
"title": row["title"],
|
| 429 |
+
"status": row["status"],
|
| 430 |
+
"created_at": row["created_at"],
|
| 431 |
+
"started_at": row["started_at"],
|
| 432 |
+
"finished_at": row["finished_at"],
|
| 433 |
+
"updated_at": row["updated_at"],
|
| 434 |
+
"result_text": row["result_text"],
|
| 435 |
+
"error": row["error"],
|
| 436 |
+
"run_dir": row["run_dir"],
|
| 437 |
+
"profile_path": row["profile_path"],
|
| 438 |
+
"returncode": row["returncode"],
|
| 439 |
+
"pid": row["pid"],
|
| 440 |
+
}
|
| 441 |
+
if lightweight:
|
| 442 |
+
# When listing, we don't have the JSON columns — return empty defaults
|
| 443 |
+
item["input"] = {}
|
| 444 |
+
item["result"] = {}
|
| 445 |
+
item["command"] = []
|
| 446 |
+
else:
|
| 447 |
+
item["input"] = from_json(row["input_json"], {})
|
| 448 |
+
item["result"] = from_json(row["output_json"], {})
|
| 449 |
+
item["command"] = from_json(row["command_json"], []) if "command_json" in (row.keys() if hasattr(row, 'keys') else []) else []
|
| 450 |
+
if item["kind"] == "research":
|
| 451 |
+
result = item["result"] if isinstance(item.get("result"), dict) else {}
|
| 452 |
+
if item["result_text"] and not result.get("text"):
|
| 453 |
+
result["text"] = item["result_text"]
|
| 454 |
+
if item["profile_path"] and not result.get("path"):
|
| 455 |
+
result["path"] = item["profile_path"]
|
| 456 |
+
item["result"] = result
|
| 457 |
+
if include_logs:
|
| 458 |
+
item["logs"] = from_json(row["logs_json"], []) if "logs_json" in (row.keys() if hasattr(row, 'keys') else []) else []
|
| 459 |
+
return item
|
| 460 |
+
|
| 461 |
+
|
| 462 |
+
# =====================================================================
|
| 463 |
+
# Jobs Management & Background Runners Section
|
| 464 |
+
# =====================================================================
|
| 465 |
+
|
| 466 |
+
def new_job(kind: str, title: str, input_payload: dict[str, Any] | None = None) -> str:
|
| 467 |
+
job_id = f"{time.strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex[:8]}"
|
| 468 |
+
with JOBS_LOCK:
|
| 469 |
+
JOBS[job_id] = {
|
| 470 |
+
"id": job_id,
|
| 471 |
+
"kind": kind,
|
| 472 |
+
"title": title,
|
| 473 |
+
"status": "queued",
|
| 474 |
+
"created_at": now(),
|
| 475 |
+
"started_at": "",
|
| 476 |
+
"finished_at": "",
|
| 477 |
+
"input": input_payload or {},
|
| 478 |
+
"logs": [],
|
| 479 |
+
"result": None,
|
| 480 |
+
"error": "",
|
| 481 |
+
}
|
| 482 |
+
job = dict(JOBS[job_id])
|
| 483 |
+
db_insert_interaction(job, input_payload)
|
| 484 |
+
cache_job_state(job)
|
| 485 |
+
return job_id
|
| 486 |
+
|
| 487 |
+
|
| 488 |
+
def update_job(job_id: str, **changes: Any) -> None:
|
| 489 |
+
db_changes: dict[str, Any] = {}
|
| 490 |
+
job_for_cache: dict[str, Any] | None = None
|
| 491 |
+
with JOBS_LOCK:
|
| 492 |
+
job = JOBS.get(job_id)
|
| 493 |
+
if not job:
|
| 494 |
+
cached = get_cached_job_state(job_id) or db_get_interaction(job_id)
|
| 495 |
+
if cached:
|
| 496 |
+
cached.update(changes)
|
| 497 |
+
job_for_cache = cached
|
| 498 |
+
db_insert_interaction(job_for_cache, job_for_cache.get("input") if isinstance(job_for_cache.get("input"), dict) else {})
|
| 499 |
+
else:
|
| 500 |
+
db_update_interaction(job_id, **changes)
|
| 501 |
+
cache_job_state(job_for_cache)
|
| 502 |
+
return
|
| 503 |
+
job.update(changes)
|
| 504 |
+
db_changes = {key: job.get(key) for key in changes}
|
| 505 |
+
job_for_cache = dict(job)
|
| 506 |
+
if job_for_cache:
|
| 507 |
+
db_insert_interaction(job_for_cache, job_for_cache.get("input") if isinstance(job_for_cache.get("input"), dict) else {})
|
| 508 |
+
else:
|
| 509 |
+
db_update_interaction(job_id, **db_changes)
|
| 510 |
+
cache_job_state(job_for_cache)
|
| 511 |
+
|
| 512 |
+
|
| 513 |
+
def append_log(job_id: str, line: str) -> None:
|
| 514 |
+
with JOBS_LOCK:
|
| 515 |
+
job = JOBS.get(job_id)
|
| 516 |
+
if not job:
|
| 517 |
+
saved = get_cached_job_state(job_id) or db_get_interaction(job_id)
|
| 518 |
+
logs = list(saved.get("logs") or []) if saved else []
|
| 519 |
+
logs.append(line)
|
| 520 |
+
if len(logs) > 2500:
|
| 521 |
+
logs = logs[-2500:]
|
| 522 |
+
run_dir = str(saved.get("run_dir") or "") if saved else ""
|
| 523 |
+
match = re.search(r"(?:Run folder|Everything is in):\s*(.+)", line)
|
| 524 |
+
if match:
|
| 525 |
+
run_dir = match.group(1).strip()
|
| 526 |
+
db_update_interaction(job_id, logs=logs, run_dir=run_dir)
|
| 527 |
+
if saved:
|
| 528 |
+
saved["logs"] = logs
|
| 529 |
+
saved["run_dir"] = run_dir
|
| 530 |
+
cache_job_state(saved)
|
| 531 |
+
return
|
| 532 |
+
job["logs"].append(line)
|
| 533 |
+
if len(job["logs"]) > 2500:
|
| 534 |
+
job["logs"] = job["logs"][-2500:]
|
| 535 |
+
|
| 536 |
+
match = re.search(r"(?:Run folder|Everything is in):\s*(.+)", line)
|
| 537 |
+
if match:
|
| 538 |
+
job["run_dir"] = match.group(1).strip()
|
| 539 |
+
logs = list(job["logs"])
|
| 540 |
+
run_dir = job.get("run_dir", "")
|
| 541 |
+
cached_job = dict(job)
|
| 542 |
+
db_update_interaction(job_id, logs=logs, run_dir=run_dir)
|
| 543 |
+
cache_job_state(cached_job)
|
| 544 |
+
|
| 545 |
+
|
| 546 |
+
def current_job_status(job_id: str) -> str:
|
| 547 |
+
cached = get_cached_job_state(job_id)
|
| 548 |
+
if cached:
|
| 549 |
+
return str(cached.get("status") or "")
|
| 550 |
+
with JOBS_LOCK:
|
| 551 |
+
job = JOBS.get(job_id)
|
| 552 |
+
if job:
|
| 553 |
+
return str(job.get("status") or "")
|
| 554 |
+
saved = db_get_interaction(job_id)
|
| 555 |
+
return str(saved.get("status") or "") if saved else ""
|
| 556 |
+
|
| 557 |
+
|
| 558 |
+
def stop_process_tree(pid: int) -> None:
|
| 559 |
+
if os.name == "nt":
|
| 560 |
+
subprocess.run(["taskkill", "/PID", str(pid), "/T", "/F"], capture_output=True, text=True)
|
| 561 |
+
else:
|
| 562 |
+
try:
|
| 563 |
+
os.kill(pid, 15)
|
| 564 |
+
except ProcessLookupError:
|
| 565 |
+
pass
|
| 566 |
+
|
| 567 |
+
|
| 568 |
+
def build_search_query(card_data: dict[str, Any]) -> str:
|
| 569 |
+
person = card_data.get("person") or {}
|
| 570 |
+
company = card_data.get("company") or {}
|
| 571 |
+
address = company.get("address") or {}
|
| 572 |
+
parts = [
|
| 573 |
+
person.get("first_name"),
|
| 574 |
+
person.get("last_name"),
|
| 575 |
+
person.get("job_title"),
|
| 576 |
+
company.get("company_name"),
|
| 577 |
+
address.get("city") or address.get("full_address"),
|
| 578 |
+
]
|
| 579 |
+
return " ".join(str(part).strip() for part in parts if str(part or "").strip())
|
| 580 |
+
|
| 581 |
+
|
| 582 |
+
def clean_int(value: Any, default: int, lower: int, upper: int) -> int:
|
| 583 |
+
try:
|
| 584 |
+
number = int(value)
|
| 585 |
+
except (TypeError, ValueError):
|
| 586 |
+
return default
|
| 587 |
+
return max(lower, min(upper, number))
|
| 588 |
+
|
| 589 |
+
|
| 590 |
+
def absorb_env() -> dict[str, str]:
|
| 591 |
+
env = os.environ.copy()
|
| 592 |
+
for path in (ROOT / ".env", RESEARCH_DIR / ".env"):
|
| 593 |
+
if not path.exists():
|
| 594 |
+
continue
|
| 595 |
+
for raw_line in path.read_text(encoding="utf-8-sig", errors="replace").splitlines():
|
| 596 |
+
line = raw_line.strip()
|
| 597 |
+
if not line or line.startswith("#") or "=" not in line:
|
| 598 |
+
continue
|
| 599 |
+
key, value = line.split("=", 1)
|
| 600 |
+
key = key.strip().lstrip("\ufeff")
|
| 601 |
+
value = value.strip().strip('"').strip("'")
|
| 602 |
+
if key:
|
| 603 |
+
env.setdefault(key, value)
|
| 604 |
+
return env
|
| 605 |
+
|
| 606 |
+
|
| 607 |
+
def safe_read_text(path: Path, max_chars: int = 250_000) -> str:
|
| 608 |
+
text = path.read_text(encoding="utf-8", errors="replace")
|
| 609 |
+
if len(text) > max_chars:
|
| 610 |
+
return text[:max_chars] + "\n\n[Output truncated in UI.]"
|
| 611 |
+
return text
|
| 612 |
+
|
| 613 |
+
|
| 614 |
+
def get_base_company_context() -> str:
|
| 615 |
+
if not BASE_COMPANY_CONTEXT_PATH.exists():
|
| 616 |
+
return ""
|
| 617 |
+
return safe_read_text(BASE_COMPANY_CONTEXT_PATH, max_chars=120_000).strip()
|
| 618 |
+
|
| 619 |
+
|
| 620 |
+
def save_base_company_context(content: str) -> dict[str, Any]:
|
| 621 |
+
cleaned = str(content or "").strip()
|
| 622 |
+
if not cleaned:
|
| 623 |
+
raise ValueError("Base company context cannot be empty.")
|
| 624 |
+
SETTINGS_DIR.mkdir(parents=True, exist_ok=True)
|
| 625 |
+
BASE_COMPANY_CONTEXT_PATH.write_text(cleaned + "\n", encoding="utf-8")
|
| 626 |
+
stat = BASE_COMPANY_CONTEXT_PATH.stat()
|
| 627 |
+
return {
|
| 628 |
+
"configured": True,
|
| 629 |
+
"path": str(BASE_COMPANY_CONTEXT_PATH),
|
| 630 |
+
"filename": BASE_COMPANY_CONTEXT_PATH.name,
|
| 631 |
+
"size": stat.st_size,
|
| 632 |
+
"updated_at": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(stat.st_mtime)),
|
| 633 |
+
}
|
| 634 |
+
|
| 635 |
+
|
| 636 |
+
def base_company_context_status(include_content: bool = False) -> dict[str, Any]:
|
| 637 |
+
if not BASE_COMPANY_CONTEXT_PATH.exists():
|
| 638 |
+
return {
|
| 639 |
+
"configured": False,
|
| 640 |
+
"path": str(BASE_COMPANY_CONTEXT_PATH),
|
| 641 |
+
"filename": BASE_COMPANY_CONTEXT_PATH.name,
|
| 642 |
+
"size": 0,
|
| 643 |
+
"updated_at": "",
|
| 644 |
+
"content": "" if include_content else None,
|
| 645 |
+
}
|
| 646 |
+
stat = BASE_COMPANY_CONTEXT_PATH.stat()
|
| 647 |
+
content = get_base_company_context() if include_content else ""
|
| 648 |
+
return {
|
| 649 |
+
"configured": bool(content if include_content else stat.st_size),
|
| 650 |
+
"path": str(BASE_COMPANY_CONTEXT_PATH),
|
| 651 |
+
"filename": BASE_COMPANY_CONTEXT_PATH.name,
|
| 652 |
+
"size": stat.st_size,
|
| 653 |
+
"updated_at": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(stat.st_mtime)),
|
| 654 |
+
"content": content if include_content else None,
|
| 655 |
+
}
|
| 656 |
+
|
| 657 |
+
|
| 658 |
+
def is_usable_profile_text(text: str) -> bool:
|
| 659 |
+
stripped = text.strip()
|
| 660 |
+
if not stripped:
|
| 661 |
+
return False
|
| 662 |
+
lowered = stripped.lower()
|
| 663 |
+
unusable_prefixes = (
|
| 664 |
+
"gemini enrichment failed",
|
| 665 |
+
"gemini enrichment skipped",
|
| 666 |
+
"gemini enrichment returned an empty response",
|
| 667 |
+
)
|
| 668 |
+
return not lowered.startswith(unusable_prefixes)
|
| 669 |
+
|
| 670 |
+
|
| 671 |
+
def safe_read_profile(path: Path, max_chars: int = 250_000) -> str:
|
| 672 |
+
text = safe_read_text(path, max_chars=max_chars)
|
| 673 |
+
return text if is_usable_profile_text(text) else ""
|
| 674 |
+
|
| 675 |
+
|
| 676 |
+
def resolve_project_file(raw_path: str) -> Path:
|
| 677 |
+
path = Path(raw_path)
|
| 678 |
+
if not path.is_absolute():
|
| 679 |
+
path = ROOT / path
|
| 680 |
+
resolved = path.resolve()
|
| 681 |
+
root = ROOT.resolve()
|
| 682 |
+
if os.path.commonpath([str(root), str(resolved)]) != str(root):
|
| 683 |
+
raise ValueError("Path is outside this project.")
|
| 684 |
+
if not resolved.exists() or not resolved.is_file():
|
| 685 |
+
raise FileNotFoundError("Profile not found.")
|
| 686 |
+
return resolved
|
| 687 |
+
|
| 688 |
+
|
| 689 |
+
def fallback_profile_path(path: Path) -> Path:
|
| 690 |
+
if path.name == "final_integrated_company_profile_gemini_enriched.txt":
|
| 691 |
+
fallback = path.with_name("final_integrated_company_profile.txt")
|
| 692 |
+
if fallback.exists() and safe_read_profile(fallback):
|
| 693 |
+
return fallback
|
| 694 |
+
return path
|
| 695 |
+
|
| 696 |
+
|
| 697 |
+
def find_final_profile(run_dir: str | Path | None) -> dict[str, str]:
|
| 698 |
+
if not run_dir:
|
| 699 |
+
return {"path": "", "text": ""}
|
| 700 |
+
|
| 701 |
+
base = Path(str(run_dir))
|
| 702 |
+
if not base.is_absolute():
|
| 703 |
+
base = ROOT / base
|
| 704 |
+
if not base.exists():
|
| 705 |
+
return {"path": str(base), "text": ""}
|
| 706 |
+
|
| 707 |
+
candidates = [
|
| 708 |
+
*sorted(base.glob("*_profile.md"), key=lambda path: path.stat().st_mtime, reverse=True),
|
| 709 |
+
*sorted(base.glob("*_profile.txt"), key=lambda path: path.stat().st_mtime, reverse=True),
|
| 710 |
+
base / "final_integrated_company_profile_gemini_enriched.txt",
|
| 711 |
+
base / "final_integrated_company_profile.txt",
|
| 712 |
+
]
|
| 713 |
+
for candidate in candidates:
|
| 714 |
+
if candidate.exists():
|
| 715 |
+
text = safe_read_profile(candidate)
|
| 716 |
+
if text:
|
| 717 |
+
return {"path": str(candidate), "text": text}
|
| 718 |
+
return {"path": str(base), "text": ""}
|
| 719 |
+
|
| 720 |
+
|
| 721 |
+
def list_profiles() -> list[dict[str, Any]]:
|
| 722 |
+
profiles: list[dict[str, Any]] = []
|
| 723 |
+
if not RESEARCH_OUTPUT_DIR.exists():
|
| 724 |
+
return profiles
|
| 725 |
+
|
| 726 |
+
candidate_paths = [
|
| 727 |
+
*RESEARCH_OUTPUT_DIR.glob("*_profile.md"),
|
| 728 |
+
*RESEARCH_OUTPUT_DIR.glob("*_profile.txt"),
|
| 729 |
+
*RESEARCH_OUTPUT_DIR.glob("*/*_profile.md"),
|
| 730 |
+
*RESEARCH_OUTPUT_DIR.glob("*/*_profile.txt"),
|
| 731 |
+
*RESEARCH_OUTPUT_DIR.glob("*/final_integrated_company_profile*.txt"),
|
| 732 |
+
]
|
| 733 |
+
for path in candidate_paths:
|
| 734 |
+
if not safe_read_profile(path, max_chars=4096):
|
| 735 |
+
continue
|
| 736 |
+
stat = path.stat()
|
| 737 |
+
profiles.append(
|
| 738 |
+
{
|
| 739 |
+
"name": path.stem if path.parent == RESEARCH_OUTPUT_DIR else path.parent.name,
|
| 740 |
+
"file": path.name,
|
| 741 |
+
"path": str(path),
|
| 742 |
+
"modified": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(stat.st_mtime)),
|
| 743 |
+
"size": stat.st_size,
|
| 744 |
+
}
|
| 745 |
+
)
|
| 746 |
+
return sorted(profiles, key=lambda item: item["modified"], reverse=True)[:40]
|
| 747 |
+
|
| 748 |
+
|
| 749 |
+
def run_geo_job(job_id: str, query: str) -> None:
|
| 750 |
+
if current_job_status(job_id) == "canceled":
|
| 751 |
+
append_log(job_id, "Regional Prospecting was stopped before the pipeline started.")
|
| 752 |
+
return
|
| 753 |
+
update_job(job_id, status="running", started_at=now())
|
| 754 |
+
append_log(job_id, "Starting geo-based company finder and ranker.")
|
| 755 |
+
try:
|
| 756 |
+
from services.geo_search import run_pipeline as run_geo_pipeline
|
| 757 |
+
from services.geo_search import run_pipeline_streaming as run_geo_pipeline_streaming
|
| 758 |
+
from services.export_service import parse_geo_companies, parse_geo_companies_from_stages
|
| 759 |
+
|
| 760 |
+
stages: list[dict[str, Any]] = []
|
| 761 |
+
maps_sources: list[dict[str, Any]] = []
|
| 762 |
+
final_ranking = ""
|
| 763 |
+
fallback_notice = ""
|
| 764 |
+
|
| 765 |
+
for event in run_geo_pipeline_streaming(query):
|
| 766 |
+
if current_job_status(job_id) == "canceled":
|
| 767 |
+
append_log(job_id, "Regional Prospecting pipeline was stopped by user.")
|
| 768 |
+
return
|
| 769 |
+
stages.append(event)
|
| 770 |
+
step = str(event.get("step") or "")
|
| 771 |
+
step_names = {
|
| 772 |
+
"input_parsed": "Input parsed",
|
| 773 |
+
"maps_done": "Fetching maps",
|
| 774 |
+
"search_done": "Fetching internet",
|
| 775 |
+
"checker_done": "Verifying",
|
| 776 |
+
"url_verify_done": "Verifying URLs",
|
| 777 |
+
"judge_done": "Ranking"
|
| 778 |
+
}
|
| 779 |
+
display_name = step_names.get(step, step)
|
| 780 |
+
append_log(job_id, f"{display_name}...")
|
| 781 |
+
if step == "maps_done":
|
| 782 |
+
maps_sources = event.get("maps_sources") or []
|
| 783 |
+
elif step == "judge_done":
|
| 784 |
+
final_ranking = str(event.get("data") or "")
|
| 785 |
+
ranked_companies = parse_geo_companies(final_ranking)
|
| 786 |
+
fallback_companies = []
|
| 787 |
+
if final_ranking and not ranked_companies:
|
| 788 |
+
fallback_companies = parse_geo_companies_from_stages(stages)
|
| 789 |
+
if fallback_companies:
|
| 790 |
+
fallback_notice = "Ranking was not available, so these discovered companies are shown unranked."
|
| 791 |
+
update_job(
|
| 792 |
+
job_id,
|
| 793 |
+
result={
|
| 794 |
+
"result": final_ranking or str(event.get("data") or ""),
|
| 795 |
+
"stages": stages,
|
| 796 |
+
"maps_sources": maps_sources,
|
| 797 |
+
"companies": ranked_companies or fallback_companies,
|
| 798 |
+
"ranking_unavailable": bool(fallback_companies and not ranked_companies),
|
| 799 |
+
"notice": fallback_notice,
|
| 800 |
+
},
|
| 801 |
+
)
|
| 802 |
+
|
| 803 |
+
if current_job_status(job_id) == "canceled":
|
| 804 |
+
append_log(job_id, "Regional Prospecting pipeline was stopped by user.")
|
| 805 |
+
return
|
| 806 |
+
|
| 807 |
+
if not final_ranking:
|
| 808 |
+
output = run_geo_pipeline(query)
|
| 809 |
+
if current_job_status(job_id) == "canceled":
|
| 810 |
+
append_log(job_id, "Regional Prospecting pipeline was stopped by user.")
|
| 811 |
+
return
|
| 812 |
+
final_ranking = str(output.get("result") or "")
|
| 813 |
+
maps_sources = output.get("maps_sources") or maps_sources
|
| 814 |
+
|
| 815 |
+
ranked_companies = parse_geo_companies(final_ranking)
|
| 816 |
+
fallback_companies = []
|
| 817 |
+
if final_ranking and not ranked_companies:
|
| 818 |
+
fallback_companies = parse_geo_companies_from_stages(stages)
|
| 819 |
+
if fallback_companies:
|
| 820 |
+
fallback_notice = "Ranking was not available, so these discovered companies are shown unranked."
|
| 821 |
+
append_log(job_id, fallback_notice)
|
| 822 |
+
|
| 823 |
+
update_job(
|
| 824 |
+
job_id,
|
| 825 |
+
status="completed",
|
| 826 |
+
finished_at=now(),
|
| 827 |
+
result={
|
| 828 |
+
"result": final_ranking,
|
| 829 |
+
"companies": ranked_companies or fallback_companies,
|
| 830 |
+
"maps_sources": maps_sources,
|
| 831 |
+
"stages": stages,
|
| 832 |
+
"ranking_unavailable": bool(fallback_companies and not ranked_companies),
|
| 833 |
+
"notice": fallback_notice,
|
| 834 |
+
},
|
| 835 |
+
)
|
| 836 |
+
append_log(job_id, "Geo ranking completed.")
|
| 837 |
+
except Exception as exc:
|
| 838 |
+
if current_job_status(job_id) == "canceled":
|
| 839 |
+
append_log(job_id, "Regional Prospecting pipeline was stopped by user.")
|
| 840 |
+
return
|
| 841 |
+
update_job(job_id, status="failed", finished_at=now(), error=str(exc))
|
| 842 |
+
append_log(job_id, f"Geo ranking failed: {type(exc).__name__}: {exc}")
|
| 843 |
+
|
| 844 |
+
|
| 845 |
+
def run_company_research_job(job_id: str, payload: dict[str, Any]) -> None:
|
| 846 |
+
company = str(payload.get("company") or "").strip()
|
| 847 |
+
base_company_md = get_base_company_context()
|
| 848 |
+
company_md = str(payload.get("company_md") or "").strip()
|
| 849 |
+
if not base_company_md:
|
| 850 |
+
update_job(
|
| 851 |
+
job_id,
|
| 852 |
+
status="failed",
|
| 853 |
+
finished_at=now(),
|
| 854 |
+
returncode=1,
|
| 855 |
+
error="Base company context is required before running Company Research.",
|
| 856 |
+
)
|
| 857 |
+
append_log(job_id, "Missing base company context. Configure it in Settings before running Company Research.")
|
| 858 |
+
return
|
| 859 |
+
if not company_md:
|
| 860 |
+
company_md = base_company_md
|
| 861 |
+
quality_mode = str(payload.get("quality_mode") or "balanced").strip().lower()
|
| 862 |
+
default_prompts = 10 if quality_mode == "deep" else 8
|
| 863 |
+
default_results = 5 if quality_mode == "deep" else 4
|
| 864 |
+
is_azure = bool(os.environ.get("CONTAINER_APP_NAME") or os.environ.get("WEBSITE_SITE_NAME"))
|
| 865 |
+
default_parallel = 2 if is_azure else (4 if quality_mode == "deep" else 6)
|
| 866 |
+
max_parallel_default = 2 if is_azure else 6
|
| 867 |
+
max_parallel = clean_int(
|
| 868 |
+
os.environ.get("COMPANY_EXTRACTOR_MAX_PARALLEL_SEARCHES"),
|
| 869 |
+
max_parallel_default,
|
| 870 |
+
1,
|
| 871 |
+
6,
|
| 872 |
+
)
|
| 873 |
+
max_prompts = clean_int(payload.get("max_prompts"), default_prompts, 1, 12)
|
| 874 |
+
max_results = clean_int(payload.get("max_results_per_prompt"), default_results, 1, 10)
|
| 875 |
+
parallel_searches = clean_int(payload.get("parallel_searches"), default_parallel, 1, max_parallel)
|
| 876 |
+
job_output_dir = RESEARCH_OUTPUT_DIR / job_id
|
| 877 |
+
job_output_dir.mkdir(parents=True, exist_ok=True)
|
| 878 |
+
company_md_path = Path("company.md")
|
| 879 |
+
if company_md:
|
| 880 |
+
try:
|
| 881 |
+
company_md_path = job_output_dir / "company.md"
|
| 882 |
+
company_md_path.write_text(company_md + "\n", encoding="utf-8")
|
| 883 |
+
except Exception as exc:
|
| 884 |
+
update_job(job_id, status="failed", finished_at=now(), returncode=1, error=str(exc))
|
| 885 |
+
append_log(job_id, f"Could not write company.md: {type(exc).__name__}: {exc}")
|
| 886 |
+
return
|
| 887 |
+
|
| 888 |
+
command = [
|
| 889 |
+
sys.executable,
|
| 890 |
+
str(PIPELINE_SCRIPT),
|
| 891 |
+
"--company",
|
| 892 |
+
str(company_md_path),
|
| 893 |
+
"--client-company",
|
| 894 |
+
company,
|
| 895 |
+
"--questions",
|
| 896 |
+
"questionlist.md",
|
| 897 |
+
"--out-dir",
|
| 898 |
+
str(job_output_dir),
|
| 899 |
+
"--num-queries",
|
| 900 |
+
str(max_prompts),
|
| 901 |
+
"--results-per-query",
|
| 902 |
+
str(max_results),
|
| 903 |
+
"--min-delay",
|
| 904 |
+
"0",
|
| 905 |
+
"--max-delay",
|
| 906 |
+
"0",
|
| 907 |
+
"--parallel-searches",
|
| 908 |
+
str(parallel_searches),
|
| 909 |
+
]
|
| 910 |
+
|
| 911 |
+
if current_job_status(job_id) == "canceled":
|
| 912 |
+
append_log(job_id, "Company research was stopped before the pipeline started.")
|
| 913 |
+
return
|
| 914 |
+
|
| 915 |
+
update_job(job_id, status="running", started_at=now(), command=command, run_dir=str(job_output_dir))
|
| 916 |
+
append_log(job_id, "Starting company information extraction pipeline.")
|
| 917 |
+
append_log(
|
| 918 |
+
job_id,
|
| 919 |
+
f"Research mode: {quality_mode or 'balanced'} "
|
| 920 |
+
f"({max_prompts} queries, {max_results} results/query, {parallel_searches} parallel searches)."
|
| 921 |
+
)
|
| 922 |
+
if company_md:
|
| 923 |
+
append_log(job_id, f"Using uploaded company.md: {company_md_path}")
|
| 924 |
+
else:
|
| 925 |
+
append_log(job_id, "Using default company.md from research folder.")
|
| 926 |
+
append_log(job_id, "Command: " + " ".join(command))
|
| 927 |
+
|
| 928 |
+
try:
|
| 929 |
+
process = subprocess.Popen(
|
| 930 |
+
command,
|
| 931 |
+
cwd=RESEARCH_DIR,
|
| 932 |
+
env=absorb_env(),
|
| 933 |
+
stdout=subprocess.PIPE,
|
| 934 |
+
stderr=subprocess.STDOUT,
|
| 935 |
+
text=True,
|
| 936 |
+
encoding="utf-8",
|
| 937 |
+
errors="replace",
|
| 938 |
+
bufsize=1,
|
| 939 |
+
)
|
| 940 |
+
update_job(job_id, pid=process.pid)
|
| 941 |
+
|
| 942 |
+
assert process.stdout is not None
|
| 943 |
+
for line in process.stdout:
|
| 944 |
+
append_log(job_id, line.rstrip())
|
| 945 |
+
|
| 946 |
+
returncode = process.wait()
|
| 947 |
+
if current_job_status(job_id) == "canceled":
|
| 948 |
+
update_job(job_id, finished_at=now(), returncode=returncode)
|
| 949 |
+
append_log(job_id, "Company research process was stopped by user.")
|
| 950 |
+
return
|
| 951 |
+
|
| 952 |
+
cached_job = get_cached_job_state(job_id)
|
| 953 |
+
run_dir = str(cached_job.get("run_dir") or "") if cached_job else ""
|
| 954 |
+
if not run_dir:
|
| 955 |
+
with JOBS_LOCK:
|
| 956 |
+
run_dir = str(JOBS.get(job_id, {}).get("run_dir") or "")
|
| 957 |
+
if not run_dir:
|
| 958 |
+
saved_job = db_get_interaction(job_id)
|
| 959 |
+
run_dir = str(saved_job.get("run_dir") or "") if saved_job else ""
|
| 960 |
+
if not run_dir:
|
| 961 |
+
run_dir = str(job_output_dir)
|
| 962 |
+
|
| 963 |
+
result = find_final_profile(run_dir)
|
| 964 |
+
profile_missing = returncode == 0 and not str(result.get("text") or "").strip()
|
| 965 |
+
if profile_missing:
|
| 966 |
+
append_log(job_id, f"No usable profile was found in: {run_dir}")
|
| 967 |
+
elif returncode == 0:
|
| 968 |
+
profile_path = str(result.get("path") or "")
|
| 969 |
+
append_log(job_id, f"Company profile ready: {profile_path or run_dir}")
|
| 970 |
+
update_job(
|
| 971 |
+
job_id,
|
| 972 |
+
status="completed" if returncode == 0 and not profile_missing else "failed",
|
| 973 |
+
finished_at=now(),
|
| 974 |
+
returncode=returncode,
|
| 975 |
+
result=result,
|
| 976 |
+
error=(
|
| 977 |
+
""
|
| 978 |
+
if returncode == 0 and not profile_missing
|
| 979 |
+
else "Pipeline completed but no usable profile was generated."
|
| 980 |
+
if profile_missing
|
| 981 |
+
else f"Pipeline exited with code {returncode}."
|
| 982 |
+
),
|
| 983 |
+
)
|
| 984 |
+
except Exception as exc:
|
| 985 |
+
if current_job_status(job_id) == "canceled":
|
| 986 |
+
update_job(job_id, finished_at=now(), returncode=1)
|
| 987 |
+
append_log(job_id, "Company research process was stopped by user.")
|
| 988 |
+
return
|
| 989 |
+
update_job(job_id, status="failed", finished_at=now(), returncode=1, error=str(exc))
|
| 990 |
+
append_log(job_id, f"Company research failed: {type(exc).__name__}: {exc}")
|
| 991 |
+
|
| 992 |
+
|
| 993 |
+
# Initialize interactions database
|
| 994 |
+
init_database()
|
| 995 |
+
try:
|
| 996 |
+
db_prune_geo_history(20)
|
| 997 |
+
except Exception as exc:
|
| 998 |
+
print(f"Geo history cleanup failed: {exc}")
|
services/export_service.py
ADDED
|
@@ -0,0 +1,1317 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import re
|
| 5 |
+
import zipfile
|
| 6 |
+
from io import BytesIO
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import Any
|
| 9 |
+
from xml.sax.saxutils import escape as xml_escape
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
# =====================================================================
|
| 13 |
+
# General Markdown & Text Helpers
|
| 14 |
+
# =====================================================================
|
| 15 |
+
|
| 16 |
+
def clean_markdown_text(value: str) -> str:
|
| 17 |
+
value = re.sub(r"^\s{0,3}#{1,6}\s*", "", value)
|
| 18 |
+
value = re.sub(r"^\s*(?:[-*+]\s+|\d+[.)]\s+)", "", value)
|
| 19 |
+
value = value.replace("**", "").replace("__", "").replace("`", "")
|
| 20 |
+
return value.strip()
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def strip_inline_markdown(value: str) -> str:
|
| 24 |
+
text = str(value or "")
|
| 25 |
+
text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r"\1 (\2)", text)
|
| 26 |
+
text = text.replace("**", "").replace("__", "").replace("`", "")
|
| 27 |
+
return text.strip()
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
# =====================================================================
|
| 31 |
+
# Excel Generation Utilities
|
| 32 |
+
# =====================================================================
|
| 33 |
+
|
| 34 |
+
def split_geo_detail_fields(value: str) -> dict[str, str]:
|
| 35 |
+
details: dict[str, str] = {}
|
| 36 |
+
for part in re.split(r";\s*", value):
|
| 37 |
+
if not part.strip() or ":" not in part:
|
| 38 |
+
continue
|
| 39 |
+
key, detail = part.split(":", 1)
|
| 40 |
+
key = clean_markdown_text(key)
|
| 41 |
+
detail = clean_markdown_text(detail)
|
| 42 |
+
if key and detail:
|
| 43 |
+
details[key] = detail
|
| 44 |
+
return details
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def parse_geo_companies(text: str) -> list[dict[str, Any]]:
|
| 48 |
+
companies: list[dict[str, Any]] = []
|
| 49 |
+
current: dict[str, Any] | None = None
|
| 50 |
+
active_field = ""
|
| 51 |
+
|
| 52 |
+
title_patterns = [
|
| 53 |
+
re.compile(r"^\s*(?:#{1,6}\s*)?(?:\*\*)?\s*(?:Rank\s*)?#?\s*(\d+)[.)]\s*(.+?)(?:\*\*)?\s*$", re.I),
|
| 54 |
+
re.compile(r"^\s*(?:\*\*)?\s*Company\s+Name\s*:\s*(.+?)(?:\*\*)?\s*$", re.I),
|
| 55 |
+
]
|
| 56 |
+
|
| 57 |
+
def finish_current() -> None:
|
| 58 |
+
if current and (current.get("company_name") or current.get("fields")):
|
| 59 |
+
details = current.get("fields", {}).get("All available details", "")
|
| 60 |
+
if details:
|
| 61 |
+
current["details"] = split_geo_detail_fields(details)
|
| 62 |
+
companies.append(current)
|
| 63 |
+
|
| 64 |
+
for raw_line in text.splitlines():
|
| 65 |
+
line = re.sub(r"^\s{0,3}#{1,6}\s*", "", raw_line)
|
| 66 |
+
line = line.replace("**", "").replace("__", "").replace("`", "").strip()
|
| 67 |
+
line = re.sub(r"^\s*[-*+]\s+", "", line).strip()
|
| 68 |
+
if not line or set(line) <= {"-", "=", "_"}:
|
| 69 |
+
continue
|
| 70 |
+
if line.lower().startswith(("here are ", "ranking logic", "summary", "why #1")):
|
| 71 |
+
active_field = ""
|
| 72 |
+
continue
|
| 73 |
+
|
| 74 |
+
if re.match(r"^[A-Za-z][A-Za-z0-9 /&().,'-]{1,64}\s*:\s*$", line):
|
| 75 |
+
active_field = ""
|
| 76 |
+
continue
|
| 77 |
+
|
| 78 |
+
title_match = None
|
| 79 |
+
for pattern in title_patterns:
|
| 80 |
+
title_match = pattern.match(line)
|
| 81 |
+
if title_match:
|
| 82 |
+
break
|
| 83 |
+
|
| 84 |
+
if title_match:
|
| 85 |
+
groups = title_match.groups()
|
| 86 |
+
if len(groups) == 2:
|
| 87 |
+
rank = groups[0]
|
| 88 |
+
name = groups[1]
|
| 89 |
+
else:
|
| 90 |
+
rank = str(len(companies) + 1)
|
| 91 |
+
name = groups[0]
|
| 92 |
+
name = re.sub(r"\s*\|\s*Match score:.*$", "", name, flags=re.I).strip()
|
| 93 |
+
|
| 94 |
+
# Skip if matched title is actually an explanation summary row
|
| 95 |
+
explanation_keywords = [
|
| 96 |
+
"is ranked", "are ranked", "was ranked", "ranked highest",
|
| 97 |
+
"ranked second", "ranked third", "ranked fourth", "ranked fifth",
|
| 98 |
+
"ranked last", "because it", "is second due to", "is third due to",
|
| 99 |
+
"is fourth due to"
|
| 100 |
+
]
|
| 101 |
+
if any(kw in name.lower() for kw in explanation_keywords):
|
| 102 |
+
finish_current()
|
| 103 |
+
current = None
|
| 104 |
+
active_field = ""
|
| 105 |
+
continue
|
| 106 |
+
|
| 107 |
+
# Skip if company is already added (deduplication)
|
| 108 |
+
clean_name = re.sub(r",?\s*Match score:.*$", "", name, flags=re.I).strip().lower()
|
| 109 |
+
existing_names = [c["company_name"].lower() for c in companies]
|
| 110 |
+
if clean_name in existing_names or any(clean_name in n or n in clean_name for n in existing_names):
|
| 111 |
+
finish_current()
|
| 112 |
+
current = None
|
| 113 |
+
active_field = ""
|
| 114 |
+
continue
|
| 115 |
+
|
| 116 |
+
finish_current()
|
| 117 |
+
current = {"rank": int(rank), "company_name": name, "fields": {}}
|
| 118 |
+
active_field = ""
|
| 119 |
+
score_match = re.search(r"Match score:\s*([^|]+)", line, re.I)
|
| 120 |
+
if score_match:
|
| 121 |
+
current["fields"]["Match score"] = score_match.group(1).strip()
|
| 122 |
+
continue
|
| 123 |
+
|
| 124 |
+
field_match = re.match(r"^([A-Za-z][A-Za-z0-9 /&().,'-]{1,64})\s*:\s*(.+)$", line)
|
| 125 |
+
if field_match:
|
| 126 |
+
if current is None:
|
| 127 |
+
current = {"rank": len(companies) + 1, "company_name": "", "fields": {}}
|
| 128 |
+
key = clean_markdown_text(field_match.group(1))
|
| 129 |
+
value = clean_markdown_text(field_match.group(2))
|
| 130 |
+
if key.lower() in {"business name", "company", "name"}:
|
| 131 |
+
if current.get("company_name") or current.get("fields"):
|
| 132 |
+
finish_current()
|
| 133 |
+
current = {"rank": len(companies) + 1, "company_name": "", "fields": {}}
|
| 134 |
+
current["company_name"] = value
|
| 135 |
+
active_field = ""
|
| 136 |
+
continue
|
| 137 |
+
current["fields"][key] = value
|
| 138 |
+
active_field = key
|
| 139 |
+
continue
|
| 140 |
+
|
| 141 |
+
if current is not None and active_field:
|
| 142 |
+
current["fields"][active_field] = f"{current['fields'].get(active_field, '')} {line}".strip()
|
| 143 |
+
|
| 144 |
+
finish_current()
|
| 145 |
+
return companies
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def parse_geo_companies_from_stages(stages: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
|
| 149 |
+
if not stages:
|
| 150 |
+
return []
|
| 151 |
+
|
| 152 |
+
preferred_steps = ["url_verify_done", "checker_done", "maps_done", "search_done"]
|
| 153 |
+
by_step = {str(stage.get("step") or ""): stage for stage in stages if isinstance(stage, dict)}
|
| 154 |
+
|
| 155 |
+
for step in preferred_steps:
|
| 156 |
+
stage = by_step.get(step)
|
| 157 |
+
if not stage:
|
| 158 |
+
continue
|
| 159 |
+
companies = parse_geo_companies(str(stage.get("data") or ""))
|
| 160 |
+
if companies:
|
| 161 |
+
for index, company in enumerate(companies, start=1):
|
| 162 |
+
company["rank"] = company.get("rank") or index
|
| 163 |
+
fields = company.setdefault("fields", {})
|
| 164 |
+
fields.setdefault("Ranking status", "Unranked - ranking AI did not return a usable ranked list.")
|
| 165 |
+
return companies
|
| 166 |
+
return []
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def detail_value(company: dict[str, Any], names: list[str]) -> str:
|
| 170 |
+
fields = company.get("fields") if isinstance(company.get("fields"), dict) else {}
|
| 171 |
+
details = company.get("details") if isinstance(company.get("details"), dict) else {}
|
| 172 |
+
lookup = {str(key).lower(): value for source in (fields, details) for key, value in source.items()}
|
| 173 |
+
for name in names:
|
| 174 |
+
value = fields.get(name) or details.get(name) or lookup.get(name.lower())
|
| 175 |
+
if value:
|
| 176 |
+
return str(value)
|
| 177 |
+
return ""
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def clean_excel_value(value: Any) -> str:
|
| 181 |
+
text = clean_markdown_text(str(value or ""))
|
| 182 |
+
text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r"\2", text)
|
| 183 |
+
text = re.sub(r"\s+Details:\s*$", "", text, flags=re.I)
|
| 184 |
+
text = re.sub(r"\s+", " ", text).strip()
|
| 185 |
+
return text
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def geo_export_tables(job: dict[str, Any]) -> dict[str, Any]:
|
| 189 |
+
result = job.get("result") if isinstance(job.get("result"), dict) else {}
|
| 190 |
+
companies = result.get("companies") if isinstance(result.get("companies"), list) else []
|
| 191 |
+
raw_ranking = str(result.get("result") or "")
|
| 192 |
+
if not companies and raw_ranking:
|
| 193 |
+
companies = parse_geo_companies(raw_ranking)
|
| 194 |
+
if not companies:
|
| 195 |
+
raise ValueError("No parsed company details are available for this Geo run.")
|
| 196 |
+
|
| 197 |
+
has_detailed_companies = any(company.get("fields") or company.get("details") for company in companies)
|
| 198 |
+
if has_detailed_companies:
|
| 199 |
+
richest_by_name: dict[str, dict[str, Any]] = {}
|
| 200 |
+
for company in companies:
|
| 201 |
+
name_key = str(company.get("company_name") or "").strip().lower()
|
| 202 |
+
detail_count = len(company.get("fields") or {}) + len(company.get("details") or {})
|
| 203 |
+
if not name_key or detail_count == 0:
|
| 204 |
+
continue
|
| 205 |
+
previous = richest_by_name.get(name_key)
|
| 206 |
+
previous_count = len(previous.get("fields") or {}) + len(previous.get("details") or {}) if previous else -1
|
| 207 |
+
if previous is None or detail_count > previous_count:
|
| 208 |
+
richest_by_name[name_key] = company
|
| 209 |
+
companies = sorted(richest_by_name.values(), key=lambda company: int(company.get("rank") or 9999))
|
| 210 |
+
|
| 211 |
+
def standardize_na(val: Any) -> str:
|
| 212 |
+
s = str(val or "").strip()
|
| 213 |
+
if not s:
|
| 214 |
+
return "N/A"
|
| 215 |
+
lower_s = s.lower()
|
| 216 |
+
if lower_s == "n/a" or lower_s == "not found":
|
| 217 |
+
return "N/A"
|
| 218 |
+
if "unresolvable" in lower_s or "dead link" in lower_s or "removed" in lower_s:
|
| 219 |
+
return "N/A"
|
| 220 |
+
if "not directly listed" in lower_s or "available" in lower_s:
|
| 221 |
+
return "N/A"
|
| 222 |
+
return s
|
| 223 |
+
|
| 224 |
+
def get_geo_contact(company: dict, want_type: str) -> str:
|
| 225 |
+
raw_phone = str(detail_value(company, ["Phone number", "Phone", "Contact"]) or "N/A")
|
| 226 |
+
raw_email = str(detail_value(company, ["Email address", "Email"]) or "N/A")
|
| 227 |
+
if raw_phone != "N/A" and raw_email == "N/A" and "@" in raw_phone:
|
| 228 |
+
email_pattern = r'([a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+\.[a-zA-Z0-9_-]+)'
|
| 229 |
+
emails = re.findall(email_pattern, raw_phone)
|
| 230 |
+
if emails:
|
| 231 |
+
raw_email = ", ".join(emails)
|
| 232 |
+
for e in emails:
|
| 233 |
+
raw_phone = raw_phone.replace(e, "")
|
| 234 |
+
raw_phone = re.sub(r',\s*', ' ', raw_phone)
|
| 235 |
+
raw_phone = re.sub(r'\s+', ' ', raw_phone).strip()
|
| 236 |
+
if not raw_phone or raw_phone.lower() == "not found" or raw_phone == ",":
|
| 237 |
+
raw_phone = "N/A"
|
| 238 |
+
return standardize_na(raw_email if want_type == "email" else raw_phone)
|
| 239 |
+
|
| 240 |
+
preferred_columns = [
|
| 241 |
+
("Rank", lambda company: company.get("rank", "")),
|
| 242 |
+
("Company Name", lambda company: company.get("company_name", "")),
|
| 243 |
+
("Key Reason", lambda company: detail_value(company, ["Key reason"])),
|
| 244 |
+
("Website", lambda company: standardize_na(detail_value(company, ["Website", "Website URL"]))),
|
| 245 |
+
("Address", lambda company: detail_value(company, ["Full address", "Address"])),
|
| 246 |
+
("Category", lambda company: detail_value(company, ["Category", "Category / type", "Industry / sector", "Industry"])),
|
| 247 |
+
("Phone", lambda company: get_geo_contact(company, "phone")),
|
| 248 |
+
("Email", lambda company: get_geo_contact(company, "email")),
|
| 249 |
+
("Location Label", lambda company: detail_value(company, ["Verification", "Location label", "Location"])),
|
| 250 |
+
]
|
| 251 |
+
preferred_names = {name for name, _ in preferred_columns}
|
| 252 |
+
|
| 253 |
+
mapped_or_dropped_keys = {
|
| 254 |
+
"rank", "company_name", "key reason", "website", "website url",
|
| 255 |
+
"full address", "address", "category", "category / type",
|
| 256 |
+
"industry / sector", "industry", "google maps rating and review count",
|
| 257 |
+
"google maps rating", "rating", "phone number", "phone", "contact",
|
| 258 |
+
"email address", "email", "verification", "location label", "location",
|
| 259 |
+
"legitimacy label", "legitimacy", "all available details"
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
extra_field_names: list[str] = []
|
| 263 |
+
for company in companies:
|
| 264 |
+
fields = company.get("fields") if isinstance(company.get("fields"), dict) else {}
|
| 265 |
+
details = company.get("details") if isinstance(company.get("details"), dict) else {}
|
| 266 |
+
for name in list(fields) + list(details):
|
| 267 |
+
if name.lower() not in mapped_or_dropped_keys and name not in extra_field_names:
|
| 268 |
+
extra_field_names.append(name)
|
| 269 |
+
|
| 270 |
+
headers = [name for name, _getter in preferred_columns] + extra_field_names
|
| 271 |
+
company_rows: list[list[Any]] = []
|
| 272 |
+
for company in companies:
|
| 273 |
+
fields = company.get("fields") if isinstance(company.get("fields"), dict) else {}
|
| 274 |
+
details = company.get("details") if isinstance(company.get("details"), dict) else {}
|
| 275 |
+
row = [clean_excel_value(getter(company)) for _name, getter in preferred_columns]
|
| 276 |
+
row.extend(clean_excel_value(fields.get(name) or details.get(name) or "") for name in extra_field_names)
|
| 277 |
+
company_rows.append(row)
|
| 278 |
+
|
| 279 |
+
source_rows = [
|
| 280 |
+
[clean_excel_value(source.get("title", "")), clean_excel_value(source.get("uri", "") or source.get("url", ""))]
|
| 281 |
+
for source in result.get("maps_sources") or []
|
| 282 |
+
if isinstance(source, dict)
|
| 283 |
+
]
|
| 284 |
+
raw_rows = [
|
| 285 |
+
["Query", clean_excel_value(job.get("title", ""))],
|
| 286 |
+
["Job ID", job.get("id", "")],
|
| 287 |
+
["Created", job.get("created_at", "")],
|
| 288 |
+
[],
|
| 289 |
+
["Raw Ranking Output"],
|
| 290 |
+
[raw_ranking],
|
| 291 |
+
]
|
| 292 |
+
return {
|
| 293 |
+
"headers": headers,
|
| 294 |
+
"company_rows": company_rows,
|
| 295 |
+
"source_rows": source_rows,
|
| 296 |
+
"raw_rows": raw_rows,
|
| 297 |
+
}
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
def build_geo_excel(job: dict[str, Any]) -> BytesIO:
|
| 301 |
+
tables = geo_export_tables(job)
|
| 302 |
+
try:
|
| 303 |
+
from openpyxl import Workbook
|
| 304 |
+
from openpyxl.styles import Alignment, Font, PatternFill
|
| 305 |
+
from openpyxl.utils import get_column_letter
|
| 306 |
+
except ImportError:
|
| 307 |
+
return build_geo_excel_basic(tables)
|
| 308 |
+
|
| 309 |
+
wb = Workbook()
|
| 310 |
+
ws = wb.active
|
| 311 |
+
ws.title = "Companies"
|
| 312 |
+
header_fill = PatternFill("solid", fgColor="101820")
|
| 313 |
+
header_font = Font(color="FFFFFF", bold=True)
|
| 314 |
+
|
| 315 |
+
headers = tables["headers"]
|
| 316 |
+
ws.append(headers)
|
| 317 |
+
for cell in ws[1]:
|
| 318 |
+
cell.fill = header_fill
|
| 319 |
+
cell.font = header_font
|
| 320 |
+
cell.alignment = Alignment(vertical="center", wrap_text=True)
|
| 321 |
+
|
| 322 |
+
for row in tables["company_rows"]:
|
| 323 |
+
ws.append(row)
|
| 324 |
+
|
| 325 |
+
ws.freeze_panes = "A2"
|
| 326 |
+
ws.auto_filter.ref = ws.dimensions
|
| 327 |
+
for row in ws.iter_rows(min_row=2):
|
| 328 |
+
for cell in row:
|
| 329 |
+
cell.alignment = Alignment(vertical="top", wrap_text=True)
|
| 330 |
+
|
| 331 |
+
max_widths = {
|
| 332 |
+
"Company Name": 34,
|
| 333 |
+
"Key Reason": 60,
|
| 334 |
+
"Address": 52,
|
| 335 |
+
"All Available Details": 72,
|
| 336 |
+
}
|
| 337 |
+
for column_cells in ws.columns:
|
| 338 |
+
header = str(column_cells[0].value or "")
|
| 339 |
+
max_len = max(len(str(cell.value or "")) for cell in column_cells[: min(len(column_cells), 25)])
|
| 340 |
+
width = min(max(max_len + 2, 12), max_widths.get(header, 44))
|
| 341 |
+
ws.column_dimensions[get_column_letter(column_cells[0].column)].width = width
|
| 342 |
+
|
| 343 |
+
source_ws = wb.create_sheet("Sources")
|
| 344 |
+
source_ws.append(["Title", "URL"])
|
| 345 |
+
for cell in source_ws[1]:
|
| 346 |
+
cell.fill = header_fill
|
| 347 |
+
cell.font = header_font
|
| 348 |
+
for row in tables["source_rows"]:
|
| 349 |
+
source_ws.append(row)
|
| 350 |
+
source_ws.freeze_panes = "A2"
|
| 351 |
+
source_ws.column_dimensions["A"].width = 45
|
| 352 |
+
source_ws.column_dimensions["B"].width = 90
|
| 353 |
+
|
| 354 |
+
raw_ws = wb.create_sheet("Raw Ranking")
|
| 355 |
+
for row in tables["raw_rows"]:
|
| 356 |
+
raw_ws.append(row)
|
| 357 |
+
raw_ws["A6"].alignment = Alignment(vertical="top", wrap_text=True)
|
| 358 |
+
raw_ws.column_dimensions["A"].width = 24
|
| 359 |
+
raw_ws.column_dimensions["B"].width = 80
|
| 360 |
+
raw_ws.row_dimensions[6].height = 320
|
| 361 |
+
for cell in ("A1", "A2", "A3", "A5"):
|
| 362 |
+
raw_ws[cell].font = Font(bold=True)
|
| 363 |
+
|
| 364 |
+
output = BytesIO()
|
| 365 |
+
wb.save(output)
|
| 366 |
+
output.seek(0)
|
| 367 |
+
return output
|
| 368 |
+
|
| 369 |
+
|
| 370 |
+
def xlsx_cell(value: Any) -> str:
|
| 371 |
+
clean_value = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f]", "", str(value or ""))
|
| 372 |
+
text = xml_escape(clean_value)
|
| 373 |
+
return f'<c t="inlineStr"><is><t xml:space="preserve">{text}</t></is></c>'
|
| 374 |
+
|
| 375 |
+
|
| 376 |
+
def xlsx_sheet(rows: list[list[Any]]) -> str:
|
| 377 |
+
body: list[str] = []
|
| 378 |
+
for row_index, row in enumerate(rows, start=1):
|
| 379 |
+
cells = "".join(xlsx_cell(value) for value in row)
|
| 380 |
+
body.append(f'<row r="{row_index}">{cells}</row>')
|
| 381 |
+
return (
|
| 382 |
+
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
| 383 |
+
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
|
| 384 |
+
'<sheetData>'
|
| 385 |
+
+ "".join(body)
|
| 386 |
+
+ "</sheetData></worksheet>"
|
| 387 |
+
)
|
| 388 |
+
|
| 389 |
+
|
| 390 |
+
def build_geo_excel_basic(tables: dict[str, Any]) -> BytesIO:
|
| 391 |
+
sheets = [
|
| 392 |
+
("Companies", [tables["headers"], *tables["company_rows"]]),
|
| 393 |
+
("Sources", [["Title", "URL"], *tables["source_rows"]]),
|
| 394 |
+
("Raw Ranking", tables["raw_rows"]),
|
| 395 |
+
]
|
| 396 |
+
output = BytesIO()
|
| 397 |
+
with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as zf:
|
| 398 |
+
zf.writestr(
|
| 399 |
+
"[Content_Types].xml",
|
| 400 |
+
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
| 401 |
+
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
|
| 402 |
+
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
|
| 403 |
+
'<Default Extension="xml" ContentType="application/xml"/>'
|
| 404 |
+
'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>'
|
| 405 |
+
+ "".join(
|
| 406 |
+
f'<Override PartName="/xl/worksheets/sheet{index}.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>'
|
| 407 |
+
for index in range(1, len(sheets) + 1)
|
| 408 |
+
)
|
| 409 |
+
+ "</Types>",
|
| 410 |
+
)
|
| 411 |
+
zf.writestr(
|
| 412 |
+
"_rels/.rels",
|
| 413 |
+
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
| 414 |
+
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
| 415 |
+
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>'
|
| 416 |
+
"</Relationships>",
|
| 417 |
+
)
|
| 418 |
+
zf.writestr(
|
| 419 |
+
"xl/workbook.xml",
|
| 420 |
+
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
| 421 |
+
'<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" '
|
| 422 |
+
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"><sheets>'
|
| 423 |
+
+ "".join(
|
| 424 |
+
f'<sheet name="{xml_escape(name)}" sheetId="{index}" r:id="rId{index}"/>'
|
| 425 |
+
for index, (name, _rows) in enumerate(sheets, start=1)
|
| 426 |
+
)
|
| 427 |
+
+ "</sheets></workbook>",
|
| 428 |
+
)
|
| 429 |
+
zf.writestr(
|
| 430 |
+
"xl/_rels/workbook.xml.rels",
|
| 431 |
+
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
| 432 |
+
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
| 433 |
+
+ "".join(
|
| 434 |
+
f'<Relationship Id="rId{index}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet{index}.xml"/>'
|
| 435 |
+
for index in range(1, len(sheets) + 1)
|
| 436 |
+
)
|
| 437 |
+
+ "</Relationships>",
|
| 438 |
+
)
|
| 439 |
+
for index, (_name, rows) in enumerate(sheets, start=1):
|
| 440 |
+
zf.writestr(f"xl/worksheets/sheet{index}.xml", xlsx_sheet(rows))
|
| 441 |
+
output.seek(0)
|
| 442 |
+
return output
|
| 443 |
+
|
| 444 |
+
|
| 445 |
+
# =====================================================================
|
| 446 |
+
# PDF Generation Utilities (ReportLab)
|
| 447 |
+
# =====================================================================
|
| 448 |
+
|
| 449 |
+
def paragraph_text(value: Any) -> str:
|
| 450 |
+
text = xml_escape(str(value or ""))
|
| 451 |
+
return text.replace("\n", "<br/>")
|
| 452 |
+
|
| 453 |
+
|
| 454 |
+
def profile_pdf_title(source_path: Path, profile_text: str = "") -> str:
|
| 455 |
+
for line in str(profile_text or "").splitlines():
|
| 456 |
+
match = re.match(r"^\s{0,3}#\s+(.+?)\s*$", line)
|
| 457 |
+
if match and "company profile" in match.group(1).lower():
|
| 458 |
+
return strip_inline_markdown(match.group(1).split(":", 1)[-1]).strip() or match.group(1).strip()
|
| 459 |
+
for line in str(profile_text or "").splitlines():
|
| 460 |
+
match = re.match(r"^\s{0,3}#\s+(.+?)\s*$", line)
|
| 461 |
+
if match:
|
| 462 |
+
return strip_inline_markdown(match.group(1)).strip()
|
| 463 |
+
name = source_path.parent.name or source_path.stem
|
| 464 |
+
return name.replace("_", " ").replace("-", " ").strip().title() or "Company Profile"
|
| 465 |
+
|
| 466 |
+
|
| 467 |
+
def reportlab_inline_html(html: str) -> str:
|
| 468 |
+
try:
|
| 469 |
+
import warnings
|
| 470 |
+
from bs4 import BeautifulSoup, MarkupResemblesLocatorWarning, NavigableString
|
| 471 |
+
except ImportError as exc:
|
| 472 |
+
raise RuntimeError(f"PDF export requires beautifulsoup4: {exc}") from exc
|
| 473 |
+
|
| 474 |
+
with warnings.catch_warnings():
|
| 475 |
+
warnings.filterwarnings("ignore", category=MarkupResemblesLocatorWarning)
|
| 476 |
+
soup = BeautifulSoup(html or "", "html.parser")
|
| 477 |
+
for tag in list(soup.find_all(True)):
|
| 478 |
+
name = tag.name.lower()
|
| 479 |
+
if name in {"strong", "b"}:
|
| 480 |
+
tag.name = "b"
|
| 481 |
+
elif name in {"em", "i"}:
|
| 482 |
+
tag.name = "i"
|
| 483 |
+
elif name == "code":
|
| 484 |
+
tag.name = "font"
|
| 485 |
+
tag.attrs = {"face": "Courier"}
|
| 486 |
+
elif name == "br":
|
| 487 |
+
tag.attrs = {}
|
| 488 |
+
elif name == "a":
|
| 489 |
+
href = tag.get("href")
|
| 490 |
+
tag.attrs = {"href": href} if href else {}
|
| 491 |
+
elif name in {"span", "p"}:
|
| 492 |
+
tag.unwrap()
|
| 493 |
+
else:
|
| 494 |
+
tag.unwrap()
|
| 495 |
+
|
| 496 |
+
parts: list[str] = []
|
| 497 |
+
for child in soup.contents:
|
| 498 |
+
if isinstance(child, NavigableString):
|
| 499 |
+
parts.append(xml_escape(str(child)))
|
| 500 |
+
else:
|
| 501 |
+
parts.append(str(child))
|
| 502 |
+
return "".join(parts).strip()
|
| 503 |
+
|
| 504 |
+
|
| 505 |
+
def markdown_inline_to_reportlab(text: str) -> str:
|
| 506 |
+
try:
|
| 507 |
+
import markdown
|
| 508 |
+
except ImportError as exc:
|
| 509 |
+
raise RuntimeError(f"PDF export requires markdown: {exc}") from exc
|
| 510 |
+
|
| 511 |
+
html = markdown.markdown(str(text or ""), extensions=["sane_lists"], output_format="html5")
|
| 512 |
+
html = re.sub(r"^\s*<p>|</p>\s*$", "", html.strip(), flags=re.I)
|
| 513 |
+
return reportlab_inline_html(html)
|
| 514 |
+
|
| 515 |
+
|
| 516 |
+
def html_nodes_to_reportlab(nodes: Any) -> str:
|
| 517 |
+
return reportlab_inline_html("".join(str(node) for node in nodes))
|
| 518 |
+
|
| 519 |
+
|
| 520 |
+
def strip_profile_line_prefix(line: str) -> str:
|
| 521 |
+
clean = strip_inline_markdown(line)
|
| 522 |
+
clean = re.sub(r"^\s{0,3}#{1,6}\s*", "", clean)
|
| 523 |
+
clean = re.sub(r"^\s*(?:[-*+]\s+|\d+[.)]\s+)", "", clean)
|
| 524 |
+
return clean.strip()
|
| 525 |
+
|
| 526 |
+
|
| 527 |
+
def split_profile_field_line(line: str) -> dict[str, str] | None:
|
| 528 |
+
normalized = strip_profile_line_prefix(line)
|
| 529 |
+
if not normalized or normalized.startswith("|") or re.match(r"^https?://", normalized, flags=re.I):
|
| 530 |
+
return None
|
| 531 |
+
colon = normalized.find(":")
|
| 532 |
+
if colon <= 0 or colon > 70:
|
| 533 |
+
return None
|
| 534 |
+
label = normalized[:colon].strip()
|
| 535 |
+
if not re.match(r"^[A-Za-z][A-Za-z0-9 /&().,'-]{1,69}$", label):
|
| 536 |
+
return None
|
| 537 |
+
segments = [part.strip() for part in re.split(r"\s+\|\s+", normalized[colon + 1 :]) if part.strip()]
|
| 538 |
+
if not segments:
|
| 539 |
+
return None
|
| 540 |
+
return {"label": label, "value": segments[0], "meta": " | ".join(segments[1:])}
|
| 541 |
+
|
| 542 |
+
|
| 543 |
+
def parse_profile_sections(text: str, fallback_title: str = "Company Profile") -> list[dict[str, Any]]:
|
| 544 |
+
sections: list[dict[str, Any]] = []
|
| 545 |
+
current: dict[str, Any] = {"title": fallback_title, "rows": [], "notes": []}
|
| 546 |
+
|
| 547 |
+
def push_current() -> None:
|
| 548 |
+
if current["rows"] or current["notes"]:
|
| 549 |
+
sections.append(current.copy())
|
| 550 |
+
|
| 551 |
+
for raw_line in str(text or "").splitlines():
|
| 552 |
+
trimmed = raw_line.strip()
|
| 553 |
+
if not trimmed:
|
| 554 |
+
continue
|
| 555 |
+
if set(trimmed) <= {"-", "=", "_"}:
|
| 556 |
+
continue
|
| 557 |
+
heading_match = re.match(r"^\s{0,3}#{1,6}\s+(.+)$", raw_line)
|
| 558 |
+
bold_heading_match = re.match(r"^\s*(?:[-*+]\s*)?\*\*#?\s*([^:*]{3,90})\s*\*\*\s*$", raw_line)
|
| 559 |
+
uppercase_heading = strip_profile_line_prefix(raw_line)
|
| 560 |
+
if heading_match or bold_heading_match or (uppercase_heading.isupper() and len(uppercase_heading) <= 90):
|
| 561 |
+
push_current()
|
| 562 |
+
title = heading_match.group(1) if heading_match else bold_heading_match.group(1) if bold_heading_match else uppercase_heading
|
| 563 |
+
current = {"title": strip_profile_line_prefix(title), "rows": [], "notes": []}
|
| 564 |
+
continue
|
| 565 |
+
field = split_profile_field_line(raw_line)
|
| 566 |
+
if field and field["value"]:
|
| 567 |
+
current["rows"].append(field)
|
| 568 |
+
else:
|
| 569 |
+
current["notes"].append(strip_profile_line_prefix(raw_line))
|
| 570 |
+
|
| 571 |
+
push_current()
|
| 572 |
+
return sections
|
| 573 |
+
|
| 574 |
+
|
| 575 |
+
def normalize_profile_heading(value: str) -> str:
|
| 576 |
+
return strip_inline_markdown(value).strip().rstrip(":")
|
| 577 |
+
|
| 578 |
+
|
| 579 |
+
def profile_bullet_content(raw_line: str) -> tuple[int, str] | None:
|
| 580 |
+
match = re.match(r"^(\s*)(?:[-*+]\s+|\d+[.)]\s+)(.+?)\s*$", raw_line)
|
| 581 |
+
if not match:
|
| 582 |
+
return None
|
| 583 |
+
return len(match.group(1).replace("\t", " ")), match.group(2).strip()
|
| 584 |
+
|
| 585 |
+
|
| 586 |
+
def split_structured_profile_field(content: str) -> dict[str, Any] | None:
|
| 587 |
+
cleaned = content.strip()
|
| 588 |
+
cleaned = re.sub(r"^\*\*(.+?)\*\*\s*:\s*", r"\1: ", cleaned)
|
| 589 |
+
cleaned = re.sub(r"^\*\*(.+?:)\*\*\s*", r"\1 ", cleaned)
|
| 590 |
+
match = re.match(r"^(.{2,72}?):\s*(.*)$", cleaned)
|
| 591 |
+
if not match:
|
| 592 |
+
return None
|
| 593 |
+
label = strip_inline_markdown(match.group(1)).strip()
|
| 594 |
+
value = match.group(2).strip()
|
| 595 |
+
if not label or re.search(r"[.!?]$", label):
|
| 596 |
+
return None
|
| 597 |
+
if not re.match(r"^[A-Za-z0-9][A-Za-z0-9 /&().,'+-]{1,72}$", label):
|
| 598 |
+
return None
|
| 599 |
+
return {"label": label, "value": value, "children": []}
|
| 600 |
+
|
| 601 |
+
|
| 602 |
+
def parse_structured_profile(text: str, fallback_title: str) -> dict[str, Any] | None:
|
| 603 |
+
title = fallback_title
|
| 604 |
+
sections: list[dict[str, Any]] = []
|
| 605 |
+
current: dict[str, Any] | None = None
|
| 606 |
+
last_field: dict[str, Any] | None = None
|
| 607 |
+
saw_title = False
|
| 608 |
+
|
| 609 |
+
def push_section() -> None:
|
| 610 |
+
nonlocal current, last_field
|
| 611 |
+
if current and (current["fields"] or current["notes"]):
|
| 612 |
+
sections.append(current)
|
| 613 |
+
current = None
|
| 614 |
+
last_field = None
|
| 615 |
+
|
| 616 |
+
for raw_line in str(text or "").splitlines():
|
| 617 |
+
if not raw_line.strip() or set(raw_line.strip()) <= {"-", "=", "_"}:
|
| 618 |
+
continue
|
| 619 |
+
|
| 620 |
+
heading_match = re.match(r"^\s{0,3}(#{1,6})\s+(.+?)\s*$", raw_line)
|
| 621 |
+
if heading_match:
|
| 622 |
+
heading = normalize_profile_heading(heading_match.group(2))
|
| 623 |
+
if not saw_title:
|
| 624 |
+
title = heading or title
|
| 625 |
+
saw_title = True
|
| 626 |
+
continue
|
| 627 |
+
if heading.lower() == title.lower():
|
| 628 |
+
continue
|
| 629 |
+
push_section()
|
| 630 |
+
current = {"title": heading, "fields": [], "notes": []}
|
| 631 |
+
continue
|
| 632 |
+
|
| 633 |
+
bold_heading_match = re.match(r"^\s*(?:[-*+]\s*)?\*\*([^:*]{3,90})\*\*\s*$", raw_line)
|
| 634 |
+
if bold_heading_match:
|
| 635 |
+
push_section()
|
| 636 |
+
current = {"title": normalize_profile_heading(bold_heading_match.group(1)), "fields": [], "notes": []}
|
| 637 |
+
continue
|
| 638 |
+
|
| 639 |
+
if current is None:
|
| 640 |
+
current = {"title": title, "fields": [], "notes": []}
|
| 641 |
+
|
| 642 |
+
bullet = profile_bullet_content(raw_line)
|
| 643 |
+
if bullet:
|
| 644 |
+
indent, content = bullet
|
| 645 |
+
field = split_structured_profile_field(content)
|
| 646 |
+
if field and indent <= 3:
|
| 647 |
+
current["fields"].append(field)
|
| 648 |
+
last_field = field
|
| 649 |
+
continue
|
| 650 |
+
if indent > 3 and last_field is not None:
|
| 651 |
+
child_field = split_structured_profile_field(content)
|
| 652 |
+
if child_field and child_field.get("value"):
|
| 653 |
+
last_field["children"].append(f"{child_field['label']}: {child_field['value']}")
|
| 654 |
+
else:
|
| 655 |
+
last_field["children"].append(content)
|
| 656 |
+
continue
|
| 657 |
+
current["notes"].append(content)
|
| 658 |
+
continue
|
| 659 |
+
|
| 660 |
+
field = split_structured_profile_field(strip_profile_line_prefix(raw_line))
|
| 661 |
+
if field:
|
| 662 |
+
current["fields"].append(field)
|
| 663 |
+
last_field = field
|
| 664 |
+
elif last_field is not None and last_field.get("value"):
|
| 665 |
+
last_field["value"] = f"{last_field['value']} {raw_line.strip()}".strip()
|
| 666 |
+
else:
|
| 667 |
+
current["notes"].append(strip_profile_line_prefix(raw_line))
|
| 668 |
+
|
| 669 |
+
push_section()
|
| 670 |
+
field_count = sum(len(section["fields"]) for section in sections)
|
| 671 |
+
if field_count < 4:
|
| 672 |
+
return None
|
| 673 |
+
return {"title": title, "sections": sections}
|
| 674 |
+
|
| 675 |
+
|
| 676 |
+
def build_profile_pdf(profile_text: str, source_path: Path) -> BytesIO:
|
| 677 |
+
try:
|
| 678 |
+
from reportlab.lib import colors
|
| 679 |
+
from reportlab.lib.enums import TA_CENTER, TA_LEFT
|
| 680 |
+
from reportlab.lib.pagesizes import A4
|
| 681 |
+
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
|
| 682 |
+
from reportlab.lib.units import inch
|
| 683 |
+
from reportlab.platypus import HRFlowable, KeepTogether, ListFlowable, ListItem, Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle
|
| 684 |
+
import markdown
|
| 685 |
+
from bs4 import BeautifulSoup, NavigableString, Tag
|
| 686 |
+
except ImportError as exc:
|
| 687 |
+
raise RuntimeError(f"PDF export requires reportlab, markdown, and beautifulsoup4: {exc}") from exc
|
| 688 |
+
|
| 689 |
+
# ReportLab standard fonts (Helvetica) cannot render many Unicode symbols,
|
| 690 |
+
# causing black squares (■) in the PDF. Strip or replace them.
|
| 691 |
+
_UNICODE_REPLACEMENTS = {
|
| 692 |
+
"\u20b9": "INR ", # ₹ Rupee sign
|
| 693 |
+
"\u20a8": "Rs ", # ₨ Rupee sign variant
|
| 694 |
+
"\u00a3": "GBP ", # £ Pound
|
| 695 |
+
"\u00a5": "JPY ", # ¥ Yen
|
| 696 |
+
"\u20ac": "EUR ", # € Euro
|
| 697 |
+
"\u2022": "-", # • Bullet
|
| 698 |
+
"\u2013": "-", # – En dash
|
| 699 |
+
"\u2014": "--", # — Em dash
|
| 700 |
+
"\u2018": "'", # ' Left single quote
|
| 701 |
+
"\u2019": "'", # ' Right single quote
|
| 702 |
+
"\u201c": '"', # " Left double quote
|
| 703 |
+
"\u201d": '"', # " Right double quote
|
| 704 |
+
"\u2026": "...", # … Ellipsis
|
| 705 |
+
"\u25a0": "", # ■ Black square (already rendered glyph)
|
| 706 |
+
"\u25aa": "", # ▪ Small black square
|
| 707 |
+
}
|
| 708 |
+
for char, replacement in _UNICODE_REPLACEMENTS.items():
|
| 709 |
+
profile_text = profile_text.replace(char, replacement)
|
| 710 |
+
# Fallback: strip any remaining non-ASCII characters that Helvetica can't render
|
| 711 |
+
import re as _re
|
| 712 |
+
profile_text = _re.sub(r'[^\x00-\x7F]+', lambda m: m.group(0) if all(ord(c) < 256 for c in m.group(0)) else '', profile_text)
|
| 713 |
+
|
| 714 |
+
title = profile_pdf_title(source_path, profile_text)
|
| 715 |
+
output = BytesIO()
|
| 716 |
+
doc = SimpleDocTemplate(
|
| 717 |
+
output,
|
| 718 |
+
pagesize=A4,
|
| 719 |
+
rightMargin=0.55 * inch,
|
| 720 |
+
leftMargin=0.55 * inch,
|
| 721 |
+
topMargin=0.5 * inch,
|
| 722 |
+
bottomMargin=0.55 * inch,
|
| 723 |
+
title=title,
|
| 724 |
+
)
|
| 725 |
+
styles = getSampleStyleSheet()
|
| 726 |
+
title_style = ParagraphStyle(
|
| 727 |
+
"ProspectTitle",
|
| 728 |
+
parent=styles["Title"],
|
| 729 |
+
alignment=TA_LEFT,
|
| 730 |
+
textColor=colors.HexColor("#101820"),
|
| 731 |
+
fontSize=19,
|
| 732 |
+
leading=23,
|
| 733 |
+
spaceAfter=6,
|
| 734 |
+
)
|
| 735 |
+
subtitle_style = ParagraphStyle(
|
| 736 |
+
"ProspectSubtitle",
|
| 737 |
+
parent=styles["Normal"],
|
| 738 |
+
alignment=TA_LEFT,
|
| 739 |
+
textColor=colors.HexColor("#607083"),
|
| 740 |
+
fontSize=9,
|
| 741 |
+
leading=12,
|
| 742 |
+
spaceAfter=10,
|
| 743 |
+
)
|
| 744 |
+
heading_styles = {
|
| 745 |
+
"h1": ParagraphStyle(
|
| 746 |
+
"ProspectH1",
|
| 747 |
+
parent=styles["Heading1"],
|
| 748 |
+
textColor=colors.HexColor("#101820"),
|
| 749 |
+
fontSize=17,
|
| 750 |
+
leading=22,
|
| 751 |
+
spaceBefore=14,
|
| 752 |
+
spaceAfter=8,
|
| 753 |
+
),
|
| 754 |
+
"h2": ParagraphStyle(
|
| 755 |
+
"ProspectH2",
|
| 756 |
+
parent=styles["Heading2"],
|
| 757 |
+
textColor=colors.HexColor("#101820"),
|
| 758 |
+
fontSize=14,
|
| 759 |
+
leading=18,
|
| 760 |
+
spaceBefore=12,
|
| 761 |
+
spaceAfter=7,
|
| 762 |
+
),
|
| 763 |
+
"h3": ParagraphStyle(
|
| 764 |
+
"ProspectH3",
|
| 765 |
+
parent=styles["Heading3"],
|
| 766 |
+
textColor=colors.HexColor("#101820"),
|
| 767 |
+
fontSize=12,
|
| 768 |
+
leading=15,
|
| 769 |
+
spaceBefore=9,
|
| 770 |
+
spaceAfter=5,
|
| 771 |
+
),
|
| 772 |
+
}
|
| 773 |
+
body_style = ParagraphStyle(
|
| 774 |
+
"ProspectMarkdownBody",
|
| 775 |
+
parent=styles["BodyText"],
|
| 776 |
+
fontSize=9.4,
|
| 777 |
+
leading=13.2,
|
| 778 |
+
textColor=colors.HexColor("#101820"),
|
| 779 |
+
spaceAfter=7,
|
| 780 |
+
)
|
| 781 |
+
bullet_style = ParagraphStyle(
|
| 782 |
+
"ProspectMarkdownBullet",
|
| 783 |
+
parent=styles["BodyText"],
|
| 784 |
+
fontSize=9.2,
|
| 785 |
+
leading=12.8,
|
| 786 |
+
textColor=colors.HexColor("#101820"),
|
| 787 |
+
spaceAfter=3,
|
| 788 |
+
)
|
| 789 |
+
code_style = ParagraphStyle(
|
| 790 |
+
"ProspectMarkdownCode",
|
| 791 |
+
parent=body_style,
|
| 792 |
+
fontName="Courier",
|
| 793 |
+
fontSize=8,
|
| 794 |
+
leading=10.5,
|
| 795 |
+
backColor=colors.HexColor("#f4f6f8"),
|
| 796 |
+
borderColor=colors.HexColor("#d9e1e8"),
|
| 797 |
+
borderWidth=0.4,
|
| 798 |
+
borderPadding=6,
|
| 799 |
+
)
|
| 800 |
+
table_cell_style = ParagraphStyle(
|
| 801 |
+
"ProspectMarkdownTableCell",
|
| 802 |
+
parent=body_style,
|
| 803 |
+
fontSize=8.2,
|
| 804 |
+
leading=10.8,
|
| 805 |
+
spaceAfter=0,
|
| 806 |
+
)
|
| 807 |
+
table_header_style = ParagraphStyle(
|
| 808 |
+
"ProspectMarkdownTableHeader",
|
| 809 |
+
parent=table_cell_style,
|
| 810 |
+
fontName="Helvetica-Bold",
|
| 811 |
+
textColor=colors.HexColor("#101820"),
|
| 812 |
+
)
|
| 813 |
+
json_label_style = ParagraphStyle(
|
| 814 |
+
"ProspectJsonLabel",
|
| 815 |
+
parent=table_cell_style,
|
| 816 |
+
fontName="Helvetica-Bold",
|
| 817 |
+
textColor=colors.HexColor("#33445a"),
|
| 818 |
+
backColor=colors.HexColor("#f6f8fa"),
|
| 819 |
+
)
|
| 820 |
+
json_value_style = ParagraphStyle(
|
| 821 |
+
"ProspectJsonValue",
|
| 822 |
+
parent=table_cell_style,
|
| 823 |
+
fontSize=8.4,
|
| 824 |
+
leading=11.2,
|
| 825 |
+
)
|
| 826 |
+
small_style = ParagraphStyle(
|
| 827 |
+
"ProspectSmall",
|
| 828 |
+
parent=styles["Normal"],
|
| 829 |
+
fontSize=8,
|
| 830 |
+
leading=10.5,
|
| 831 |
+
textColor=colors.HexColor("#607083"),
|
| 832 |
+
alignment=TA_LEFT,
|
| 833 |
+
)
|
| 834 |
+
structured_section_style = ParagraphStyle(
|
| 835 |
+
"ProspectStructuredSection",
|
| 836 |
+
parent=styles["Heading2"],
|
| 837 |
+
textColor=colors.HexColor("#101820"),
|
| 838 |
+
fontSize=13,
|
| 839 |
+
leading=16,
|
| 840 |
+
spaceBefore=10,
|
| 841 |
+
spaceAfter=5,
|
| 842 |
+
)
|
| 843 |
+
structured_label_style = ParagraphStyle(
|
| 844 |
+
"ProspectStructuredLabel",
|
| 845 |
+
parent=styles["BodyText"],
|
| 846 |
+
fontName="Helvetica-Bold",
|
| 847 |
+
fontSize=8.1,
|
| 848 |
+
leading=10,
|
| 849 |
+
textColor=colors.HexColor("#344054"),
|
| 850 |
+
spaceAfter=0,
|
| 851 |
+
)
|
| 852 |
+
structured_value_style = ParagraphStyle(
|
| 853 |
+
"ProspectStructuredValue",
|
| 854 |
+
parent=styles["BodyText"],
|
| 855 |
+
fontSize=8.6,
|
| 856 |
+
leading=11.5,
|
| 857 |
+
textColor=colors.HexColor("#101820"),
|
| 858 |
+
spaceAfter=0,
|
| 859 |
+
)
|
| 860 |
+
structured_card_style = ParagraphStyle(
|
| 861 |
+
"ProspectStructuredCard",
|
| 862 |
+
parent=structured_value_style,
|
| 863 |
+
fontSize=8.4,
|
| 864 |
+
leading=11.2,
|
| 865 |
+
)
|
| 866 |
+
person_card_title_style = ParagraphStyle(
|
| 867 |
+
"ProspectPersonCardTitle",
|
| 868 |
+
parent=styles["BodyText"],
|
| 869 |
+
fontName="Helvetica-Bold",
|
| 870 |
+
fontSize=9.2,
|
| 871 |
+
leading=11.5,
|
| 872 |
+
textColor=colors.HexColor("#101820"),
|
| 873 |
+
spaceAfter=0,
|
| 874 |
+
)
|
| 875 |
+
|
| 876 |
+
story: list[Any] = [
|
| 877 |
+
Paragraph(paragraph_text(title), title_style),
|
| 878 |
+
Paragraph("ProspectIQ Company Intelligence Report", small_style),
|
| 879 |
+
Paragraph("<b>Source:</b> " + paragraph_text(source_path.name), subtitle_style),
|
| 880 |
+
]
|
| 881 |
+
|
| 882 |
+
available_width = A4[0] - doc.leftMargin - doc.rightMargin
|
| 883 |
+
|
| 884 |
+
def profile_field_value_html(field: dict[str, Any]) -> str:
|
| 885 |
+
value_parts: list[str] = []
|
| 886 |
+
value = str(field.get("value") or "").strip()
|
| 887 |
+
if value:
|
| 888 |
+
value_parts.append(markdown_inline_to_reportlab(value))
|
| 889 |
+
for child in field.get("children") or []:
|
| 890 |
+
child_text = markdown_inline_to_reportlab(str(child))
|
| 891 |
+
if child_text:
|
| 892 |
+
value_parts.append("- " + child_text)
|
| 893 |
+
return "<br/>".join(value_parts) or "Not available"
|
| 894 |
+
|
| 895 |
+
def render_profile_fact_grid(fields: list[dict[str, Any]]) -> Table | None:
|
| 896 |
+
cells: list[Any] = []
|
| 897 |
+
for field in fields:
|
| 898 |
+
label = paragraph_text(field.get("label", ""))
|
| 899 |
+
value = profile_field_value_html(field)
|
| 900 |
+
cells.append(Paragraph(f"<b>{label}</b><br/>{value}", structured_card_style))
|
| 901 |
+
if not cells:
|
| 902 |
+
return None
|
| 903 |
+
rows: list[list[Any]] = []
|
| 904 |
+
for index in range(0, len(cells), 2):
|
| 905 |
+
row = cells[index : index + 2]
|
| 906 |
+
if len(row) == 1:
|
| 907 |
+
row.append(Paragraph("", structured_card_style))
|
| 908 |
+
rows.append(row)
|
| 909 |
+
table = Table(rows, colWidths=[available_width / 2 - 4, available_width / 2 - 4], hAlign="LEFT", splitByRow=True)
|
| 910 |
+
table.setStyle(
|
| 911 |
+
TableStyle(
|
| 912 |
+
[
|
| 913 |
+
("BACKGROUND", (0, 0), (-1, -1), colors.HexColor("#f8fafc")),
|
| 914 |
+
("BOX", (0, 0), (-1, -1), 0.35, colors.HexColor("#d9e1e8")),
|
| 915 |
+
("INNERGRID", (0, 0), (-1, -1), 0.25, colors.HexColor("#e6edf3")),
|
| 916 |
+
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
| 917 |
+
("LEFTPADDING", (0, 0), (-1, -1), 7),
|
| 918 |
+
("RIGHTPADDING", (0, 0), (-1, -1), 7),
|
| 919 |
+
("TOPPADDING", (0, 0), (-1, -1), 6),
|
| 920 |
+
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
|
| 921 |
+
]
|
| 922 |
+
)
|
| 923 |
+
)
|
| 924 |
+
return table
|
| 925 |
+
|
| 926 |
+
def render_profile_fields_table(fields: list[dict[str, Any]]) -> Table | None:
|
| 927 |
+
rows: list[list[Any]] = []
|
| 928 |
+
for field in fields:
|
| 929 |
+
rows.append(
|
| 930 |
+
[
|
| 931 |
+
Paragraph(paragraph_text(field.get("label", "")), structured_label_style),
|
| 932 |
+
Paragraph(profile_field_value_html(field), structured_value_style),
|
| 933 |
+
]
|
| 934 |
+
)
|
| 935 |
+
if not rows:
|
| 936 |
+
return None
|
| 937 |
+
table = Table(rows, colWidths=[available_width * 0.24, available_width * 0.76], hAlign="LEFT", splitByRow=True)
|
| 938 |
+
table.setStyle(
|
| 939 |
+
TableStyle(
|
| 940 |
+
[
|
| 941 |
+
("BACKGROUND", (0, 0), (0, -1), colors.HexColor("#f6f8fa")),
|
| 942 |
+
("GRID", (0, 0), (-1, -1), 0.3, colors.HexColor("#d9e1e8")),
|
| 943 |
+
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
| 944 |
+
("LEFTPADDING", (0, 0), (-1, -1), 6),
|
| 945 |
+
("RIGHTPADDING", (0, 0), (-1, -1), 6),
|
| 946 |
+
("TOPPADDING", (0, 0), (-1, -1), 5),
|
| 947 |
+
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
|
| 948 |
+
]
|
| 949 |
+
)
|
| 950 |
+
)
|
| 951 |
+
return table
|
| 952 |
+
|
| 953 |
+
def split_decision_maker_fields(fields: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
| 954 |
+
general_fields: list[dict[str, Any]] = []
|
| 955 |
+
people: list[dict[str, Any]] = []
|
| 956 |
+
current: dict[str, Any] | None = None
|
| 957 |
+
|
| 958 |
+
for field in fields:
|
| 959 |
+
label = str(field.get("label") or "").strip()
|
| 960 |
+
if re.match(r"^Person\s+\d+\b", label, flags=re.I):
|
| 961 |
+
if current:
|
| 962 |
+
people.append(current)
|
| 963 |
+
current = {"marker": field, "fields": []}
|
| 964 |
+
continue
|
| 965 |
+
if current is not None:
|
| 966 |
+
current["fields"].append(field)
|
| 967 |
+
else:
|
| 968 |
+
general_fields.append(field)
|
| 969 |
+
|
| 970 |
+
if current:
|
| 971 |
+
people.append(current)
|
| 972 |
+
return general_fields, people
|
| 973 |
+
|
| 974 |
+
def render_decision_maker_card(person: dict[str, Any]) -> KeepTogether | None:
|
| 975 |
+
marker = person.get("marker") if isinstance(person.get("marker"), dict) else {}
|
| 976 |
+
person_fields = person.get("fields") if isinstance(person.get("fields"), list) else []
|
| 977 |
+
marker_label = str(marker.get("label") or "Person").strip()
|
| 978 |
+
marker_value = strip_inline_markdown(str(marker.get("value") or "")).strip()
|
| 979 |
+
name_value = ""
|
| 980 |
+
title_value = ""
|
| 981 |
+
for field in person_fields:
|
| 982 |
+
label = str(field.get("label") or "").strip().lower()
|
| 983 |
+
if label == "name":
|
| 984 |
+
name_value = strip_inline_markdown(str(field.get("value") or "")).strip()
|
| 985 |
+
elif label == "title":
|
| 986 |
+
title_value = strip_inline_markdown(str(field.get("value") or "")).strip()
|
| 987 |
+
|
| 988 |
+
summary_parts = [part for part in (name_value, title_value) if part and part.lower() != "not available"]
|
| 989 |
+
if not summary_parts and marker_value and marker_value.lower() != "not available":
|
| 990 |
+
summary_parts.append(marker_value)
|
| 991 |
+
heading = marker_label + (f" - {' | '.join(summary_parts)}" if summary_parts else "")
|
| 992 |
+
|
| 993 |
+
rows: list[list[Any]] = [[Paragraph(paragraph_text(heading), person_card_title_style), Paragraph("", person_card_title_style)]]
|
| 994 |
+
for field in person_fields:
|
| 995 |
+
rows.append(
|
| 996 |
+
[
|
| 997 |
+
Paragraph(paragraph_text(field.get("label", "")), structured_label_style),
|
| 998 |
+
Paragraph(profile_field_value_html(field), structured_value_style),
|
| 999 |
+
]
|
| 1000 |
+
)
|
| 1001 |
+
if len(rows) == 1 and marker_value:
|
| 1002 |
+
rows.append(
|
| 1003 |
+
[
|
| 1004 |
+
Paragraph("Status", structured_label_style),
|
| 1005 |
+
Paragraph(markdown_inline_to_reportlab(marker_value), structured_value_style),
|
| 1006 |
+
]
|
| 1007 |
+
)
|
| 1008 |
+
if len(rows) == 1:
|
| 1009 |
+
return None
|
| 1010 |
+
|
| 1011 |
+
table = Table(rows, colWidths=[available_width * 0.24, available_width * 0.76], hAlign="LEFT", splitByRow=True)
|
| 1012 |
+
table.setStyle(
|
| 1013 |
+
TableStyle(
|
| 1014 |
+
[
|
| 1015 |
+
("SPAN", (0, 0), (-1, 0)),
|
| 1016 |
+
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#eef3f8")),
|
| 1017 |
+
("BACKGROUND", (0, 1), (0, -1), colors.HexColor("#f8fafc")),
|
| 1018 |
+
("BOX", (0, 0), (-1, -1), 0.45, colors.HexColor("#cdd8e3")),
|
| 1019 |
+
("INNERGRID", (0, 1), (-1, -1), 0.25, colors.HexColor("#d9e1e8")),
|
| 1020 |
+
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
| 1021 |
+
("LEFTPADDING", (0, 0), (-1, -1), 7),
|
| 1022 |
+
("RIGHTPADDING", (0, 0), (-1, -1), 7),
|
| 1023 |
+
("TOPPADDING", (0, 0), (-1, -1), 5),
|
| 1024 |
+
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
|
| 1025 |
+
]
|
| 1026 |
+
)
|
| 1027 |
+
)
|
| 1028 |
+
return KeepTogether([table, Spacer(1, 7)])
|
| 1029 |
+
|
| 1030 |
+
def is_short_profile_field(field: dict[str, Any]) -> bool:
|
| 1031 |
+
return not field.get("children") and len(strip_inline_markdown(str(field.get("value") or ""))) <= 95
|
| 1032 |
+
|
| 1033 |
+
structured_profile = parse_structured_profile(profile_text, title)
|
| 1034 |
+
if structured_profile:
|
| 1035 |
+
for section in structured_profile["sections"]:
|
| 1036 |
+
section_title = str(section.get("title") or "").strip()
|
| 1037 |
+
if not section_title or section_title.lower() == title.lower():
|
| 1038 |
+
continue
|
| 1039 |
+
story.append(Paragraph(paragraph_text(section_title), structured_section_style))
|
| 1040 |
+
story.append(HRFlowable(width="100%", thickness=0.35, color=colors.HexColor("#d9e1e8"), spaceAfter=6))
|
| 1041 |
+
|
| 1042 |
+
notes = [note for note in section.get("notes", []) if note]
|
| 1043 |
+
for note in notes:
|
| 1044 |
+
story.append(Paragraph(markdown_inline_to_reportlab(note), body_style))
|
| 1045 |
+
|
| 1046 |
+
fields = list(section.get("fields") or [])
|
| 1047 |
+
if section_title.lower() == "company basics":
|
| 1048 |
+
short_fields = [field for field in fields if is_short_profile_field(field)]
|
| 1049 |
+
long_fields = [field for field in fields if not is_short_profile_field(field)]
|
| 1050 |
+
grid = render_profile_fact_grid(short_fields)
|
| 1051 |
+
if grid:
|
| 1052 |
+
story.append(grid)
|
| 1053 |
+
story.append(Spacer(1, 8))
|
| 1054 |
+
table = render_profile_fields_table(long_fields)
|
| 1055 |
+
if table:
|
| 1056 |
+
story.append(table)
|
| 1057 |
+
story.append(Spacer(1, 8))
|
| 1058 |
+
elif section_title.lower() == "decision makers":
|
| 1059 |
+
general_fields, people = split_decision_maker_fields(fields)
|
| 1060 |
+
table = render_profile_fields_table(general_fields)
|
| 1061 |
+
if table:
|
| 1062 |
+
story.append(table)
|
| 1063 |
+
story.append(Spacer(1, 8))
|
| 1064 |
+
for person in people:
|
| 1065 |
+
card = render_decision_maker_card(person)
|
| 1066 |
+
if card:
|
| 1067 |
+
story.append(card)
|
| 1068 |
+
else:
|
| 1069 |
+
table = render_profile_fields_table(fields)
|
| 1070 |
+
if table:
|
| 1071 |
+
story.append(table)
|
| 1072 |
+
story.append(Spacer(1, 8))
|
| 1073 |
+
|
| 1074 |
+
story.extend([Spacer(1, 10), Paragraph("Generated by ProspectIQ. Company Research formatting optimized for PDF readability.", small_style)])
|
| 1075 |
+
doc.build(story)
|
| 1076 |
+
output.seek(0)
|
| 1077 |
+
return output
|
| 1078 |
+
|
| 1079 |
+
html = markdown.markdown(
|
| 1080 |
+
str(profile_text or ""),
|
| 1081 |
+
extensions=["extra", "sane_lists", "nl2br"],
|
| 1082 |
+
output_format="html5",
|
| 1083 |
+
)
|
| 1084 |
+
soup = BeautifulSoup(html, "html.parser")
|
| 1085 |
+
|
| 1086 |
+
def element_text(element: Tag) -> str:
|
| 1087 |
+
nested_lists = element.find_all(["ul", "ol"], recursive=False)
|
| 1088 |
+
for nested in nested_lists:
|
| 1089 |
+
nested.extract()
|
| 1090 |
+
return html_nodes_to_reportlab(element.contents)
|
| 1091 |
+
|
| 1092 |
+
def render_list(list_element: Tag, level: int = 0) -> ListFlowable:
|
| 1093 |
+
items: list[Any] = []
|
| 1094 |
+
for li in list_element.find_all("li", recursive=False):
|
| 1095 |
+
nested_lists = [child for child in li.children if isinstance(child, Tag) and child.name in {"ul", "ol"}]
|
| 1096 |
+
content_nodes = [child for child in li.contents if not (isinstance(child, Tag) and child.name in {"ul", "ol"})]
|
| 1097 |
+
content = html_nodes_to_reportlab(content_nodes) or " "
|
| 1098 |
+
flowables: list[Any] = [Paragraph(content, bullet_style)]
|
| 1099 |
+
for nested in nested_lists:
|
| 1100 |
+
flowables.append(render_list(nested, level + 1))
|
| 1101 |
+
items.append(ListItem(flowables, leftIndent=level * 10))
|
| 1102 |
+
return ListFlowable(
|
| 1103 |
+
items,
|
| 1104 |
+
bulletType="1" if list_element.name == "ol" else "bullet",
|
| 1105 |
+
leftIndent=18 + level * 12,
|
| 1106 |
+
bulletFontName="Helvetica",
|
| 1107 |
+
bulletFontSize=7,
|
| 1108 |
+
bulletOffsetY=1,
|
| 1109 |
+
spaceBefore=2,
|
| 1110 |
+
spaceAfter=6 if level == 0 else 2,
|
| 1111 |
+
)
|
| 1112 |
+
|
| 1113 |
+
def render_table(table_element: Tag) -> Table:
|
| 1114 |
+
rows: list[list[Any]] = []
|
| 1115 |
+
for tr in table_element.find_all("tr"):
|
| 1116 |
+
cells = tr.find_all(["th", "td"], recursive=False)
|
| 1117 |
+
if not cells:
|
| 1118 |
+
continue
|
| 1119 |
+
row: list[Any] = []
|
| 1120 |
+
for cell in cells:
|
| 1121 |
+
style = table_header_style if cell.name == "th" else table_cell_style
|
| 1122 |
+
row.append(Paragraph(html_nodes_to_reportlab(cell.contents), style))
|
| 1123 |
+
rows.append(row)
|
| 1124 |
+
if not rows:
|
| 1125 |
+
rows = [[Paragraph("", table_cell_style)]]
|
| 1126 |
+
column_count = max(len(row) for row in rows)
|
| 1127 |
+
for row in rows:
|
| 1128 |
+
while len(row) < column_count:
|
| 1129 |
+
row.append(Paragraph("", table_cell_style))
|
| 1130 |
+
available_width = A4[0] - doc.leftMargin - doc.rightMargin
|
| 1131 |
+
table = Table(rows, colWidths=[available_width / column_count] * column_count, hAlign="LEFT", splitByRow=True)
|
| 1132 |
+
table.setStyle(
|
| 1133 |
+
TableStyle(
|
| 1134 |
+
[
|
| 1135 |
+
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#f0f3f6")),
|
| 1136 |
+
("GRID", (0, 0), (-1, -1), 0.35, colors.HexColor("#d9e1e8")),
|
| 1137 |
+
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
| 1138 |
+
("LEFTPADDING", (0, 0), (-1, -1), 5),
|
| 1139 |
+
("RIGHTPADDING", (0, 0), (-1, -1), 5),
|
| 1140 |
+
("TOPPADDING", (0, 0), (-1, -1), 5),
|
| 1141 |
+
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
|
| 1142 |
+
]
|
| 1143 |
+
)
|
| 1144 |
+
)
|
| 1145 |
+
return table
|
| 1146 |
+
|
| 1147 |
+
def humanize_json_key(value: Any) -> str:
|
| 1148 |
+
text = str(value or "").strip()
|
| 1149 |
+
text = re.sub(r"[_-]+", " ", text)
|
| 1150 |
+
text = re.sub(r"\s+", " ", text)
|
| 1151 |
+
return text[:1].upper() + text[1:] if text else "Value"
|
| 1152 |
+
|
| 1153 |
+
def json_scalar(value: Any) -> str:
|
| 1154 |
+
if value is None:
|
| 1155 |
+
return ""
|
| 1156 |
+
if isinstance(value, bool):
|
| 1157 |
+
return "Yes" if value else "No"
|
| 1158 |
+
if isinstance(value, (int, float)):
|
| 1159 |
+
return str(value)
|
| 1160 |
+
return str(value)
|
| 1161 |
+
|
| 1162 |
+
def json_flowables(value: Any, title_text: str = "") -> list[Any]:
|
| 1163 |
+
flowables: list[Any] = []
|
| 1164 |
+
available_width = A4[0] - doc.leftMargin - doc.rightMargin
|
| 1165 |
+
|
| 1166 |
+
if title_text:
|
| 1167 |
+
flowables.append(Paragraph(paragraph_text(humanize_json_key(title_text)), heading_styles["h3"]))
|
| 1168 |
+
|
| 1169 |
+
if isinstance(value, dict):
|
| 1170 |
+
simple_rows: list[list[Any]] = []
|
| 1171 |
+
complex_items: list[tuple[str, Any]] = []
|
| 1172 |
+
for key, item in value.items():
|
| 1173 |
+
if isinstance(item, (dict, list)):
|
| 1174 |
+
complex_items.append((str(key), item))
|
| 1175 |
+
else:
|
| 1176 |
+
simple_rows.append(
|
| 1177 |
+
[
|
| 1178 |
+
Paragraph(paragraph_text(humanize_json_key(key)), json_label_style),
|
| 1179 |
+
Paragraph(markdown_inline_to_reportlab(json_scalar(item)), json_value_style),
|
| 1180 |
+
]
|
| 1181 |
+
)
|
| 1182 |
+
if simple_rows:
|
| 1183 |
+
table = Table(simple_rows, colWidths=[available_width * 0.28, available_width * 0.72], hAlign="LEFT", splitByRow=True)
|
| 1184 |
+
table.setStyle(
|
| 1185 |
+
TableStyle(
|
| 1186 |
+
[
|
| 1187 |
+
("GRID", (0, 0), (-1, -1), 0.3, colors.HexColor("#d9e1e8")),
|
| 1188 |
+
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
| 1189 |
+
("LEFTPADDING", (0, 0), (-1, -1), 6),
|
| 1190 |
+
("RIGHTPADDING", (0, 0), (-1, -1), 6),
|
| 1191 |
+
("TOPPADDING", (0, 0), (-1, -1), 5),
|
| 1192 |
+
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
|
| 1193 |
+
]
|
| 1194 |
+
)
|
| 1195 |
+
)
|
| 1196 |
+
flowables.extend([table, Spacer(1, 8)])
|
| 1197 |
+
for key, item in complex_items:
|
| 1198 |
+
flowables.extend(json_flowables(item, str(key)))
|
| 1199 |
+
return flowables
|
| 1200 |
+
|
| 1201 |
+
if isinstance(value, list):
|
| 1202 |
+
dict_items = [item for item in value if isinstance(item, dict)]
|
| 1203 |
+
if dict_items and len(dict_items) == len(value):
|
| 1204 |
+
keys: list[str] = []
|
| 1205 |
+
for item in dict_items:
|
| 1206 |
+
for key in item.keys():
|
| 1207 |
+
if key not in keys and not isinstance(item.get(key), (dict, list)):
|
| 1208 |
+
keys.append(str(key))
|
| 1209 |
+
if keys:
|
| 1210 |
+
rows: list[list[Any]] = [
|
| 1211 |
+
[Paragraph(paragraph_text(humanize_json_key(key)), table_header_style) for key in keys]
|
| 1212 |
+
]
|
| 1213 |
+
for item in dict_items:
|
| 1214 |
+
rows.append(
|
| 1215 |
+
[
|
| 1216 |
+
Paragraph(markdown_inline_to_reportlab(json_scalar(item.get(key))), json_value_style)
|
| 1217 |
+
for key in keys
|
| 1218 |
+
]
|
| 1219 |
+
)
|
| 1220 |
+
table = Table(rows, colWidths=[available_width / len(keys)] * len(keys), hAlign="LEFT", splitByRow=True)
|
| 1221 |
+
table.setStyle(
|
| 1222 |
+
TableStyle(
|
| 1223 |
+
[
|
| 1224 |
+
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#f0f3f6")),
|
| 1225 |
+
("GRID", (0, 0), (-1, -1), 0.3, colors.HexColor("#d9e1e8")),
|
| 1226 |
+
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
| 1227 |
+
("LEFTPADDING", (0, 0), (-1, -1), 5),
|
| 1228 |
+
("RIGHTPADDING", (0, 0), (-1, -1), 5),
|
| 1229 |
+
("TOPPADDING", (0, 0), (-1, -1), 5),
|
| 1230 |
+
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
|
| 1231 |
+
]
|
| 1232 |
+
)
|
| 1233 |
+
)
|
| 1234 |
+
flowables.extend([table, Spacer(1, 8)])
|
| 1235 |
+
for index, item in enumerate(dict_items, start=1):
|
| 1236 |
+
nested = {key: nested_value for key, nested_value in item.items() if isinstance(nested_value, (dict, list))}
|
| 1237 |
+
if nested:
|
| 1238 |
+
flowables.extend(json_flowables(nested, f"Item {index} details"))
|
| 1239 |
+
return flowables
|
| 1240 |
+
|
| 1241 |
+
list_items = [
|
| 1242 |
+
ListItem([Paragraph(markdown_inline_to_reportlab(json_scalar(item)), bullet_style)])
|
| 1243 |
+
for item in value
|
| 1244 |
+
if not isinstance(item, (dict, list))
|
| 1245 |
+
]
|
| 1246 |
+
if list_items:
|
| 1247 |
+
flowables.append(ListFlowable(list_items, bulletType="bullet", leftIndent=18, bulletFontSize=7, spaceAfter=8))
|
| 1248 |
+
for index, item in enumerate(value, start=1):
|
| 1249 |
+
if isinstance(item, (dict, list)):
|
| 1250 |
+
flowables.extend(json_flowables(item, f"Item {index}"))
|
| 1251 |
+
return flowables
|
| 1252 |
+
|
| 1253 |
+
text = json_scalar(value)
|
| 1254 |
+
if text:
|
| 1255 |
+
flowables.append(Paragraph(markdown_inline_to_reportlab(text), body_style))
|
| 1256 |
+
return flowables
|
| 1257 |
+
|
| 1258 |
+
def render_code_block(raw_text: str) -> list[Any]:
|
| 1259 |
+
text = raw_text.strip()
|
| 1260 |
+
json_text = re.sub(r"^\s*json\s+", "", text, flags=re.I | re.S).strip()
|
| 1261 |
+
try:
|
| 1262 |
+
parsed = json.loads(json_text)
|
| 1263 |
+
except (TypeError, json.JSONDecodeError):
|
| 1264 |
+
return [Paragraph(paragraph_text(text), code_style), Spacer(1, 8)]
|
| 1265 |
+
return [*json_flowables(parsed), Spacer(1, 4)]
|
| 1266 |
+
|
| 1267 |
+
for child in soup.contents:
|
| 1268 |
+
if isinstance(child, NavigableString):
|
| 1269 |
+
if child.strip():
|
| 1270 |
+
story.append(Paragraph(markdown_inline_to_reportlab(str(child)), body_style))
|
| 1271 |
+
continue
|
| 1272 |
+
if not isinstance(child, Tag):
|
| 1273 |
+
continue
|
| 1274 |
+
|
| 1275 |
+
tag_name = child.name.lower()
|
| 1276 |
+
if tag_name in heading_styles:
|
| 1277 |
+
story.append(Paragraph(html_nodes_to_reportlab(child.contents), heading_styles[tag_name]))
|
| 1278 |
+
if tag_name == "h1":
|
| 1279 |
+
story.append(HRFlowable(width="100%", thickness=0.4, color=colors.HexColor("#d9e1e8"), spaceAfter=4))
|
| 1280 |
+
elif tag_name in {"h4", "h5", "h6"}:
|
| 1281 |
+
story.append(Paragraph(html_nodes_to_reportlab(child.contents), heading_styles["h3"]))
|
| 1282 |
+
elif tag_name == "p":
|
| 1283 |
+
text = html_nodes_to_reportlab(child.contents)
|
| 1284 |
+
if text:
|
| 1285 |
+
story.append(Paragraph(text, body_style))
|
| 1286 |
+
elif tag_name in {"ul", "ol"}:
|
| 1287 |
+
story.append(render_list(child))
|
| 1288 |
+
elif tag_name == "hr":
|
| 1289 |
+
story.append(Spacer(1, 7))
|
| 1290 |
+
story.append(HRFlowable(width="100%", thickness=0.45, color=colors.HexColor("#d9e1e8"), spaceAfter=7))
|
| 1291 |
+
elif tag_name == "table":
|
| 1292 |
+
story.append(render_table(child))
|
| 1293 |
+
story.append(Spacer(1, 8))
|
| 1294 |
+
elif tag_name == "pre":
|
| 1295 |
+
story.extend(render_code_block(child.get_text()))
|
| 1296 |
+
elif tag_name == "blockquote":
|
| 1297 |
+
text = html_nodes_to_reportlab(child.contents)
|
| 1298 |
+
if text:
|
| 1299 |
+
quote_style = ParagraphStyle(
|
| 1300 |
+
"ProspectQuote",
|
| 1301 |
+
parent=body_style,
|
| 1302 |
+
leftIndent=14,
|
| 1303 |
+
borderColor=colors.HexColor("#d9e1e8"),
|
| 1304 |
+
borderWidth=0.5,
|
| 1305 |
+
borderPadding=7,
|
| 1306 |
+
textColor=colors.HexColor("#33445a"),
|
| 1307 |
+
)
|
| 1308 |
+
story.append(Paragraph(text, quote_style))
|
| 1309 |
+
else:
|
| 1310 |
+
text = html_nodes_to_reportlab(child.contents)
|
| 1311 |
+
if text:
|
| 1312 |
+
story.append(Paragraph(text, body_style))
|
| 1313 |
+
|
| 1314 |
+
story.extend([Spacer(1, 18), Paragraph("Generated by ProspectIQ. Markdown formatting preserved from the selected profile file.", small_style)])
|
| 1315 |
+
doc.build(story)
|
| 1316 |
+
output.seek(0)
|
| 1317 |
+
return output
|
services/geo_search.py
ADDED
|
@@ -0,0 +1,811 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
finale.py — Multi-AI Company Prospecting Pipeline
|
| 3 |
+
|
| 4 |
+
Pipeline:
|
| 5 |
+
1. Detect input type (text vs coordinates)
|
| 6 |
+
2. If coordinates → Nominatim reverse geocode → location text
|
| 7 |
+
3. Parallel execution:
|
| 8 |
+
a. Google Search AI (separate API key) — finds companies, employees, websites
|
| 9 |
+
b. Google Maps AI (separate API key) — finds businesses near location
|
| 10 |
+
4. Combine results from both
|
| 11 |
+
5. Checker AI (separate API key) — validates, deduplicates, verifies accuracy
|
| 12 |
+
6. Judge/Ranking AI (separate API key) — ranks companies based on user query
|
| 13 |
+
"""
|
| 14 |
+
"""An industry ("automotive manufacturers")
|
| 15 |
+
A service ("CNC machining companies")
|
| 16 |
+
A location ("Hamilton, Ontario")
|
| 17 |
+
Or coordinates ("43.2557,-79.8711"""
|
| 18 |
+
|
| 19 |
+
import re
|
| 20 |
+
import requests
|
| 21 |
+
import concurrent.futures
|
| 22 |
+
from google import genai
|
| 23 |
+
from google.genai import types
|
| 24 |
+
|
| 25 |
+
from services.ai_service import with_gemini_key_fallback
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
#====================================================
|
| 29 |
+
# API KEYS — Use separate keys for each AI agent
|
| 30 |
+
#====================================================
|
| 31 |
+
#====================================================
|
| 32 |
+
# SYSTEM PROMPTS
|
| 33 |
+
#====================================================
|
| 34 |
+
SEARCH_SYSTEM_PROMPT = """You are a company research analyst. Find legitimate, verified companies matching the user's query and location.
|
| 35 |
+
|
| 36 |
+
For each company return:
|
| 37 |
+
- Company name
|
| 38 |
+
- Official website URL (confirmed live — never guess; write "Website: Not found" if unconfirmed)
|
| 39 |
+
- Industry / sector
|
| 40 |
+
- Services or products
|
| 41 |
+
- Employee count: search LinkedIn, Crunchbase, or company site; classify as Micro(1-10), Small(11-50), Medium(51-500), Large(501-5000), Enterprise(5000+); include numeric estimate (e.g. "~250 — Medium"); write "Not available" if not found
|
| 42 |
+
- Phone number (if available)
|
| 43 |
+
- Email address (if available)
|
| 44 |
+
- Year founded (if available)
|
| 45 |
+
- Brief description
|
| 46 |
+
|
| 47 |
+
LINKEDIN (mandatory for every company — search "company name site:linkedin.com/company"):
|
| 48 |
+
- LinkedIn URL (live URL only, never guessed)
|
| 49 |
+
- Headcount (as shown on LinkedIn)
|
| 50 |
+
- Headquarters, Founded year, Industry, Specialties, Followers
|
| 51 |
+
- LinkedIn About (verbatim, do not paraphrase)
|
| 52 |
+
- Write "LinkedIn: Not found" if the page cannot be found
|
| 53 |
+
|
| 54 |
+
Rules:
|
| 55 |
+
- Only include currently operational companies with a verifiable online presence.
|
| 56 |
+
- Never fabricate data; write "Not available" for missing fields.
|
| 57 |
+
- Employee count is mandatory — always search for it.
|
| 58 |
+
- Never construct or guess URLs; only include URLs found live in search results or credible sources (LinkedIn, Crunchbase, Maps listing).
|
| 59 |
+
- Return consistent structured output for every entry."""
|
| 60 |
+
|
| 61 |
+
MAPS_SYSTEM_PROMPT = """You are a geographic business discovery specialist using Google Maps data.
|
| 62 |
+
|
| 63 |
+
Find real businesses near the specified location that match the user's query. For each business return:
|
| 64 |
+
- Business name
|
| 65 |
+
- Full address
|
| 66 |
+
- Category / type
|
| 67 |
+
- Google Maps rating and review count (if available)
|
| 68 |
+
- Phone number (if available)
|
| 69 |
+
- Email address (if available)
|
| 70 |
+
- Website URL (if available)
|
| 71 |
+
|
| 72 |
+
Rules:
|
| 73 |
+
- Only include physically existing businesses near the location.
|
| 74 |
+
- Do NOT include employee count — that is not your role.
|
| 75 |
+
- Never fabricate addresses or ratings; write "Not available" for missing data.
|
| 76 |
+
- Prioritize businesses relevant to the query.
|
| 77 |
+
- Return consistent structured output for every entry."""
|
| 78 |
+
|
| 79 |
+
CHECKER_SYSTEM_PROMPT = """You are a data verification and legitimacy specialist.
|
| 80 |
+
|
| 81 |
+
You receive combined results from Google Search and Google Maps. Your tasks:
|
| 82 |
+
|
| 83 |
+
1. CROSS-VERIFY: If the same company appears in both sources, merge into one entry (prefer most detailed/accurate data).
|
| 84 |
+
2. VALIDATE URLs (critical): Actively resolve every website URL. Keep it if confirmed live and belonging to the company. If the domain doesn't exist, errors, or redirects elsewhere → replace with "Website: Not found (unresolvable domain)". Never keep guessed URLs; never invent replacements.
|
| 85 |
+
3. VALIDATE CONSISTENCY: Flag logical inconsistencies (e.g., 10,000 employees listed as a tiny local shop).
|
| 86 |
+
4. DEDUPLICATE: Consolidate duplicate entries into one comprehensive record.
|
| 87 |
+
5. REMOVE IRRELEVANT: Remove entries clearly unrelated to the user's query. Do not aggressively remove companies just because their HQ is in a different city if they might have a presence in the queried location. Let the ranking AI score them lower instead.
|
| 88 |
+
6. FLAG UNCERTAINTIES: Mark unverifiable data with [UNVERIFIED].
|
| 89 |
+
7. LOCATION VERIFICATION (every entry): Cross-check address against website, Maps listing, or public records.
|
| 90 |
+
- Confirmed at queried location → [LOCATION VERIFIED ✓]
|
| 91 |
+
- Address doesn't match or unconfirmable → [LOCATION MISMATCH ✗] + explain
|
| 92 |
+
- Insufficient data → [LOCATION UNVERIFIED]
|
| 93 |
+
8. LEGITIMACY VERIFICATION (every entry): Check for working website, consistent name across sources, verifiable registration, real contact details.
|
| 94 |
+
- Passes checks → [LEGITIMATE ✓]
|
| 95 |
+
- Signs of fabrication or not found in credible sources → [LEGITIMACY QUESTIONABLE ✗] + reasons
|
| 96 |
+
- Inconclusive → [LEGITIMACY UNVERIFIED]
|
| 97 |
+
|
| 98 |
+
Rules:
|
| 99 |
+
- Do NOT add new companies or data not present in the input.
|
| 100 |
+
- Do NOT remove entries just for limited info — only remove duplicates and irrelevant ones.
|
| 101 |
+
- Every entry MUST have both a Location Verification status and a Legitimacy status.
|
| 102 |
+
- End with a brief summary of corrections, removals, flags, and URL issues."""
|
| 103 |
+
|
| 104 |
+
JUDGE_SYSTEM_PROMPT = """You are a business ranking analyst.
|
| 105 |
+
|
| 106 |
+
Rank the verified companies from most to least relevant to the user's query using these criteria (in order):
|
| 107 |
+
1. RELEVANCE — services/products match the query
|
| 108 |
+
2. CREDIBILITY — verified website, established presence, good ratings
|
| 109 |
+
3. COMPLETENESS — amount of verified info available
|
| 110 |
+
4. LOCATION FIT — proximity/match to the user's specified area
|
| 111 |
+
5. SCALE — size appropriate for the user's likely needs
|
| 112 |
+
|
| 113 |
+
For each company, you MUST output the details in the following exact format:
|
| 114 |
+
|
| 115 |
+
Rank [Rank Number]. [Company Name]
|
| 116 |
+
Key reason: [Reason]
|
| 117 |
+
Category: [Category / Industry, e.g. AI Services]
|
| 118 |
+
Full address: [Full street address as provided, or N/A]
|
| 119 |
+
Phone number: [Phone number as provided, or N/A if missing]
|
| 120 |
+
Website: [Website URL, or N/A if missing or dead link]
|
| 121 |
+
Email: [Email address as provided, or N/A if missing]
|
| 122 |
+
Rating: [Google Maps rating or N/A]
|
| 123 |
+
Services or products: [Services/products as provided, or N/A]
|
| 124 |
+
Verification: [Location label, e.g. LOCATION VERIFIED ✓ or LOCATION MISMATCH ✖ or LOCATION UNVERIFIED]
|
| 125 |
+
|
| 126 |
+
Rules:
|
| 127 |
+
- Be objective; do NOT invent information — use only what was provided in the checked results.
|
| 128 |
+
- Do NOT output any summary or explanation paragraphs at the end of the ranking list. ONLY output the ranked company blocks.
|
| 129 |
+
- For companies clearly outside the queried location (e.g., Bengaluru instead of Chennai), rank them at the very bottom and state "Location mismatch" in the Key reason.
|
| 130 |
+
- For Website, Phone number, and Email: If the exact valid value is not found, or if it is a dead link, strictly output "N/A". Do not output descriptions like "Not directly listed".
|
| 131 |
+
- Preserve ALL available details from the checked results for each company, especially address, phone, email, and services."""
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
#====================================================
|
| 135 |
+
# STEP 1: INPUT DETECTION
|
| 136 |
+
#====================================================
|
| 137 |
+
def detect_input_type(user_input: str) -> dict:
|
| 138 |
+
"""
|
| 139 |
+
Detect whether the user input contains coordinates or is plain text.
|
| 140 |
+
Returns a dict with 'type' ('coordinates' or 'text') and parsed values.
|
| 141 |
+
"""
|
| 142 |
+
# Pattern: lat, lon (e.g., "12.225284, 79.074699" or "12.225284 79.074699")
|
| 143 |
+
coord_pattern = r'[-+]?\d{1,3}\.\d+[\s,]+[-+]?\d{1,3}\.\d+'
|
| 144 |
+
match = re.search(coord_pattern, user_input)
|
| 145 |
+
|
| 146 |
+
if match:
|
| 147 |
+
coord_str = match.group()
|
| 148 |
+
parts = re.split(r'[\s,]+', coord_str)
|
| 149 |
+
lat, lon = float(parts[0]), float(parts[1])
|
| 150 |
+
|
| 151 |
+
# Validate coordinate ranges
|
| 152 |
+
if -90 <= lat <= 90 and -180 <= lon <= 180:
|
| 153 |
+
# Extract the query part (everything except the coordinates)
|
| 154 |
+
query_text = user_input.replace(match.group(), '').strip()
|
| 155 |
+
query_text = re.sub(r'\s+', ' ', query_text).strip(' ,')
|
| 156 |
+
|
| 157 |
+
return {
|
| 158 |
+
'type': 'coordinates',
|
| 159 |
+
'latitude': lat,
|
| 160 |
+
'longitude': lon,
|
| 161 |
+
'query_text': query_text if query_text else None,
|
| 162 |
+
'raw_input': user_input
|
| 163 |
+
}
|
| 164 |
+
|
| 165 |
+
return {
|
| 166 |
+
'type': 'text',
|
| 167 |
+
'query_text': user_input,
|
| 168 |
+
'latitude': None,
|
| 169 |
+
'longitude': None,
|
| 170 |
+
'raw_input': user_input
|
| 171 |
+
}
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
#====================================================
|
| 175 |
+
# STEP 2: NOMINATIM REVERSE GEOCODING
|
| 176 |
+
#====================================================
|
| 177 |
+
def nominatim_reverse_geocode(lat: float, lon: float) -> dict:
|
| 178 |
+
"""
|
| 179 |
+
Convert coordinates to a human-readable location using OpenStreetMap Nominatim.
|
| 180 |
+
"""
|
| 181 |
+
url = "https://nominatim.openstreetmap.org/reverse"
|
| 182 |
+
params = {
|
| 183 |
+
"lat": lat,
|
| 184 |
+
"lon": lon,
|
| 185 |
+
"format": "jsonv2",
|
| 186 |
+
"addressdetails": 1,
|
| 187 |
+
"zoom": 18
|
| 188 |
+
}
|
| 189 |
+
headers = {
|
| 190 |
+
"User-Agent": "prospect_finder_app/1.0"
|
| 191 |
+
}
|
| 192 |
+
|
| 193 |
+
try:
|
| 194 |
+
response = requests.get(url, params=params, headers=headers, timeout=10)
|
| 195 |
+
response.raise_for_status()
|
| 196 |
+
data = response.json()
|
| 197 |
+
|
| 198 |
+
address = data.get("address", {})
|
| 199 |
+
return {
|
| 200 |
+
"display_name": data.get("display_name", "Unknown location"),
|
| 201 |
+
"city": address.get("city", address.get("town", address.get("village", ""))),
|
| 202 |
+
"state": address.get("state", ""),
|
| 203 |
+
"country": address.get("country", ""),
|
| 204 |
+
"postcode": address.get("postcode", ""),
|
| 205 |
+
"full_address": address
|
| 206 |
+
}
|
| 207 |
+
except Exception as e:
|
| 208 |
+
print(f"[Nominatim Error] {e}")
|
| 209 |
+
return {
|
| 210 |
+
"display_name": f"Location at {lat}, {lon}",
|
| 211 |
+
"city": "", "state": "", "country": "",
|
| 212 |
+
"postcode": "", "full_address": {}
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
#====================================================
|
| 217 |
+
# STEP 3a: GOOGLE SEARCH AI
|
| 218 |
+
#====================================================
|
| 219 |
+
def run_google_search_ai(query: str, location_context: str) -> str:
|
| 220 |
+
"""
|
| 221 |
+
Use Gemini with Google Search grounding to find company information.
|
| 222 |
+
"""
|
| 223 |
+
search_prompt = f"""User Query: {query}
|
| 224 |
+
Location Context: {location_context}
|
| 225 |
+
|
| 226 |
+
Search for legitimate companies and businesses that match the above query in the specified location.
|
| 227 |
+
Provide detailed information for each company found."""
|
| 228 |
+
|
| 229 |
+
try:
|
| 230 |
+
def request_search(api_key: str):
|
| 231 |
+
client = genai.Client(api_key=api_key)
|
| 232 |
+
return client.models.generate_content(
|
| 233 |
+
model="gemini-2.5-flash",
|
| 234 |
+
contents=search_prompt,
|
| 235 |
+
config=types.GenerateContentConfig(
|
| 236 |
+
system_instruction=SEARCH_SYSTEM_PROMPT,
|
| 237 |
+
tools=[types.Tool(google_search=types.GoogleSearch())],
|
| 238 |
+
service_tier=types.ServiceTier.PRIORITY,
|
| 239 |
+
),
|
| 240 |
+
)
|
| 241 |
+
|
| 242 |
+
response = with_gemini_key_fallback((), request_search)
|
| 243 |
+
|
| 244 |
+
result_text = response.text or ""
|
| 245 |
+
|
| 246 |
+
# Extract grounding sources
|
| 247 |
+
sources = []
|
| 248 |
+
if response.candidates and response.candidates[0].grounding_metadata:
|
| 249 |
+
grounding = response.candidates[0].grounding_metadata
|
| 250 |
+
if grounding.grounding_chunks:
|
| 251 |
+
for chunk in grounding.grounding_chunks:
|
| 252 |
+
if hasattr(chunk, 'web') and chunk.web:
|
| 253 |
+
sources.append(f"- [{chunk.web.title}]({chunk.web.uri})")
|
| 254 |
+
|
| 255 |
+
if sources:
|
| 256 |
+
result_text += "\n\n### Google Search Sources:\n" + "\n".join(sources)
|
| 257 |
+
|
| 258 |
+
return result_text
|
| 259 |
+
|
| 260 |
+
except Exception as gemini_err:
|
| 261 |
+
# --- Claude Web Search Fallback ---
|
| 262 |
+
import os
|
| 263 |
+
import anthropic
|
| 264 |
+
claude_key = os.environ.get("ANTHROPIC_API_KEY", "").strip()
|
| 265 |
+
if not claude_key:
|
| 266 |
+
return f"[Google Search AI Error] {gemini_err}"
|
| 267 |
+
|
| 268 |
+
client = anthropic.Anthropic(api_key=claude_key)
|
| 269 |
+
|
| 270 |
+
messages = [{"role": "user", "content": search_prompt}]
|
| 271 |
+
all_sources = []
|
| 272 |
+
max_continuations = 5
|
| 273 |
+
continuation_count = 0
|
| 274 |
+
full_response = ""
|
| 275 |
+
|
| 276 |
+
TOOLS = [{
|
| 277 |
+
"type": "web_search_20250305",
|
| 278 |
+
"name": "web_search",
|
| 279 |
+
"max_uses": 5
|
| 280 |
+
}]
|
| 281 |
+
|
| 282 |
+
while True:
|
| 283 |
+
try:
|
| 284 |
+
message = client.messages.create(
|
| 285 |
+
model="claude-haiku-4-5",
|
| 286 |
+
max_tokens=4096,
|
| 287 |
+
system=SEARCH_SYSTEM_PROMPT,
|
| 288 |
+
messages=messages,
|
| 289 |
+
tools=TOOLS,
|
| 290 |
+
)
|
| 291 |
+
except Exception as claude_err:
|
| 292 |
+
return f"[Claude Search AI Error] {claude_err} (Gemini error: {gemini_err})"
|
| 293 |
+
|
| 294 |
+
for block in message.content:
|
| 295 |
+
if block.type == "text":
|
| 296 |
+
full_response += block.text
|
| 297 |
+
if hasattr(block, "citations") and block.citations:
|
| 298 |
+
for citation in block.citations:
|
| 299 |
+
if getattr(citation, "type", "") == "web_search_result_location":
|
| 300 |
+
source = f"- [{getattr(citation, 'title', 'source')}]({getattr(citation, 'url', '')})"
|
| 301 |
+
if source not in all_sources:
|
| 302 |
+
all_sources.append(source)
|
| 303 |
+
|
| 304 |
+
stop_reason = message.stop_reason
|
| 305 |
+
|
| 306 |
+
if stop_reason == "end_turn":
|
| 307 |
+
break
|
| 308 |
+
elif stop_reason == "max_tokens":
|
| 309 |
+
continuation_count += 1
|
| 310 |
+
if continuation_count >= max_continuations:
|
| 311 |
+
break
|
| 312 |
+
messages.append({"role": "assistant", "content": message.content})
|
| 313 |
+
messages.append({
|
| 314 |
+
"role": "user",
|
| 315 |
+
"content": "Your previous response was cut off. Please continue exactly from where you left off.",
|
| 316 |
+
})
|
| 317 |
+
elif stop_reason == "pause_turn":
|
| 318 |
+
continuation_count += 1
|
| 319 |
+
if continuation_count >= max_continuations:
|
| 320 |
+
break
|
| 321 |
+
messages.append({"role": "assistant", "content": message.content})
|
| 322 |
+
elif stop_reason == "refusal":
|
| 323 |
+
full_response += "\n\n[Refusal: Claude declined to respond]"
|
| 324 |
+
break
|
| 325 |
+
else:
|
| 326 |
+
break
|
| 327 |
+
|
| 328 |
+
if all_sources:
|
| 329 |
+
full_response += "\n\n### Claude Web Search Sources:\n" + "\n".join(all_sources)
|
| 330 |
+
|
| 331 |
+
return full_response
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
#====================================================
|
| 335 |
+
# STEP 3b: GOOGLE MAPS AI
|
| 336 |
+
#====================================================
|
| 337 |
+
def run_google_maps_ai(query: str, lat: float, lon: float, location_context: str) -> str:
|
| 338 |
+
"""
|
| 339 |
+
Use Gemini with Google Maps grounding to find businesses near coordinates.
|
| 340 |
+
"""
|
| 341 |
+
maps_prompt = f"""User Query: {query}
|
| 342 |
+
Location: {location_context}
|
| 343 |
+
Coordinates: Latitude {lat}, Longitude {lon}
|
| 344 |
+
|
| 345 |
+
Find businesses and companies near these coordinates that match the user's query.
|
| 346 |
+
Provide detailed location-based information for each business found."""
|
| 347 |
+
|
| 348 |
+
try:
|
| 349 |
+
config = types.GenerateContentConfig(
|
| 350 |
+
system_instruction=MAPS_SYSTEM_PROMPT,
|
| 351 |
+
service_tier=types.ServiceTier.PRIORITY,
|
| 352 |
+
tools=[types.Tool(google_maps=types.GoogleMaps())],
|
| 353 |
+
tool_config=types.ToolConfig(
|
| 354 |
+
retrieval_config=types.RetrievalConfig(
|
| 355 |
+
lat_lng=types.LatLng(latitude=lat, longitude=lon)
|
| 356 |
+
)
|
| 357 |
+
),
|
| 358 |
+
)
|
| 359 |
+
|
| 360 |
+
def request_maps(api_key: str):
|
| 361 |
+
client = genai.Client(api_key=api_key)
|
| 362 |
+
return client.models.generate_content(
|
| 363 |
+
model="gemini-2.5-flash",
|
| 364 |
+
contents=maps_prompt,
|
| 365 |
+
config=config,
|
| 366 |
+
)
|
| 367 |
+
|
| 368 |
+
response = with_gemini_key_fallback((), request_maps)
|
| 369 |
+
|
| 370 |
+
result_text = response.text or ""
|
| 371 |
+
|
| 372 |
+
# Extract grounding sources from Maps
|
| 373 |
+
sources = []
|
| 374 |
+
if response.candidates and response.candidates[0].grounding_metadata:
|
| 375 |
+
grounding = response.candidates[0].grounding_metadata
|
| 376 |
+
if grounding.grounding_chunks:
|
| 377 |
+
for chunk in grounding.grounding_chunks:
|
| 378 |
+
if hasattr(chunk, 'maps') and chunk.maps:
|
| 379 |
+
sources.append(f"- [{chunk.maps.title}]({chunk.maps.uri})")
|
| 380 |
+
elif hasattr(chunk, 'web') and chunk.web:
|
| 381 |
+
sources.append(f"- [{chunk.web.title}]({chunk.web.uri})")
|
| 382 |
+
|
| 383 |
+
if sources:
|
| 384 |
+
result_text += "\n\n### Google Maps Sources:\n" + "\n".join(sources)
|
| 385 |
+
|
| 386 |
+
return result_text
|
| 387 |
+
|
| 388 |
+
except Exception as e:
|
| 389 |
+
return "Limit reached try after 5 minutes please contact admin"
|
| 390 |
+
|
| 391 |
+
|
| 392 |
+
#====================================================
|
| 393 |
+
# STEP 4: COMBINE RESULTS
|
| 394 |
+
#====================================================
|
| 395 |
+
def combine_results(search_result: str, maps_result: str) -> str:
|
| 396 |
+
"""
|
| 397 |
+
Combine the results from Google Search AI and Google Maps AI into a single text block.
|
| 398 |
+
"""
|
| 399 |
+
combined = f"""
|
| 400 |
+
================================================================================
|
| 401 |
+
GOOGLE SEARCH AI RESULTS
|
| 402 |
+
================================================================================
|
| 403 |
+
{search_result}
|
| 404 |
+
|
| 405 |
+
================================================================================
|
| 406 |
+
GOOGLE MAPS AI RESULTS
|
| 407 |
+
================================================================================
|
| 408 |
+
{maps_result}
|
| 409 |
+
"""
|
| 410 |
+
return combined.strip()
|
| 411 |
+
|
| 412 |
+
|
| 413 |
+
#====================================================
|
| 414 |
+
# STEP 5: CHECKER AI
|
| 415 |
+
#====================================================
|
| 416 |
+
def run_checker_ai(combined_results: str, user_query: str) -> str:
|
| 417 |
+
"""
|
| 418 |
+
Validate, cross-reference, and deduplicate the combined results.
|
| 419 |
+
"""
|
| 420 |
+
checker_prompt = f"""User Query: {user_query}
|
| 421 |
+
|
| 422 |
+
Verify, cross-reference, and clean the results below. For EVERY company apply:
|
| 423 |
+
A) Location label: [LOCATION VERIFIED ✓], [LOCATION MISMATCH ✗], or [LOCATION UNVERIFIED]
|
| 424 |
+
B) Legitimacy label: [LEGITIMATE ✓], [LEGITIMACY QUESTIONABLE ✗] (with reasons), or [LEGITIMACY UNVERIFIED]
|
| 425 |
+
|
| 426 |
+
--- COMBINED RESULTS ---
|
| 427 |
+
{combined_results}
|
| 428 |
+
--- END ---
|
| 429 |
+
|
| 430 |
+
Output the cleaned results. Every entry must have both labels."""
|
| 431 |
+
|
| 432 |
+
try:
|
| 433 |
+
def request_checker(api_key: str):
|
| 434 |
+
client = genai.Client(api_key=api_key)
|
| 435 |
+
return client.models.generate_content(
|
| 436 |
+
model="gemini-2.5-flash",
|
| 437 |
+
contents=checker_prompt,
|
| 438 |
+
config=types.GenerateContentConfig(
|
| 439 |
+
system_instruction=CHECKER_SYSTEM_PROMPT,
|
| 440 |
+
tools=[types.Tool(google_search=types.GoogleSearch())],
|
| 441 |
+
service_tier=types.ServiceTier.PRIORITY,
|
| 442 |
+
),
|
| 443 |
+
)
|
| 444 |
+
|
| 445 |
+
response = with_gemini_key_fallback((), request_checker)
|
| 446 |
+
return response.text or "[Checker AI returned no output]"
|
| 447 |
+
|
| 448 |
+
except Exception as gemini_err:
|
| 449 |
+
# --- Claude Web Search Fallback ---
|
| 450 |
+
import os
|
| 451 |
+
import anthropic
|
| 452 |
+
claude_key = os.environ.get("ANTHROPIC_API_KEY", "").strip()
|
| 453 |
+
if not claude_key:
|
| 454 |
+
return f"[Checker AI Error] {gemini_err}"
|
| 455 |
+
|
| 456 |
+
client = anthropic.Anthropic(api_key=claude_key)
|
| 457 |
+
|
| 458 |
+
messages = [{"role": "user", "content": checker_prompt}]
|
| 459 |
+
all_sources = []
|
| 460 |
+
max_continuations = 5
|
| 461 |
+
continuation_count = 0
|
| 462 |
+
full_response = ""
|
| 463 |
+
|
| 464 |
+
TOOLS = [{
|
| 465 |
+
"type": "web_search_20250305",
|
| 466 |
+
"name": "web_search",
|
| 467 |
+
"max_uses": 5
|
| 468 |
+
}]
|
| 469 |
+
|
| 470 |
+
while True:
|
| 471 |
+
try:
|
| 472 |
+
message = client.messages.create(
|
| 473 |
+
model="claude-haiku-4-5",
|
| 474 |
+
max_tokens=4096,
|
| 475 |
+
system=CHECKER_SYSTEM_PROMPT,
|
| 476 |
+
messages=messages,
|
| 477 |
+
tools=TOOLS,
|
| 478 |
+
)
|
| 479 |
+
except Exception as claude_err:
|
| 480 |
+
return f"[Claude Checker AI Error] {claude_err} (Gemini error: {gemini_err})"
|
| 481 |
+
|
| 482 |
+
for block in message.content:
|
| 483 |
+
if block.type == "text":
|
| 484 |
+
full_response += block.text
|
| 485 |
+
if hasattr(block, "citations") and block.citations:
|
| 486 |
+
for citation in block.citations:
|
| 487 |
+
if getattr(citation, "type", "") == "web_search_result_location":
|
| 488 |
+
source = f"- [{getattr(citation, 'title', 'source')}]({getattr(citation, 'url', '')})"
|
| 489 |
+
if source not in all_sources:
|
| 490 |
+
all_sources.append(source)
|
| 491 |
+
|
| 492 |
+
stop_reason = message.stop_reason
|
| 493 |
+
|
| 494 |
+
if stop_reason == "end_turn":
|
| 495 |
+
break
|
| 496 |
+
elif stop_reason == "max_tokens":
|
| 497 |
+
continuation_count += 1
|
| 498 |
+
if continuation_count >= max_continuations:
|
| 499 |
+
break
|
| 500 |
+
messages.append({"role": "assistant", "content": message.content})
|
| 501 |
+
messages.append({
|
| 502 |
+
"role": "user",
|
| 503 |
+
"content": "Your previous response was cut off. Please continue exactly from where you left off.",
|
| 504 |
+
})
|
| 505 |
+
elif stop_reason == "pause_turn":
|
| 506 |
+
continuation_count += 1
|
| 507 |
+
if continuation_count >= max_continuations:
|
| 508 |
+
break
|
| 509 |
+
messages.append({"role": "assistant", "content": message.content})
|
| 510 |
+
elif stop_reason == "refusal":
|
| 511 |
+
full_response += "\n\n[Refusal: Claude declined to respond]"
|
| 512 |
+
break
|
| 513 |
+
else:
|
| 514 |
+
break
|
| 515 |
+
|
| 516 |
+
if all_sources:
|
| 517 |
+
full_response += "\n\n### Claude Checker Sources:\n" + "\n".join(all_sources)
|
| 518 |
+
|
| 519 |
+
return full_response
|
| 520 |
+
|
| 521 |
+
|
| 522 |
+
#====================================================
|
| 523 |
+
# STEP 5.5: URL VERIFICATION (Hard Check)
|
| 524 |
+
#====================================================
|
| 525 |
+
def url_exists(url: str) -> bool:
|
| 526 |
+
"""
|
| 527 |
+
Check if a URL is live by sending a HEAD request.
|
| 528 |
+
Returns True if status < 400, False otherwise.
|
| 529 |
+
"""
|
| 530 |
+
try:
|
| 531 |
+
response = requests.head(url, allow_redirects=True, timeout=5)
|
| 532 |
+
return response.status_code < 400
|
| 533 |
+
except requests.RequestException:
|
| 534 |
+
return False
|
| 535 |
+
|
| 536 |
+
|
| 537 |
+
def verify_urls_in_text(text: str) -> str:
|
| 538 |
+
"""
|
| 539 |
+
Find all URLs in the text using regex, verify each one with url_exists(),
|
| 540 |
+
and replace dead URLs with a removal notice.
|
| 541 |
+
Returns the cleaned text.
|
| 542 |
+
"""
|
| 543 |
+
# Match http/https URLs (exclude trailing backticks, markdown chars, punctuation)
|
| 544 |
+
url_pattern = r'https?://[^\s\)\]\"\'\>\`\,\;]+'
|
| 545 |
+
urls_found = re.findall(url_pattern, text)
|
| 546 |
+
|
| 547 |
+
# Deduplicate while preserving order
|
| 548 |
+
unique_urls = list(dict.fromkeys(urls_found))
|
| 549 |
+
|
| 550 |
+
if not unique_urls:
|
| 551 |
+
return text
|
| 552 |
+
|
| 553 |
+
print(f"\n[URL Verifier] Checking {len(unique_urls)} unique URLs...")
|
| 554 |
+
|
| 555 |
+
# Check URLs in parallel for speed
|
| 556 |
+
dead_urls = []
|
| 557 |
+
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
|
| 558 |
+
future_to_url = {executor.submit(url_exists, url): url for url in unique_urls}
|
| 559 |
+
for future in concurrent.futures.as_completed(future_to_url):
|
| 560 |
+
url = future_to_url[future]
|
| 561 |
+
is_live = future.result()
|
| 562 |
+
status = "✓ Live" if is_live else "✗ Dead"
|
| 563 |
+
print(f" {status}: {url}")
|
| 564 |
+
if not is_live:
|
| 565 |
+
dead_urls.append(url)
|
| 566 |
+
|
| 567 |
+
# Replace dead URLs in the text
|
| 568 |
+
cleaned_text = text
|
| 569 |
+
for dead_url in dead_urls:
|
| 570 |
+
cleaned_text = cleaned_text.replace(dead_url, "[URL removed — unresolvable/dead link]")
|
| 571 |
+
|
| 572 |
+
if dead_urls:
|
| 573 |
+
print(f"[URL Verifier] Removed {len(dead_urls)} dead URL(s).")
|
| 574 |
+
else:
|
| 575 |
+
print(f"[URL Verifier] All URLs are live. ✓")
|
| 576 |
+
|
| 577 |
+
return cleaned_text
|
| 578 |
+
|
| 579 |
+
|
| 580 |
+
#====================================================
|
| 581 |
+
# STEP 6: JUDGE / RANKING AI
|
| 582 |
+
#====================================================
|
| 583 |
+
def run_judge_ai(checked_results: str, user_query: str) -> str:
|
| 584 |
+
"""
|
| 585 |
+
Rank the verified companies based on relevance to the user's query.
|
| 586 |
+
"""
|
| 587 |
+
judge_prompt = f"""User Query: {user_query}
|
| 588 |
+
|
| 589 |
+
Rank the verified companies below from most to least relevant.
|
| 590 |
+
|
| 591 |
+
--- VERIFIED RESULTS ---
|
| 592 |
+
{checked_results}
|
| 593 |
+
--- END ---"""
|
| 594 |
+
|
| 595 |
+
try:
|
| 596 |
+
def request_judge(api_key: str):
|
| 597 |
+
client = genai.Client(api_key=api_key)
|
| 598 |
+
return client.models.generate_content(
|
| 599 |
+
model="gemini-2.5-flash-lite",
|
| 600 |
+
contents=judge_prompt,
|
| 601 |
+
config=types.GenerateContentConfig(
|
| 602 |
+
system_instruction=JUDGE_SYSTEM_PROMPT,
|
| 603 |
+
service_tier=types.ServiceTier.PRIORITY,
|
| 604 |
+
),
|
| 605 |
+
)
|
| 606 |
+
|
| 607 |
+
response = with_gemini_key_fallback((), request_judge)
|
| 608 |
+
return response.text or "[Judge AI returned no output]"
|
| 609 |
+
|
| 610 |
+
except Exception as gemini_err:
|
| 611 |
+
import os
|
| 612 |
+
import anthropic
|
| 613 |
+
claude_key = os.environ.get("ANTHROPIC_API_KEY", "").strip()
|
| 614 |
+
if not claude_key:
|
| 615 |
+
return f"[Judge AI Error] {gemini_err}"
|
| 616 |
+
|
| 617 |
+
try:
|
| 618 |
+
client = anthropic.Anthropic(api_key=claude_key)
|
| 619 |
+
|
| 620 |
+
full_response = ""
|
| 621 |
+
current_messages = [
|
| 622 |
+
{"role": "user", "content": judge_prompt}
|
| 623 |
+
]
|
| 624 |
+
max_continuations = 3
|
| 625 |
+
|
| 626 |
+
for _ in range(max_continuations + 1):
|
| 627 |
+
message = client.messages.create(
|
| 628 |
+
model="claude-haiku-4-5",
|
| 629 |
+
max_tokens=4096,
|
| 630 |
+
system=JUDGE_SYSTEM_PROMPT,
|
| 631 |
+
messages=current_messages,
|
| 632 |
+
)
|
| 633 |
+
|
| 634 |
+
for block in message.content:
|
| 635 |
+
if block.type == "text":
|
| 636 |
+
full_response += block.text
|
| 637 |
+
|
| 638 |
+
if message.stop_reason != "max_tokens":
|
| 639 |
+
break
|
| 640 |
+
|
| 641 |
+
current_messages.append({"role": "assistant", "content": message.content})
|
| 642 |
+
current_messages.append({
|
| 643 |
+
"role": "user",
|
| 644 |
+
"content": "Your response was cut off. Continue exactly from where you stopped."
|
| 645 |
+
})
|
| 646 |
+
|
| 647 |
+
return full_response or "[Judge AI returned no output]"
|
| 648 |
+
except Exception as claude_err:
|
| 649 |
+
return f"[Claude Judge AI Error] {claude_err} (Gemini error: {gemini_err})"
|
| 650 |
+
|
| 651 |
+
|
| 652 |
+
#====================================================
|
| 653 |
+
# MAIN PIPELINE
|
| 654 |
+
#====================================================
|
| 655 |
+
def run_pipeline(user_input: str) -> dict:
|
| 656 |
+
"""
|
| 657 |
+
Execute the full multi-AI company prospecting pipeline.
|
| 658 |
+
Returns a dict with 'result' (final ranking text) and 'maps_sources' (list of dicts).
|
| 659 |
+
"""
|
| 660 |
+
# === Step 1: Detect input type ===
|
| 661 |
+
parsed = detect_input_type(user_input)
|
| 662 |
+
|
| 663 |
+
lat = parsed['latitude']
|
| 664 |
+
lon = parsed['longitude']
|
| 665 |
+
location_context = ""
|
| 666 |
+
|
| 667 |
+
# === Step 2: Geocode if coordinates ===
|
| 668 |
+
if parsed['type'] == 'coordinates':
|
| 669 |
+
geo_data = nominatim_reverse_geocode(lat, lon)
|
| 670 |
+
location_context = geo_data['display_name']
|
| 671 |
+
query = parsed['query_text'] if parsed['query_text'] else f"companies near {location_context}"
|
| 672 |
+
else:
|
| 673 |
+
query = parsed['query_text']
|
| 674 |
+
location_context = query
|
| 675 |
+
lat = 0.0
|
| 676 |
+
lon = 0.0
|
| 677 |
+
|
| 678 |
+
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
|
| 679 |
+
search_future = executor.submit(run_google_search_ai, query, location_context)
|
| 680 |
+
maps_future = executor.submit(run_google_maps_ai, query, lat, lon, location_context)
|
| 681 |
+
search_result = search_future.result()
|
| 682 |
+
maps_result = maps_future.result()
|
| 683 |
+
|
| 684 |
+
if maps_result == "Limit reached try after 5 minutes please contact admin":
|
| 685 |
+
return {
|
| 686 |
+
'result': maps_result,
|
| 687 |
+
'maps_sources': []
|
| 688 |
+
}
|
| 689 |
+
|
| 690 |
+
# === Step 4: Combine results ===
|
| 691 |
+
combined = combine_results(search_result, maps_result)
|
| 692 |
+
|
| 693 |
+
# === Step 5: Checker AI ===
|
| 694 |
+
checked = run_checker_ai(combined, user_input)
|
| 695 |
+
|
| 696 |
+
# === Step 5.5: URL Verification ===
|
| 697 |
+
checked = verify_urls_in_text(checked)
|
| 698 |
+
|
| 699 |
+
# === Step 6: Judge / Ranking AI ===
|
| 700 |
+
final_ranking = run_judge_ai(checked, user_input)
|
| 701 |
+
|
| 702 |
+
# === Collect Maps sources from the maps_result section ===
|
| 703 |
+
maps_sources = []
|
| 704 |
+
for line in maps_result.splitlines():
|
| 705 |
+
line = line.strip()
|
| 706 |
+
# Match markdown links like: - [Title](uri)
|
| 707 |
+
link_match = re.match(r'^-\s*\[(.+?)\]\((.+?)\)', line)
|
| 708 |
+
if link_match:
|
| 709 |
+
maps_sources.append({
|
| 710 |
+
'title': link_match.group(1),
|
| 711 |
+
'uri': link_match.group(2)
|
| 712 |
+
})
|
| 713 |
+
|
| 714 |
+
return {
|
| 715 |
+
'result': final_ranking,
|
| 716 |
+
'maps_sources': maps_sources
|
| 717 |
+
}
|
| 718 |
+
|
| 719 |
+
|
| 720 |
+
#====================================================
|
| 721 |
+
# STREAMING PIPELINE (yields intermediate results)
|
| 722 |
+
#====================================================
|
| 723 |
+
def run_pipeline_streaming(user_input: str):
|
| 724 |
+
"""
|
| 725 |
+
Generator version of run_pipeline().
|
| 726 |
+
Yields JSON-serializable dicts at each pipeline stage so the caller
|
| 727 |
+
can stream intermediate results to the frontend via SSE.
|
| 728 |
+
"""
|
| 729 |
+
# === Step 1: Detect input type ===
|
| 730 |
+
parsed = detect_input_type(user_input)
|
| 731 |
+
lat = parsed['latitude']
|
| 732 |
+
lon = parsed['longitude']
|
| 733 |
+
location_context = ""
|
| 734 |
+
|
| 735 |
+
if parsed['type'] == 'coordinates':
|
| 736 |
+
geo_data = nominatim_reverse_geocode(lat, lon)
|
| 737 |
+
location_context = geo_data['display_name']
|
| 738 |
+
query = parsed['query_text'] if parsed['query_text'] else f"companies near {location_context}"
|
| 739 |
+
else:
|
| 740 |
+
query = parsed['query_text']
|
| 741 |
+
location_context = query
|
| 742 |
+
lat = 0.0
|
| 743 |
+
lon = 0.0
|
| 744 |
+
|
| 745 |
+
yield {"step": "input_parsed", "data": {"input_type": parsed['type'], "query": query, "location_context": location_context}}
|
| 746 |
+
|
| 747 |
+
# === Step 2 & 3: Parallel Search + Maps (yield as each completes) ===
|
| 748 |
+
search_result = ""
|
| 749 |
+
maps_result = ""
|
| 750 |
+
with concurrent.futures.ThreadPoolExecutor(max_workers=2) as executor:
|
| 751 |
+
future_to_name = {
|
| 752 |
+
executor.submit(run_google_search_ai, query, location_context): "search",
|
| 753 |
+
executor.submit(run_google_maps_ai, query, lat, lon, location_context): "maps",
|
| 754 |
+
}
|
| 755 |
+
for future in concurrent.futures.as_completed(future_to_name):
|
| 756 |
+
name = future_to_name[future]
|
| 757 |
+
result = future.result()
|
| 758 |
+
if name == "search":
|
| 759 |
+
search_result = result
|
| 760 |
+
yield {"step": "search_done", "data": search_result}
|
| 761 |
+
else:
|
| 762 |
+
maps_result = result
|
| 763 |
+
if maps_result == "Limit reached try after 5 minutes please contact admin":
|
| 764 |
+
yield {"step": "maps_done", "data": maps_result, "maps_sources": []}
|
| 765 |
+
yield {"step": "judge_done", "data": maps_result}
|
| 766 |
+
return
|
| 767 |
+
# Extract maps sources from markdown links
|
| 768 |
+
maps_sources = []
|
| 769 |
+
for line in maps_result.splitlines():
|
| 770 |
+
line_s = line.strip()
|
| 771 |
+
link_match = re.match(r'^-\s*\[(.+?)\]\((.+?)\)', line_s)
|
| 772 |
+
if link_match:
|
| 773 |
+
maps_sources.append({'title': link_match.group(1), 'uri': link_match.group(2)})
|
| 774 |
+
yield {"step": "maps_done", "data": maps_result, "maps_sources": maps_sources}
|
| 775 |
+
|
| 776 |
+
# === Step 4: Combine + Checker AI ===
|
| 777 |
+
combined = combine_results(search_result, maps_result)
|
| 778 |
+
checked = run_checker_ai(combined, user_input)
|
| 779 |
+
yield {"step": "checker_done", "data": checked}
|
| 780 |
+
|
| 781 |
+
# === Step 4.5: URL Verification ===
|
| 782 |
+
checked = verify_urls_in_text(checked)
|
| 783 |
+
yield {"step": "url_verify_done", "data": checked}
|
| 784 |
+
|
| 785 |
+
# === Step 5: Judge / Ranking AI ===
|
| 786 |
+
final_ranking = run_judge_ai(checked, user_input)
|
| 787 |
+
yield {"step": "judge_done", "data": final_ranking}
|
| 788 |
+
|
| 789 |
+
|
| 790 |
+
#====================================================
|
| 791 |
+
# ENTRY POINT
|
| 792 |
+
#====================================================
|
| 793 |
+
if __name__ == "__main__":
|
| 794 |
+
user_input = input("Your query: ").strip()
|
| 795 |
+
|
| 796 |
+
if not user_input:
|
| 797 |
+
print("No input provided. Exiting.")
|
| 798 |
+
else:
|
| 799 |
+
output = run_pipeline(user_input)
|
| 800 |
+
|
| 801 |
+
print(output['result'])
|
| 802 |
+
|
| 803 |
+
if output['maps_sources']:
|
| 804 |
+
print("\n" + "-" * 40)
|
| 805 |
+
print("Sources (Google Maps):")
|
| 806 |
+
for src in output['maps_sources']:
|
| 807 |
+
print(f" - [{src['title']}]({src['uri']})")
|
| 808 |
+
|
| 809 |
+
|
| 810 |
+
|
| 811 |
+
|
services/ocr.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import os
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
from google import genai
|
| 6 |
+
from google.genai import types
|
| 7 |
+
|
| 8 |
+
from services.ai_service import gemini_keys, with_gemini_key_fallback
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
OCR_MODEL = "gemini-2.5-flash"
|
| 12 |
+
DEFAULT_OCR_API_KEY = ""
|
| 13 |
+
|
| 14 |
+
SYSTEM_PROMPT = """
|
| 15 |
+
You are a precise business card data extraction assistant.
|
| 16 |
+
|
| 17 |
+
Extract all available information from the business card image and return ONLY a valid JSON object.
|
| 18 |
+
|
| 19 |
+
Use the following schema exactly:
|
| 20 |
+
|
| 21 |
+
{
|
| 22 |
+
"person": {
|
| 23 |
+
"first_name": "",
|
| 24 |
+
"last_name": "",
|
| 25 |
+
"job_title": "",
|
| 26 |
+
"email": "",
|
| 27 |
+
"phone": {
|
| 28 |
+
"mobile": "",
|
| 29 |
+
"office": "",
|
| 30 |
+
"extension": ""
|
| 31 |
+
},
|
| 32 |
+
"social": {
|
| 33 |
+
"linkedin": ""
|
| 34 |
+
}
|
| 35 |
+
},
|
| 36 |
+
"company": {
|
| 37 |
+
"company_name": "",
|
| 38 |
+
"website": "",
|
| 39 |
+
"address": {
|
| 40 |
+
"full_address": "",
|
| 41 |
+
"street": "",
|
| 42 |
+
"city": "",
|
| 43 |
+
"state_province": "",
|
| 44 |
+
"postal_code": "",
|
| 45 |
+
"country": ""
|
| 46 |
+
}
|
| 47 |
+
},
|
| 48 |
+
"metadata": {
|
| 49 |
+
"confidence_score": 0.0,
|
| 50 |
+
"unparsed_text": "",
|
| 51 |
+
"card_language": ""
|
| 52 |
+
}
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
Rules:
|
| 56 |
+
1. Return ONLY valid JSON.
|
| 57 |
+
2. Do NOT wrap the response in markdown or code fences.
|
| 58 |
+
3. Do NOT include explanations or commentary.
|
| 59 |
+
4. If a field is missing, use an empty string "".
|
| 60 |
+
5. confidence_score must be between 0 and 1.
|
| 61 |
+
6. Put any text that cannot be mapped to a field into metadata.unparsed_text.
|
| 62 |
+
7. Infer card_language using ISO language codes such as "en", "fr", "de", "es", etc.
|
| 63 |
+
8. Extract LinkedIn URLs if present.
|
| 64 |
+
9. Separate mobile, office, and extension numbers when possible.
|
| 65 |
+
10. Preserve all extracted values exactly as written on the card.
|
| 66 |
+
"""
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _get_api_key() -> str:
|
| 70 |
+
keys = gemini_keys()
|
| 71 |
+
return keys[0] if keys else DEFAULT_OCR_API_KEY
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _parse_json_response(text: str) -> dict[str, Any]:
|
| 75 |
+
cleaned = text.strip()
|
| 76 |
+
if cleaned.startswith("```"):
|
| 77 |
+
cleaned = cleaned.strip("`")
|
| 78 |
+
if cleaned.lower().startswith("json"):
|
| 79 |
+
cleaned = cleaned[4:].strip()
|
| 80 |
+
return json.loads(cleaned)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def extract_business_card(image_bytes: bytes, mime_type: str) -> dict[str, Any]:
|
| 84 |
+
"""
|
| 85 |
+
Extract structured business-card data from uploaded image bytes.
|
| 86 |
+
"""
|
| 87 |
+
if not image_bytes:
|
| 88 |
+
raise ValueError("No image bytes provided.")
|
| 89 |
+
|
| 90 |
+
api_key = _get_api_key()
|
| 91 |
+
if not api_key:
|
| 92 |
+
raise RuntimeError("Set GEMINI_API_KEY_prime_1, GEMINI_API_KEY_prime_2, or GEMINI_API_KEY_prime_3 before using OCR.")
|
| 93 |
+
|
| 94 |
+
def call_ocr(key: str):
|
| 95 |
+
client = genai.Client(api_key=key)
|
| 96 |
+
return client.models.generate_content(
|
| 97 |
+
model=OCR_MODEL,
|
| 98 |
+
contents=[
|
| 99 |
+
types.Part.from_bytes(data=image_bytes, mime_type=mime_type),
|
| 100 |
+
SYSTEM_PROMPT,
|
| 101 |
+
],
|
| 102 |
+
config=types.GenerateContentConfig(service_tier=types.ServiceTier.STANDARD),
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
response_text = ""
|
| 106 |
+
try:
|
| 107 |
+
response = with_gemini_key_fallback((), call_ocr)
|
| 108 |
+
response_text = response.text or ""
|
| 109 |
+
except Exception as gemini_err:
|
| 110 |
+
import os
|
| 111 |
+
import base64
|
| 112 |
+
import anthropic
|
| 113 |
+
|
| 114 |
+
claude_key = os.environ.get("ANTHROPIC_API_KEY", "").strip()
|
| 115 |
+
if not claude_key:
|
| 116 |
+
raise RuntimeError(f"Gemini OCR failed and ANTHROPIC_API_KEY is not configured. Gemini Error: {gemini_err}")
|
| 117 |
+
|
| 118 |
+
client = anthropic.Anthropic(api_key=claude_key)
|
| 119 |
+
|
| 120 |
+
image_data = base64.standard_b64encode(image_bytes).decode("utf-8")
|
| 121 |
+
image_block = {
|
| 122 |
+
"type": "image",
|
| 123 |
+
"source": {
|
| 124 |
+
"type": "base64",
|
| 125 |
+
"media_type": mime_type,
|
| 126 |
+
"data": image_data,
|
| 127 |
+
},
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
messages = [
|
| 131 |
+
{
|
| 132 |
+
"role": "user",
|
| 133 |
+
"content": [
|
| 134 |
+
image_block,
|
| 135 |
+
{"type": "text", "text": "Extract all available information from the business card image and return ONLY a valid JSON object."},
|
| 136 |
+
],
|
| 137 |
+
}
|
| 138 |
+
]
|
| 139 |
+
|
| 140 |
+
full_response = ""
|
| 141 |
+
max_continuations = 5
|
| 142 |
+
continuation_count = 0
|
| 143 |
+
|
| 144 |
+
while True:
|
| 145 |
+
claude_response = client.messages.create(
|
| 146 |
+
model="claude-haiku-4-5",
|
| 147 |
+
max_tokens=4096,
|
| 148 |
+
system=SYSTEM_PROMPT,
|
| 149 |
+
messages=messages,
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
for block in claude_response.content:
|
| 153 |
+
if block.type == "text":
|
| 154 |
+
full_response += block.text
|
| 155 |
+
|
| 156 |
+
stop_reason = claude_response.stop_reason
|
| 157 |
+
|
| 158 |
+
if stop_reason == "end_turn":
|
| 159 |
+
break
|
| 160 |
+
elif stop_reason == "max_tokens":
|
| 161 |
+
continuation_count += 1
|
| 162 |
+
if continuation_count >= max_continuations:
|
| 163 |
+
break
|
| 164 |
+
messages.append({"role": "assistant", "content": claude_response.content})
|
| 165 |
+
messages.append({
|
| 166 |
+
"role": "user",
|
| 167 |
+
"content": "Your previous response was cut off. Please continue exactly from where you left off. Do not include markdown or explanations, just the JSON.",
|
| 168 |
+
})
|
| 169 |
+
elif stop_reason == "refusal":
|
| 170 |
+
raise ValueError("Claude refused to analyze this image due to content policy.")
|
| 171 |
+
else:
|
| 172 |
+
break
|
| 173 |
+
|
| 174 |
+
response_text = full_response
|
| 175 |
+
|
| 176 |
+
try:
|
| 177 |
+
return _parse_json_response(response_text)
|
| 178 |
+
except json.JSONDecodeError as exc:
|
| 179 |
+
raise ValueError(f"OCR returned invalid JSON: {response_text}") from exc
|
| 180 |
+
|
services/rag_service.py
ADDED
|
@@ -0,0 +1,975 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
RAG Service for the Learning Section.
|
| 3 |
+
|
| 4 |
+
Provides document upload, indexing (ChromaDB + Gemini embeddings),
|
| 5 |
+
and retrieval-augmented generation via Gemini 2.5 Flash.
|
| 6 |
+
Each collection is isolated by UUID; each document is tracked by UUID
|
| 7 |
+
so uploads/deletes don't corrupt the vector store.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import json
|
| 13 |
+
import os
|
| 14 |
+
import shutil
|
| 15 |
+
import subprocess
|
| 16 |
+
import uuid
|
| 17 |
+
from datetime import datetime
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
from typing import Any
|
| 20 |
+
|
| 21 |
+
# ---------------------------------------------------------------------------
|
| 22 |
+
# Paths
|
| 23 |
+
# ---------------------------------------------------------------------------
|
| 24 |
+
BASE_DIR = Path(__file__).resolve().parent.parent
|
| 25 |
+
LEARNING_DATA_DIR = BASE_DIR / "learning_data"
|
| 26 |
+
UPLOADS_DIR = LEARNING_DATA_DIR / "uploads"
|
| 27 |
+
CONVERTED_DIR = LEARNING_DATA_DIR / "converted"
|
| 28 |
+
METADATA_FILE = LEARNING_DATA_DIR / "collections_meta.json"
|
| 29 |
+
|
| 30 |
+
for _d in (LEARNING_DATA_DIR, UPLOADS_DIR, CONVERTED_DIR):
|
| 31 |
+
_d.mkdir(parents=True, exist_ok=True)
|
| 32 |
+
|
| 33 |
+
ALLOWED_EXTENSIONS = {".pdf", ".docx", ".pptx", ".ppt", ".doc", ".xlsx", ".xls"}
|
| 34 |
+
|
| 35 |
+
def is_supabase_active() -> bool:
|
| 36 |
+
from db import pg_pool
|
| 37 |
+
return pg_pool is not None or (os.environ.get("SUPABASE_DB_HOST") is not None and os.environ.get("SUPABASE_DB_PASSWORD") is not None)
|
| 38 |
+
|
| 39 |
+
def get_supabase_client():
|
| 40 |
+
url = os.environ.get("SUPABASE_URL")
|
| 41 |
+
key = os.environ.get("SUPABASE_KEY")
|
| 42 |
+
if url and key:
|
| 43 |
+
try:
|
| 44 |
+
from supabase import create_client
|
| 45 |
+
return create_client(url, key)
|
| 46 |
+
except ImportError:
|
| 47 |
+
print("Warning: supabase python package is not installed. Cloud storage sync disabled.")
|
| 48 |
+
return None
|
| 49 |
+
except Exception as e:
|
| 50 |
+
print(f"Error creating Supabase client: {e}")
|
| 51 |
+
return None
|
| 52 |
+
|
| 53 |
+
# ---------------------------------------------------------------------------
|
| 54 |
+
# API key helpers
|
| 55 |
+
# ---------------------------------------------------------------------------
|
| 56 |
+
def get_all_gemini_api_keys() -> list[str]:
|
| 57 |
+
keys = []
|
| 58 |
+
for name in ("GEMINI_API_KEY_prime_1", "GEMINI_API_KEY_prime_2", "GEMINI_API_KEY_prime_3"):
|
| 59 |
+
key = os.environ.get(name, "").strip()
|
| 60 |
+
if key:
|
| 61 |
+
keys.append(key)
|
| 62 |
+
return keys
|
| 63 |
+
|
| 64 |
+
def get_gemini_api_key() -> str:
|
| 65 |
+
keys = get_all_gemini_api_keys()
|
| 66 |
+
return keys[0] if keys else ""
|
| 67 |
+
|
| 68 |
+
def get_embedding_model(api_key: str = None):
|
| 69 |
+
from langchain_google_genai import GoogleGenerativeAIEmbeddings
|
| 70 |
+
if api_key is None:
|
| 71 |
+
api_key = get_gemini_api_key()
|
| 72 |
+
return GoogleGenerativeAIEmbeddings(
|
| 73 |
+
model="gemini-embedding-2-preview",
|
| 74 |
+
google_api_key=api_key,
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
def get_llm(api_key: str = None):
|
| 78 |
+
from langchain_google_genai import ChatGoogleGenerativeAI
|
| 79 |
+
if api_key is None:
|
| 80 |
+
api_key = get_gemini_api_key()
|
| 81 |
+
return ChatGoogleGenerativeAI(
|
| 82 |
+
model="gemini-2.5-flash",
|
| 83 |
+
temperature=0.7,
|
| 84 |
+
google_api_key=api_key,
|
| 85 |
+
max_retries=0,
|
| 86 |
+
model_kwargs={"service_tier": "standard"},
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
# ---------------------------------------------------------------------------
|
| 90 |
+
# Metadata (JSON file) management
|
| 91 |
+
# ---------------------------------------------------------------------------
|
| 92 |
+
def load_metadata() -> dict:
|
| 93 |
+
if METADATA_FILE.exists():
|
| 94 |
+
with open(METADATA_FILE, "r", encoding="utf-8") as f:
|
| 95 |
+
return json.load(f)
|
| 96 |
+
return {"collections": {}}
|
| 97 |
+
|
| 98 |
+
def save_metadata(meta: dict) -> None:
|
| 99 |
+
with open(METADATA_FILE, "w", encoding="utf-8") as f:
|
| 100 |
+
json.dump(meta, f, indent=2, default=str)
|
| 101 |
+
|
| 102 |
+
# ---------------------------------------------------------------------------
|
| 103 |
+
# Collection CRUD
|
| 104 |
+
# ---------------------------------------------------------------------------
|
| 105 |
+
def create_collection(name: str, user_id: str = '', visibility: str = 'private') -> dict:
|
| 106 |
+
collection_id = str(uuid.uuid4())
|
| 107 |
+
if is_supabase_active():
|
| 108 |
+
from db import get_db_connection
|
| 109 |
+
created_at = datetime.now().isoformat()
|
| 110 |
+
with get_db_connection() as conn:
|
| 111 |
+
with conn.cursor() as cursor:
|
| 112 |
+
cursor.execute(
|
| 113 |
+
"INSERT INTO rag_collections (id, name, user_id, visibility, created_at) VALUES (?, ?, ?, ?, ?)",
|
| 114 |
+
(collection_id, name, user_id, visibility, created_at)
|
| 115 |
+
)
|
| 116 |
+
(UPLOADS_DIR / collection_id).mkdir(parents=True, exist_ok=True)
|
| 117 |
+
return {
|
| 118 |
+
"id": collection_id,
|
| 119 |
+
"name": name,
|
| 120 |
+
"user_id": user_id,
|
| 121 |
+
"visibility": visibility,
|
| 122 |
+
"created_at": created_at,
|
| 123 |
+
}
|
| 124 |
+
else:
|
| 125 |
+
meta = load_metadata()
|
| 126 |
+
meta["collections"][collection_id] = {
|
| 127 |
+
"name": name,
|
| 128 |
+
"user_id": user_id,
|
| 129 |
+
"visibility": visibility,
|
| 130 |
+
"created_at": datetime.now().isoformat(),
|
| 131 |
+
"documents": {},
|
| 132 |
+
}
|
| 133 |
+
save_metadata(meta)
|
| 134 |
+
(UPLOADS_DIR / collection_id).mkdir(parents=True, exist_ok=True)
|
| 135 |
+
return {
|
| 136 |
+
"id": collection_id,
|
| 137 |
+
"name": name,
|
| 138 |
+
"user_id": user_id,
|
| 139 |
+
"visibility": visibility,
|
| 140 |
+
"created_at": meta["collections"][collection_id]["created_at"],
|
| 141 |
+
}
|
| 142 |
+
|
| 143 |
+
def rename_collection(collection_id: str, new_name: str) -> dict:
|
| 144 |
+
if is_supabase_active():
|
| 145 |
+
from db import get_db_connection
|
| 146 |
+
with get_db_connection() as conn:
|
| 147 |
+
with conn.cursor() as cursor:
|
| 148 |
+
cursor.execute("UPDATE rag_collections SET name = ? WHERE id = ?", (new_name, collection_id))
|
| 149 |
+
return {"id": collection_id, "name": new_name}
|
| 150 |
+
else:
|
| 151 |
+
meta = load_metadata()
|
| 152 |
+
if collection_id not in meta["collections"]:
|
| 153 |
+
raise ValueError("Collection not found")
|
| 154 |
+
meta["collections"][collection_id]["name"] = new_name
|
| 155 |
+
save_metadata(meta)
|
| 156 |
+
return {"id": collection_id, "name": new_name}
|
| 157 |
+
|
| 158 |
+
def list_collections(user_id: str = '', user_role: str = '') -> list[dict]:
|
| 159 |
+
if is_supabase_active():
|
| 160 |
+
from db import get_db_connection
|
| 161 |
+
with get_db_connection() as conn:
|
| 162 |
+
with conn.cursor() as cursor:
|
| 163 |
+
cursor.execute("""
|
| 164 |
+
SELECT rc.*, COUNT(rd.id) as doc_count
|
| 165 |
+
FROM rag_collections rc
|
| 166 |
+
LEFT JOIN rag_documents rd ON rc.id = rd.collection_id
|
| 167 |
+
GROUP BY rc.id, rc.created_at, rc.name, rc.user_id, rc.visibility
|
| 168 |
+
""")
|
| 169 |
+
rows = cursor.fetchall()
|
| 170 |
+
result = []
|
| 171 |
+
for r in rows:
|
| 172 |
+
entry = {
|
| 173 |
+
"id": r["id"],
|
| 174 |
+
"name": r["name"],
|
| 175 |
+
"created_at": str(r["created_at"]),
|
| 176 |
+
"document_count": r["doc_count"],
|
| 177 |
+
"user_id": r.get("user_id", ""),
|
| 178 |
+
"visibility": r.get("visibility", "private"),
|
| 179 |
+
}
|
| 180 |
+
if user_role in ('Admin', 'Super Admin'):
|
| 181 |
+
result.append(entry)
|
| 182 |
+
else:
|
| 183 |
+
owner = r.get("user_id", "")
|
| 184 |
+
vis = r.get("visibility", "private")
|
| 185 |
+
if not owner or owner == user_id or vis == 'public':
|
| 186 |
+
result.append(entry)
|
| 187 |
+
result.sort(key=lambda x: x["created_at"], reverse=True)
|
| 188 |
+
return result
|
| 189 |
+
else:
|
| 190 |
+
meta = load_metadata()
|
| 191 |
+
result = []
|
| 192 |
+
for cid, cdata in meta["collections"].items():
|
| 193 |
+
entry = {
|
| 194 |
+
"id": cid,
|
| 195 |
+
"name": cdata["name"],
|
| 196 |
+
"created_at": cdata["created_at"],
|
| 197 |
+
"document_count": len(cdata.get("documents", {})),
|
| 198 |
+
"user_id": cdata.get("user_id", ""),
|
| 199 |
+
"visibility": cdata.get("visibility", "private"),
|
| 200 |
+
}
|
| 201 |
+
if user_role in ('Admin', 'Super Admin'):
|
| 202 |
+
result.append(entry)
|
| 203 |
+
else:
|
| 204 |
+
owner = cdata.get("user_id", "")
|
| 205 |
+
vis = cdata.get("visibility", "private")
|
| 206 |
+
if not owner or owner == user_id or vis == 'public':
|
| 207 |
+
result.append(entry)
|
| 208 |
+
result.sort(key=lambda x: x["created_at"], reverse=True)
|
| 209 |
+
return result
|
| 210 |
+
|
| 211 |
+
def delete_collection(collection_id: str) -> None:
|
| 212 |
+
# Pinecone deletion
|
| 213 |
+
try:
|
| 214 |
+
from pinecone import Pinecone
|
| 215 |
+
pc = Pinecone(api_key=os.environ.get("PINECONE_API_KEY"))
|
| 216 |
+
index = pc.Index("maple-prospect")
|
| 217 |
+
index.delete(delete_all=True, namespace=f"col_{collection_id[:8]}")
|
| 218 |
+
except Exception as exc:
|
| 219 |
+
print(f"Warning: Pinecone cleanup for collection {collection_id}: {exc}")
|
| 220 |
+
|
| 221 |
+
if is_supabase_active():
|
| 222 |
+
from db import get_db_connection
|
| 223 |
+
supabase = get_supabase_client()
|
| 224 |
+
if supabase:
|
| 225 |
+
try:
|
| 226 |
+
# Retrieve files from DB to remove from storage
|
| 227 |
+
with get_db_connection() as conn:
|
| 228 |
+
with conn.cursor() as cursor:
|
| 229 |
+
cursor.execute("SELECT upload_path FROM rag_documents WHERE collection_id = ?", (collection_id,))
|
| 230 |
+
doc_rows = cursor.fetchall()
|
| 231 |
+
paths_to_delete = [d["upload_path"] for d in doc_rows if d.get("upload_path")]
|
| 232 |
+
if paths_to_delete:
|
| 233 |
+
supabase.storage.from_("learning-playbook").remove(paths_to_delete)
|
| 234 |
+
except Exception as exc:
|
| 235 |
+
print(f"Warning: Supabase Storage cleanup for collection {collection_id}: {exc}")
|
| 236 |
+
|
| 237 |
+
with get_db_connection() as conn:
|
| 238 |
+
with conn.cursor() as cursor:
|
| 239 |
+
cursor.execute("DELETE FROM rag_collections WHERE id = ?", (collection_id,))
|
| 240 |
+
else:
|
| 241 |
+
meta = load_metadata()
|
| 242 |
+
if collection_id not in meta["collections"]:
|
| 243 |
+
raise ValueError("Collection not found")
|
| 244 |
+
# Remove uploaded files
|
| 245 |
+
for d in (UPLOADS_DIR / collection_id, CONVERTED_DIR / collection_id):
|
| 246 |
+
if d.exists():
|
| 247 |
+
shutil.rmtree(d, ignore_errors=True)
|
| 248 |
+
del meta["collections"][collection_id]
|
| 249 |
+
save_metadata(meta)
|
| 250 |
+
|
| 251 |
+
def list_documents(collection_id: str) -> list[dict]:
|
| 252 |
+
if is_supabase_active():
|
| 253 |
+
from db import get_db_connection
|
| 254 |
+
with get_db_connection() as conn:
|
| 255 |
+
with conn.cursor() as cursor:
|
| 256 |
+
cursor.execute("SELECT * FROM rag_documents WHERE collection_id = ? ORDER BY uploaded_at DESC", (collection_id,))
|
| 257 |
+
rows = cursor.fetchall()
|
| 258 |
+
return [
|
| 259 |
+
{
|
| 260 |
+
"id": r["id"],
|
| 261 |
+
"filename": r["filename"],
|
| 262 |
+
"original_type": r["original_type"],
|
| 263 |
+
"pages": r.get("pages", 0),
|
| 264 |
+
"chunks": r.get("chunks", 0),
|
| 265 |
+
"uploaded_at": str(r["uploaded_at"]),
|
| 266 |
+
}
|
| 267 |
+
for r in rows
|
| 268 |
+
]
|
| 269 |
+
else:
|
| 270 |
+
meta = load_metadata()
|
| 271 |
+
if collection_id not in meta["collections"]:
|
| 272 |
+
raise ValueError("Collection not found")
|
| 273 |
+
docs = meta["collections"][collection_id].get("documents", {})
|
| 274 |
+
return [
|
| 275 |
+
{
|
| 276 |
+
"id": did,
|
| 277 |
+
"filename": dd["filename"],
|
| 278 |
+
"original_type": dd["original_type"],
|
| 279 |
+
"pages": dd.get("pages", 0),
|
| 280 |
+
"chunks": dd.get("chunks", 0),
|
| 281 |
+
"uploaded_at": dd["uploaded_at"],
|
| 282 |
+
}
|
| 283 |
+
for did, dd in docs.items()
|
| 284 |
+
]
|
| 285 |
+
|
| 286 |
+
# ---------------------------------------------------------------------------
|
| 287 |
+
# File-type detection & conversion helpers
|
| 288 |
+
# ---------------------------------------------------------------------------
|
| 289 |
+
def detect_file_type(filename: str) -> str | None:
|
| 290 |
+
ext = Path(filename).suffix.lower()
|
| 291 |
+
if ext == ".pdf":
|
| 292 |
+
return "pdf"
|
| 293 |
+
if ext in (".docx", ".doc"):
|
| 294 |
+
return "docx"
|
| 295 |
+
if ext in (".pptx", ".ppt"):
|
| 296 |
+
return "pptx"
|
| 297 |
+
if ext in (".xlsx", ".xls"):
|
| 298 |
+
return "xlsx"
|
| 299 |
+
return None
|
| 300 |
+
|
| 301 |
+
def find_soffice() -> str | None:
|
| 302 |
+
if shutil.which("soffice"):
|
| 303 |
+
return "soffice"
|
| 304 |
+
for p in (
|
| 305 |
+
r"C:\Program Files\LibreOffice\program\soffice.exe",
|
| 306 |
+
r"C:\Program Files (x86)\LibreOffice\program\soffice.exe",
|
| 307 |
+
"/usr/bin/soffice",
|
| 308 |
+
"/usr/local/bin/soffice",
|
| 309 |
+
):
|
| 310 |
+
if Path(p).exists():
|
| 311 |
+
return p
|
| 312 |
+
return None
|
| 313 |
+
|
| 314 |
+
def convert_to_pdf(input_path: Path, output_dir: Path) -> Path:
|
| 315 |
+
soffice = find_soffice()
|
| 316 |
+
if not soffice:
|
| 317 |
+
raise RuntimeError(
|
| 318 |
+
"LibreOffice is not installed. Install it to convert DOCX/PPT files. "
|
| 319 |
+
"Download: https://www.libreoffice.org/download/"
|
| 320 |
+
)
|
| 321 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 322 |
+
subprocess.run(
|
| 323 |
+
[soffice, "--headless", "--convert-to", "pdf", str(input_path), "--outdir", str(output_dir)],
|
| 324 |
+
check=True,
|
| 325 |
+
capture_output=True,
|
| 326 |
+
timeout=120,
|
| 327 |
+
)
|
| 328 |
+
pdf_path = output_dir / (input_path.stem + ".pdf")
|
| 329 |
+
if not pdf_path.exists():
|
| 330 |
+
raise RuntimeError(f"Conversion failed – PDF not found at {pdf_path}")
|
| 331 |
+
return pdf_path
|
| 332 |
+
|
| 333 |
+
# ---------------------------------------------------------------------------
|
| 334 |
+
# Document add / delete
|
| 335 |
+
# ---------------------------------------------------------------------------
|
| 336 |
+
def add_document(collection_id: str, file_storage) -> dict:
|
| 337 |
+
"""Save, optionally convert, chunk, embed and store a document."""
|
| 338 |
+
filename = file_storage.filename
|
| 339 |
+
file_type = detect_file_type(filename)
|
| 340 |
+
if not file_type:
|
| 341 |
+
raise ValueError("Unsupported file type. Allowed: PDF, DOCX, PPT/PPTX, XLSX/XLS")
|
| 342 |
+
|
| 343 |
+
doc_id = str(uuid.uuid4())
|
| 344 |
+
|
| 345 |
+
# Persist raw upload
|
| 346 |
+
upload_dir = UPLOADS_DIR / collection_id
|
| 347 |
+
upload_dir.mkdir(parents=True, exist_ok=True)
|
| 348 |
+
safe_name = f"{doc_id}_{filename}"
|
| 349 |
+
upload_path = upload_dir / safe_name
|
| 350 |
+
file_storage.save(str(upload_path))
|
| 351 |
+
|
| 352 |
+
if is_supabase_active():
|
| 353 |
+
storage_path = f"uploads/{collection_id}/{safe_name}"
|
| 354 |
+
supabase = get_supabase_client()
|
| 355 |
+
if supabase:
|
| 356 |
+
try:
|
| 357 |
+
import mimetypes
|
| 358 |
+
mime_type, _ = mimetypes.guess_type(filename)
|
| 359 |
+
if not mime_type:
|
| 360 |
+
mime_type = "application/octet-stream"
|
| 361 |
+
with open(upload_path, "rb") as f:
|
| 362 |
+
supabase.storage.from_("learning-playbook").upload(
|
| 363 |
+
path=storage_path,
|
| 364 |
+
file=f,
|
| 365 |
+
file_options={"content-type": mime_type, "x-upsert": "true"}
|
| 366 |
+
)
|
| 367 |
+
except Exception as exc:
|
| 368 |
+
print(f"Warning: Supabase Storage upload failed, relying on local: {exc}")
|
| 369 |
+
|
| 370 |
+
from db import get_db_connection
|
| 371 |
+
created_at = datetime.now().isoformat()
|
| 372 |
+
with get_db_connection() as conn:
|
| 373 |
+
with conn.cursor() as cursor:
|
| 374 |
+
cursor.execute(
|
| 375 |
+
"INSERT INTO rag_documents (id, collection_id, filename, original_type, pages, chunks, uploaded_at, upload_path) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
| 376 |
+
(doc_id, collection_id, filename, file_type, 0, 0, created_at, storage_path)
|
| 377 |
+
)
|
| 378 |
+
else:
|
| 379 |
+
meta = load_metadata()
|
| 380 |
+
if collection_id not in meta["collections"]:
|
| 381 |
+
raise ValueError("Collection not found")
|
| 382 |
+
meta["collections"][collection_id]["documents"][doc_id] = {
|
| 383 |
+
"filename": filename,
|
| 384 |
+
"original_type": file_type,
|
| 385 |
+
"pages": 0,
|
| 386 |
+
"chunks": 0,
|
| 387 |
+
"uploaded_at": datetime.now().isoformat(),
|
| 388 |
+
"upload_path": str(upload_path),
|
| 389 |
+
}
|
| 390 |
+
save_metadata(meta)
|
| 391 |
+
|
| 392 |
+
# Trigger background processing task
|
| 393 |
+
from tasks import process_document_task
|
| 394 |
+
task = process_document_task.delay(collection_id, doc_id, filename, file_type, str(upload_path))
|
| 395 |
+
|
| 396 |
+
return {
|
| 397 |
+
"id": doc_id,
|
| 398 |
+
"filename": filename,
|
| 399 |
+
"original_type": file_type,
|
| 400 |
+
"task_id": task.id,
|
| 401 |
+
"status": "processing"
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
# ---------------------------------------------------------------------------
|
| 405 |
+
# Shared text → ChromaDB indexer (used by video/url sources)
|
| 406 |
+
# ---------------------------------------------------------------------------
|
| 407 |
+
def _index_text_as_document(
|
| 408 |
+
collection_id: str,
|
| 409 |
+
doc_id: str,
|
| 410 |
+
text: str,
|
| 411 |
+
source_name: str,
|
| 412 |
+
source_type: str,
|
| 413 |
+
) -> dict:
|
| 414 |
+
"""Chunk raw text and index it into Pinecone under *doc_id*."""
|
| 415 |
+
from langchain_core.documents import Document as LCDocument
|
| 416 |
+
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
| 417 |
+
|
| 418 |
+
splitter = RecursiveCharacterTextSplitter(
|
| 419 |
+
chunk_size=1000,
|
| 420 |
+
chunk_overlap=300,
|
| 421 |
+
length_function=len,
|
| 422 |
+
add_start_index=True,
|
| 423 |
+
)
|
| 424 |
+
chunks = splitter.create_documents(
|
| 425 |
+
[text],
|
| 426 |
+
metadatas=[{"doc_id": doc_id, "filename": source_name, "page": 0}],
|
| 427 |
+
)
|
| 428 |
+
|
| 429 |
+
if chunks:
|
| 430 |
+
from langchain_pinecone import PineconeVectorStore
|
| 431 |
+
keys = get_all_gemini_api_keys()
|
| 432 |
+
if not keys:
|
| 433 |
+
raise ValueError("Limit reached try after 5 minutes please contact admin")
|
| 434 |
+
|
| 435 |
+
success = False
|
| 436 |
+
last_err = None
|
| 437 |
+
for key in keys:
|
| 438 |
+
try:
|
| 439 |
+
embedding_model = get_embedding_model(key)
|
| 440 |
+
PineconeVectorStore.from_documents(
|
| 441 |
+
chunks,
|
| 442 |
+
embedding_model,
|
| 443 |
+
index_name="maple-prospect",
|
| 444 |
+
namespace=f"col_{collection_id[:8]}"
|
| 445 |
+
)
|
| 446 |
+
success = True
|
| 447 |
+
break
|
| 448 |
+
except Exception as e:
|
| 449 |
+
last_err = e
|
| 450 |
+
continue
|
| 451 |
+
|
| 452 |
+
if not success:
|
| 453 |
+
raise ValueError("Limit reached try after 5 minutes please contact admin")
|
| 454 |
+
|
| 455 |
+
return {"chunks": len(chunks)}
|
| 456 |
+
|
| 457 |
+
# ---------------------------------------------------------------------------
|
| 458 |
+
# YouTube URL → transcript → index
|
| 459 |
+
# ---------------------------------------------------------------------------
|
| 460 |
+
def _extract_youtube_video_id(url: str) -> str | None:
|
| 461 |
+
from urllib.parse import urlparse, parse_qs
|
| 462 |
+
parsed = urlparse(url)
|
| 463 |
+
if parsed.hostname in ("www.youtube.com", "youtube.com"):
|
| 464 |
+
return parse_qs(parsed.query).get("v", [None])[0]
|
| 465 |
+
if parsed.hostname == "youtu.be":
|
| 466 |
+
return parsed.path.lstrip("/")
|
| 467 |
+
return None
|
| 468 |
+
|
| 469 |
+
def add_youtube_url(collection_id: str, url: str) -> dict:
|
| 470 |
+
"""Fetch YouTube transcript via Google Gemini and index it into the collection."""
|
| 471 |
+
if is_supabase_active():
|
| 472 |
+
from db import get_db_connection
|
| 473 |
+
with get_db_connection() as conn:
|
| 474 |
+
with conn.cursor() as cursor:
|
| 475 |
+
cursor.execute("SELECT id FROM rag_collections WHERE id = ?", (collection_id,))
|
| 476 |
+
if not cursor.fetchone():
|
| 477 |
+
raise ValueError("Collection not found")
|
| 478 |
+
else:
|
| 479 |
+
meta = load_metadata()
|
| 480 |
+
if collection_id not in meta["collections"]:
|
| 481 |
+
raise ValueError("Collection not found")
|
| 482 |
+
|
| 483 |
+
video_id = _extract_youtube_video_id(url)
|
| 484 |
+
if not video_id:
|
| 485 |
+
raise ValueError("Invalid YouTube URL. Supported formats: youtube.com/watch?v=... or youtu.be/...")
|
| 486 |
+
|
| 487 |
+
doc_id = str(uuid.uuid4())
|
| 488 |
+
source_name = f"youtube_{video_id}.txt"
|
| 489 |
+
|
| 490 |
+
if is_supabase_active():
|
| 491 |
+
from db import get_db_connection
|
| 492 |
+
created_at = datetime.now().isoformat()
|
| 493 |
+
with get_db_connection() as conn:
|
| 494 |
+
with conn.cursor() as cursor:
|
| 495 |
+
cursor.execute(
|
| 496 |
+
"INSERT INTO rag_documents (id, collection_id, filename, original_type, pages, chunks, uploaded_at, upload_path) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
| 497 |
+
(doc_id, collection_id, source_name, "youtube", 0, 0, created_at, None)
|
| 498 |
+
)
|
| 499 |
+
else:
|
| 500 |
+
meta = load_metadata()
|
| 501 |
+
meta["collections"][collection_id]["documents"][doc_id] = {
|
| 502 |
+
"filename": source_name,
|
| 503 |
+
"original_type": "youtube",
|
| 504 |
+
"pages": 0,
|
| 505 |
+
"chunks": 0,
|
| 506 |
+
"uploaded_at": datetime.now().isoformat(),
|
| 507 |
+
"upload_path": None,
|
| 508 |
+
}
|
| 509 |
+
save_metadata(meta)
|
| 510 |
+
|
| 511 |
+
from tasks import process_youtube_task
|
| 512 |
+
task = process_youtube_task.delay(collection_id, doc_id, url, video_id, source_name)
|
| 513 |
+
|
| 514 |
+
return {
|
| 515 |
+
"id": doc_id,
|
| 516 |
+
"filename": source_name,
|
| 517 |
+
"original_type": "youtube",
|
| 518 |
+
"source_url": url,
|
| 519 |
+
"task_id": task.id,
|
| 520 |
+
"status": "processing"
|
| 521 |
+
}
|
| 522 |
+
|
| 523 |
+
# ---------------------------------------------------------------------------
|
| 524 |
+
# Local MP4 video → upload to Gemini → transcribe → index
|
| 525 |
+
# ---------------------------------------------------------------------------
|
| 526 |
+
def add_video_file(collection_id: str, file_storage) -> dict:
|
| 527 |
+
"""Upload an MP4, transcribe with Google Gemini, and index."""
|
| 528 |
+
if is_supabase_active():
|
| 529 |
+
from db import get_db_connection
|
| 530 |
+
with get_db_connection() as conn:
|
| 531 |
+
with conn.cursor() as cursor:
|
| 532 |
+
cursor.execute("SELECT id FROM rag_collections WHERE id = ?", (collection_id,))
|
| 533 |
+
if not cursor.fetchone():
|
| 534 |
+
raise ValueError("Collection not found")
|
| 535 |
+
else:
|
| 536 |
+
meta = load_metadata()
|
| 537 |
+
if collection_id not in meta["collections"]:
|
| 538 |
+
raise ValueError("Collection not found")
|
| 539 |
+
|
| 540 |
+
filename = file_storage.filename
|
| 541 |
+
if not filename.lower().endswith(".mp4"):
|
| 542 |
+
raise ValueError("Only .mp4 video files are supported.")
|
| 543 |
+
|
| 544 |
+
doc_id = str(uuid.uuid4())
|
| 545 |
+
|
| 546 |
+
# Save uploaded MP4
|
| 547 |
+
upload_dir = UPLOADS_DIR / collection_id
|
| 548 |
+
upload_dir.mkdir(parents=True, exist_ok=True)
|
| 549 |
+
mp4_path = upload_dir / f"{doc_id}_{filename}"
|
| 550 |
+
file_storage.save(str(mp4_path))
|
| 551 |
+
|
| 552 |
+
if is_supabase_active():
|
| 553 |
+
storage_path = f"uploads/{collection_id}/{doc_id}_{filename}"
|
| 554 |
+
supabase = get_supabase_client()
|
| 555 |
+
if supabase:
|
| 556 |
+
try:
|
| 557 |
+
import mimetypes
|
| 558 |
+
mime_type, _ = mimetypes.guess_type(filename)
|
| 559 |
+
if not mime_type:
|
| 560 |
+
mime_type = "video/mp4"
|
| 561 |
+
with open(mp4_path, "rb") as f:
|
| 562 |
+
supabase.storage.from_("learning-playbook").upload(
|
| 563 |
+
path=storage_path,
|
| 564 |
+
file=f,
|
| 565 |
+
file_options={"content-type": mime_type, "x-upsert": "true"}
|
| 566 |
+
)
|
| 567 |
+
except Exception as exc:
|
| 568 |
+
print(f"Warning: Supabase Storage upload failed, relying on local: {exc}")
|
| 569 |
+
|
| 570 |
+
from db import get_db_connection
|
| 571 |
+
created_at = datetime.now().isoformat()
|
| 572 |
+
with get_db_connection() as conn:
|
| 573 |
+
with conn.cursor() as cursor:
|
| 574 |
+
cursor.execute(
|
| 575 |
+
"INSERT INTO rag_documents (id, collection_id, filename, original_type, pages, chunks, uploaded_at, upload_path) VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
| 576 |
+
(doc_id, collection_id, filename, "mp4", 0, 0, created_at, storage_path)
|
| 577 |
+
)
|
| 578 |
+
else:
|
| 579 |
+
meta = load_metadata()
|
| 580 |
+
meta["collections"][collection_id]["documents"][doc_id] = {
|
| 581 |
+
"filename": filename,
|
| 582 |
+
"original_type": "mp4",
|
| 583 |
+
"pages": 0,
|
| 584 |
+
"chunks": 0,
|
| 585 |
+
"uploaded_at": datetime.now().isoformat(),
|
| 586 |
+
"upload_path": str(mp4_path),
|
| 587 |
+
}
|
| 588 |
+
save_metadata(meta)
|
| 589 |
+
|
| 590 |
+
from tasks import process_video_task
|
| 591 |
+
task = process_video_task.delay(collection_id, doc_id, filename, str(mp4_path))
|
| 592 |
+
|
| 593 |
+
return {
|
| 594 |
+
"id": doc_id,
|
| 595 |
+
"filename": filename,
|
| 596 |
+
"original_type": "mp4",
|
| 597 |
+
"task_id": task.id,
|
| 598 |
+
"status": "processing"
|
| 599 |
+
}
|
| 600 |
+
|
| 601 |
+
def delete_document(collection_id: str, doc_id: str) -> None:
|
| 602 |
+
# Remove chunks from Pinecone
|
| 603 |
+
try:
|
| 604 |
+
from pinecone import Pinecone
|
| 605 |
+
pc = Pinecone(api_key=os.environ.get("PINECONE_API_KEY"))
|
| 606 |
+
index = pc.Index("maple-prospect")
|
| 607 |
+
index.delete(filter={"doc_id": {"$eq": doc_id}}, namespace=f"col_{collection_id[:8]}")
|
| 608 |
+
except Exception as exc:
|
| 609 |
+
print(f"Warning: Pinecone cleanup for doc {doc_id}: {exc}")
|
| 610 |
+
|
| 611 |
+
if is_supabase_active():
|
| 612 |
+
from db import get_db_connection
|
| 613 |
+
with get_db_connection() as conn:
|
| 614 |
+
with conn.cursor() as cursor:
|
| 615 |
+
cursor.execute("SELECT * FROM rag_documents WHERE id = ?", (doc_id,))
|
| 616 |
+
doc_data = cursor.fetchone()
|
| 617 |
+
|
| 618 |
+
if not doc_data:
|
| 619 |
+
raise ValueError("Document not found")
|
| 620 |
+
|
| 621 |
+
# Remove from Supabase Storage
|
| 622 |
+
supabase = get_supabase_client()
|
| 623 |
+
if supabase and doc_data.get("upload_path"):
|
| 624 |
+
try:
|
| 625 |
+
supabase.storage.from_("learning-playbook").remove([doc_data["upload_path"]])
|
| 626 |
+
except Exception as exc:
|
| 627 |
+
print(f"Warning: Supabase Storage file deletion failed: {exc}")
|
| 628 |
+
|
| 629 |
+
# Remove local file if exists
|
| 630 |
+
local_filename = f"{doc_id}_{doc_data['filename']}"
|
| 631 |
+
local_path = UPLOADS_DIR / collection_id / local_filename
|
| 632 |
+
if local_path.exists():
|
| 633 |
+
local_path.unlink(missing_ok=True)
|
| 634 |
+
|
| 635 |
+
with get_db_connection() as conn:
|
| 636 |
+
with conn.cursor() as cursor:
|
| 637 |
+
cursor.execute("DELETE FROM rag_documents WHERE id = ?", (doc_id,))
|
| 638 |
+
else:
|
| 639 |
+
meta = load_metadata()
|
| 640 |
+
if collection_id not in meta["collections"]:
|
| 641 |
+
raise ValueError("Collection not found")
|
| 642 |
+
if doc_id not in meta["collections"][collection_id].get("documents", {}):
|
| 643 |
+
raise ValueError("Document not found")
|
| 644 |
+
|
| 645 |
+
# Remove file
|
| 646 |
+
doc_data = meta["collections"][collection_id]["documents"][doc_id]
|
| 647 |
+
upload_path = doc_data.get("upload_path")
|
| 648 |
+
if upload_path and Path(upload_path).exists():
|
| 649 |
+
Path(upload_path).unlink(missing_ok=True)
|
| 650 |
+
|
| 651 |
+
del meta["collections"][collection_id]["documents"][doc_id]
|
| 652 |
+
save_metadata(meta)
|
| 653 |
+
|
| 654 |
+
# ---------------------------------------------------------------------------
|
| 655 |
+
# RAG query
|
| 656 |
+
# ---------------------------------------------------------------------------
|
| 657 |
+
def format_docs(docs) -> str:
|
| 658 |
+
formatted = []
|
| 659 |
+
seen_content: set[str] = set()
|
| 660 |
+
for doc in docs:
|
| 661 |
+
page_num = doc.metadata.get("page", 0)
|
| 662 |
+
content = doc.page_content.strip()
|
| 663 |
+
if content not in seen_content:
|
| 664 |
+
seen_content.add(content)
|
| 665 |
+
formatted.append(f"[Page {page_num + 1}]: {content}")
|
| 666 |
+
return "\n\n".join(formatted)
|
| 667 |
+
|
| 668 |
+
# ---------------------------------------------------------------------------
|
| 669 |
+
# Chat Sessions
|
| 670 |
+
# ---------------------------------------------------------------------------
|
| 671 |
+
MAX_MEMORY_TURNS = 5 # number of Q&A pairs kept as LLM context
|
| 672 |
+
|
| 673 |
+
def create_session(collection_id: str, name: str | None = None) -> dict:
|
| 674 |
+
if is_supabase_active():
|
| 675 |
+
from db import get_db_connection
|
| 676 |
+
session_id = str(uuid.uuid4())
|
| 677 |
+
created_at = datetime.now().isoformat()
|
| 678 |
+
with get_db_connection() as conn:
|
| 679 |
+
with conn.cursor() as cursor:
|
| 680 |
+
cursor.execute("SELECT COUNT(*) FROM rag_sessions WHERE collection_id = ?", (collection_id,))
|
| 681 |
+
count = cursor.fetchone()[0]
|
| 682 |
+
session_name = name or f"Chat {count + 1}"
|
| 683 |
+
cursor.execute(
|
| 684 |
+
"INSERT INTO rag_sessions (id, collection_id, name, created_at) VALUES (?, ?, ?, ?)",
|
| 685 |
+
(session_id, collection_id, session_name, created_at)
|
| 686 |
+
)
|
| 687 |
+
return {"id": session_id, "name": session_name, "created_at": created_at}
|
| 688 |
+
else:
|
| 689 |
+
meta = load_metadata()
|
| 690 |
+
if collection_id not in meta["collections"]:
|
| 691 |
+
raise ValueError("Collection not found")
|
| 692 |
+
sessions = meta["collections"][collection_id].setdefault("sessions", {})
|
| 693 |
+
session_id = str(uuid.uuid4())
|
| 694 |
+
session_name = name or f"Chat {len(sessions) + 1}"
|
| 695 |
+
sessions[session_id] = {
|
| 696 |
+
"name": session_name,
|
| 697 |
+
"created_at": datetime.now().isoformat(),
|
| 698 |
+
"messages": [],
|
| 699 |
+
}
|
| 700 |
+
save_metadata(meta)
|
| 701 |
+
return {"id": session_id, "name": session_name, "created_at": sessions[session_id]["created_at"]}
|
| 702 |
+
|
| 703 |
+
def list_sessions(collection_id: str) -> list[dict]:
|
| 704 |
+
if is_supabase_active():
|
| 705 |
+
from db import get_db_connection
|
| 706 |
+
with get_db_connection() as conn:
|
| 707 |
+
with conn.cursor() as cursor:
|
| 708 |
+
cursor.execute("""
|
| 709 |
+
SELECT rs.*, COUNT(rm.id) as msg_count
|
| 710 |
+
FROM rag_sessions rs
|
| 711 |
+
LEFT JOIN rag_messages rm ON rs.id = rm.session_id
|
| 712 |
+
WHERE rs.collection_id = ?
|
| 713 |
+
GROUP BY rs.id, rs.created_at, rs.name, rs.collection_id
|
| 714 |
+
ORDER BY rs.created_at DESC
|
| 715 |
+
""", (collection_id,))
|
| 716 |
+
rows = cursor.fetchall()
|
| 717 |
+
return [
|
| 718 |
+
{
|
| 719 |
+
"id": r["id"],
|
| 720 |
+
"name": r["name"],
|
| 721 |
+
"created_at": str(r["created_at"]),
|
| 722 |
+
"message_count": r["msg_count"],
|
| 723 |
+
}
|
| 724 |
+
for r in rows
|
| 725 |
+
]
|
| 726 |
+
else:
|
| 727 |
+
meta = load_metadata()
|
| 728 |
+
if collection_id not in meta["collections"]:
|
| 729 |
+
raise ValueError("Collection not found")
|
| 730 |
+
sessions = meta["collections"][collection_id].get("sessions", {})
|
| 731 |
+
result = []
|
| 732 |
+
for sid, sdata in sessions.items():
|
| 733 |
+
result.append({
|
| 734 |
+
"id": sid,
|
| 735 |
+
"name": sdata["name"],
|
| 736 |
+
"created_at": sdata["created_at"],
|
| 737 |
+
"message_count": len(sdata.get("messages", [])),
|
| 738 |
+
})
|
| 739 |
+
result.sort(key=lambda x: x["created_at"], reverse=True)
|
| 740 |
+
return result
|
| 741 |
+
|
| 742 |
+
def get_session_messages(collection_id: str, session_id: str) -> list[dict]:
|
| 743 |
+
if is_supabase_active():
|
| 744 |
+
from db import get_db_connection
|
| 745 |
+
with get_db_connection() as conn:
|
| 746 |
+
with conn.cursor() as cursor:
|
| 747 |
+
cursor.execute("SELECT role, content, sources FROM rag_messages WHERE session_id = ? ORDER BY id ASC", (session_id,))
|
| 748 |
+
rows = cursor.fetchall()
|
| 749 |
+
return [
|
| 750 |
+
{
|
| 751 |
+
"role": r["role"],
|
| 752 |
+
"content": r["content"],
|
| 753 |
+
"sources": r["sources"] if isinstance(r["sources"], list) else json.loads(r["sources"] or "[]"),
|
| 754 |
+
}
|
| 755 |
+
for r in rows
|
| 756 |
+
]
|
| 757 |
+
else:
|
| 758 |
+
meta = load_metadata()
|
| 759 |
+
if collection_id not in meta["collections"]:
|
| 760 |
+
raise ValueError("Collection not found")
|
| 761 |
+
sessions = meta["collections"][collection_id].get("sessions", {})
|
| 762 |
+
if session_id not in sessions:
|
| 763 |
+
raise ValueError("Session not found")
|
| 764 |
+
return sessions[session_id].get("messages", [])
|
| 765 |
+
|
| 766 |
+
def delete_session(collection_id: str, session_id: str) -> None:
|
| 767 |
+
if is_supabase_active():
|
| 768 |
+
from db import get_db_connection
|
| 769 |
+
with get_db_connection() as conn:
|
| 770 |
+
with conn.cursor() as cursor:
|
| 771 |
+
cursor.execute("DELETE FROM rag_sessions WHERE id = ?", (session_id,))
|
| 772 |
+
else:
|
| 773 |
+
meta = load_metadata()
|
| 774 |
+
if collection_id not in meta["collections"]:
|
| 775 |
+
raise ValueError("Collection not found")
|
| 776 |
+
sessions = meta["collections"][collection_id].get("sessions", {})
|
| 777 |
+
if session_id not in sessions:
|
| 778 |
+
raise ValueError("Session not found")
|
| 779 |
+
del sessions[session_id]
|
| 780 |
+
save_metadata(meta)
|
| 781 |
+
|
| 782 |
+
def rename_session(collection_id: str, session_id: str, new_name: str) -> dict:
|
| 783 |
+
if is_supabase_active():
|
| 784 |
+
from db import get_db_connection
|
| 785 |
+
with get_db_connection() as conn:
|
| 786 |
+
with conn.cursor() as cursor:
|
| 787 |
+
cursor.execute("UPDATE rag_sessions SET name = ? WHERE id = ?", (new_name, session_id))
|
| 788 |
+
return {"id": session_id, "name": new_name}
|
| 789 |
+
else:
|
| 790 |
+
meta = load_metadata()
|
| 791 |
+
if collection_id not in meta["collections"]:
|
| 792 |
+
raise ValueError("Collection not found")
|
| 793 |
+
sessions = meta["collections"][collection_id].get("sessions", {})
|
| 794 |
+
if session_id not in sessions:
|
| 795 |
+
raise ValueError("Session not found")
|
| 796 |
+
sessions[session_id]["name"] = new_name
|
| 797 |
+
save_metadata(meta)
|
| 798 |
+
return {"id": session_id, "name": new_name}
|
| 799 |
+
|
| 800 |
+
def _save_message(collection_id: str, session_id: str, role: str, content: str, sources: list | None = None) -> None:
|
| 801 |
+
"""Append a message to a session's history."""
|
| 802 |
+
if is_supabase_active():
|
| 803 |
+
from db import get_db_connection
|
| 804 |
+
with get_db_connection() as conn:
|
| 805 |
+
with conn.cursor() as cursor:
|
| 806 |
+
cursor.execute(
|
| 807 |
+
"INSERT INTO rag_messages (collection_id, session_id, role, content, sources) VALUES (?, ?, ?, ?, ?)",
|
| 808 |
+
(collection_id, session_id, role, content, json.dumps(sources or []))
|
| 809 |
+
)
|
| 810 |
+
else:
|
| 811 |
+
meta = load_metadata()
|
| 812 |
+
sessions = meta["collections"][collection_id].get("sessions", {})
|
| 813 |
+
if session_id not in sessions:
|
| 814 |
+
return
|
| 815 |
+
msg: dict[str, Any] = {"role": role, "content": content}
|
| 816 |
+
if sources is not None:
|
| 817 |
+
msg["sources"] = sources
|
| 818 |
+
sessions[session_id]["messages"].append(msg)
|
| 819 |
+
save_metadata(meta)
|
| 820 |
+
|
| 821 |
+
# ---------------------------------------------------------------------------
|
| 822 |
+
# RAG query (with session memory)
|
| 823 |
+
# ---------------------------------------------------------------------------
|
| 824 |
+
def query_collection(collection_id: str, question: str, session_id: str | None = None) -> dict[str, Any]:
|
| 825 |
+
"""Run a RAG query against the indexed documents in *collection_id*."""
|
| 826 |
+
if is_supabase_active():
|
| 827 |
+
from db import get_db_connection
|
| 828 |
+
with get_db_connection() as conn:
|
| 829 |
+
with conn.cursor() as cursor:
|
| 830 |
+
cursor.execute("SELECT id FROM rag_collections WHERE id = ?", (collection_id,))
|
| 831 |
+
if not cursor.fetchone():
|
| 832 |
+
raise ValueError("Collection not found")
|
| 833 |
+
cursor.execute("SELECT COUNT(*) FROM rag_documents WHERE collection_id = ?", (collection_id,))
|
| 834 |
+
doc_count = cursor.fetchone()[0]
|
| 835 |
+
if doc_count == 0:
|
| 836 |
+
return {
|
| 837 |
+
"answer": "No documents have been uploaded yet. Please upload some documents first.",
|
| 838 |
+
"sources": [],
|
| 839 |
+
}
|
| 840 |
+
else:
|
| 841 |
+
meta = load_metadata()
|
| 842 |
+
if collection_id not in meta["collections"]:
|
| 843 |
+
raise ValueError("Collection not found")
|
| 844 |
+
if not meta["collections"][collection_id].get("documents"):
|
| 845 |
+
return {
|
| 846 |
+
"answer": "No documents have been uploaded yet. Please upload some documents first.",
|
| 847 |
+
"sources": [],
|
| 848 |
+
}
|
| 849 |
+
|
| 850 |
+
from langchain_pinecone import PineconeVectorStore
|
| 851 |
+
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
|
| 852 |
+
|
| 853 |
+
keys = get_all_gemini_api_keys()
|
| 854 |
+
if not keys:
|
| 855 |
+
return {"answer": "Limit reached try after 5 minutes please contact admin", "sources": [], "suggestions": []}
|
| 856 |
+
|
| 857 |
+
docs = None
|
| 858 |
+
last_err = None
|
| 859 |
+
for key in keys:
|
| 860 |
+
try:
|
| 861 |
+
embedding_model = get_embedding_model(key)
|
| 862 |
+
vector_store = PineconeVectorStore(
|
| 863 |
+
index_name="maple-prospect",
|
| 864 |
+
embedding=embedding_model,
|
| 865 |
+
namespace=f"col_{collection_id[:8]}"
|
| 866 |
+
)
|
| 867 |
+
retriever = vector_store.as_retriever(
|
| 868 |
+
search_type="similarity",
|
| 869 |
+
search_kwargs={"k": 3},
|
| 870 |
+
)
|
| 871 |
+
docs = retriever.invoke(question)
|
| 872 |
+
break
|
| 873 |
+
except Exception as e:
|
| 874 |
+
last_err = e
|
| 875 |
+
docs = None
|
| 876 |
+
continue
|
| 877 |
+
|
| 878 |
+
if docs is None and last_err is not None:
|
| 879 |
+
return {"answer": "Limit reached try after 5 minutes please contact admin", "sources": [], "suggestions": []}
|
| 880 |
+
|
| 881 |
+
if not docs:
|
| 882 |
+
return {"answer": "The answer is not available in the uploaded documents.", "sources": []}
|
| 883 |
+
|
| 884 |
+
context = format_docs(docs)
|
| 885 |
+
|
| 886 |
+
# Build message list with system prompt
|
| 887 |
+
messages = [
|
| 888 |
+
SystemMessage(
|
| 889 |
+
content=(
|
| 890 |
+
"You are an intelligent explainer and learning assistant — like a knowledgeable tutor "
|
| 891 |
+
"who can both explain documents and answer general questions. "
|
| 892 |
+
"Your job is to make complex topics easy to understand. "
|
| 893 |
+
"Always respond in a clear, engaging, and well-structured way with 3 to 4 sentences minimum. "
|
| 894 |
+
"Do not give one-line answers. Include key details and slightly elaborate the concept.\n\n"
|
| 895 |
+
|
| 896 |
+
"When answering, follow these rules:\n"
|
| 897 |
+
"1. DOCUMENT QUESTIONS: If the answer is present in the provided context, answer using "
|
| 898 |
+
"that context. Each piece of context is prefixed with its page number like [Page 3]. "
|
| 899 |
+
"Naturally include page references like (p. 3) when citing specific information — "
|
| 900 |
+
"do not overuse them, only cite the most relevant pages.\n"
|
| 901 |
+
"2. GENERAL QUESTIONS: If the user is asking a general question that is NOT in the "
|
| 902 |
+
"document context, use your own knowledge to explain it clearly and helpfully. "
|
| 903 |
+
"In this case, mention that you are answering from general knowledge, not the document.\n"
|
| 904 |
+
"3. MIXED: If the question is partially answered by the document and partially general, "
|
| 905 |
+
"combine both — cite the document where relevant and supplement with your own knowledge.\n\n"
|
| 906 |
+
|
| 907 |
+
"After your answer, add a line containing ONLY '---SUGGESTIONS---' "
|
| 908 |
+
"followed by exactly 3 short follow-up questions the user might want to ask next, "
|
| 909 |
+
"each on its own line starting with '- '. "
|
| 910 |
+
"These should be relevant to the context and your answer."
|
| 911 |
+
)
|
| 912 |
+
),
|
| 913 |
+
]
|
| 914 |
+
|
| 915 |
+
# Inject last N conversation turns as memory
|
| 916 |
+
if session_id:
|
| 917 |
+
history = get_session_messages(collection_id, session_id)
|
| 918 |
+
# Take last MAX_MEMORY_TURNS pairs (user + assistant = 2 messages each)
|
| 919 |
+
recent = history[-(MAX_MEMORY_TURNS * 2):]
|
| 920 |
+
for msg in recent:
|
| 921 |
+
if msg["role"] == "user":
|
| 922 |
+
messages.append(HumanMessage(content=msg["content"]))
|
| 923 |
+
elif msg["role"] == "assistant":
|
| 924 |
+
messages.append(AIMessage(content=msg["content"]))
|
| 925 |
+
|
| 926 |
+
# Current question with context
|
| 927 |
+
messages.append(HumanMessage(content=f"Context:\n{context}\n\nQuestion:\n{question}"))
|
| 928 |
+
|
| 929 |
+
response = None
|
| 930 |
+
last_err = None
|
| 931 |
+
for key in keys:
|
| 932 |
+
try:
|
| 933 |
+
llm = get_llm(key)
|
| 934 |
+
response = llm.invoke(messages)
|
| 935 |
+
break
|
| 936 |
+
except Exception as e:
|
| 937 |
+
last_err = e
|
| 938 |
+
response = None
|
| 939 |
+
continue
|
| 940 |
+
|
| 941 |
+
if response is None:
|
| 942 |
+
return {"answer": "Limit reached try after 5 minutes please contact admin", "sources": [], "suggestions": []}
|
| 943 |
+
|
| 944 |
+
# Parse answer and follow-up suggestions
|
| 945 |
+
raw_content = response.content or ""
|
| 946 |
+
suggestions: list[str] = []
|
| 947 |
+
answer_text = raw_content
|
| 948 |
+
|
| 949 |
+
if "---SUGGESTIONS---" in raw_content:
|
| 950 |
+
parts = raw_content.split("---SUGGESTIONS---", 1)
|
| 951 |
+
answer_text = parts[0].strip()
|
| 952 |
+
suggestion_block = parts[1].strip()
|
| 953 |
+
for line in suggestion_block.splitlines():
|
| 954 |
+
line = line.strip()
|
| 955 |
+
if line.startswith("- "):
|
| 956 |
+
suggestions.append(line[2:].strip())
|
| 957 |
+
suggestions = suggestions[:3] # cap at 3
|
| 958 |
+
|
| 959 |
+
# Collect source references
|
| 960 |
+
sources: list[dict] = []
|
| 961 |
+
seen_pages: set[int] = set()
|
| 962 |
+
for doc in docs:
|
| 963 |
+
page = doc.metadata.get("page", 0) + 1
|
| 964 |
+
fname = doc.metadata.get("filename", "Unknown")
|
| 965 |
+
if page not in seen_pages:
|
| 966 |
+
seen_pages.add(page)
|
| 967 |
+
sources.append({"page": page, "filename": fname})
|
| 968 |
+
|
| 969 |
+
# Persist messages to session
|
| 970 |
+
if session_id:
|
| 971 |
+
_save_message(collection_id, session_id, "user", question)
|
| 972 |
+
_save_message(collection_id, session_id, "assistant", answer_text, sources)
|
| 973 |
+
|
| 974 |
+
return {"answer": answer_text, "sources": sources, "suggestions": suggestions}
|
| 975 |
+
|
services/research/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Research pipeline services package for ProspectIQ."""
|
services/research/analyzer.py
ADDED
|
@@ -0,0 +1,568 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Company Research Script
|
| 4 |
+
Uses Gemini 2.5 Flash with Google Search grounding to research a target
|
| 5 |
+
company and produce a B2B sales intelligence profile — tailored to the
|
| 6 |
+
seller's own business context, loaded fresh from company.md on every run.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import os
|
| 10 |
+
import sys
|
| 11 |
+
import json
|
| 12 |
+
import argparse
|
| 13 |
+
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
|
| 14 |
+
from dotenv import load_dotenv
|
| 15 |
+
from google import genai
|
| 16 |
+
from google.genai.types import Tool, GoogleSearch, GenerateContentConfig, ServiceTier
|
| 17 |
+
|
| 18 |
+
# Load environment variables
|
| 19 |
+
load_dotenv()
|
| 20 |
+
|
| 21 |
+
PAID_GEMINI_ENV_NAMES = (
|
| 22 |
+
"GEMINI_API_KEY_prime_1",
|
| 23 |
+
"GEMINI_API_KEY_prime_2",
|
| 24 |
+
"GEMINI_API_KEY_prime_3",
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
def get_all_paid_gemini_api_keys() -> list[str]:
|
| 28 |
+
keys = []
|
| 29 |
+
for name in PAID_GEMINI_ENV_NAMES:
|
| 30 |
+
key = os.getenv(name, "").strip()
|
| 31 |
+
if key:
|
| 32 |
+
keys.append(key)
|
| 33 |
+
return keys
|
| 34 |
+
|
| 35 |
+
DEFAULT_COMPANY_MD_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "company.md")
|
| 36 |
+
DEFAULT_SEARCH_RESULTS_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "combined_results.json")
|
| 37 |
+
|
| 38 |
+
RESEARCH_PROMPT = """
|
| 39 |
+
You are a world-class B2B sales intelligence researcher working on behalf of OUR COMPANY (described below).
|
| 40 |
+
Your task is to research the TARGET COMPANY "{company_name}" thoroughly using web search and produce a
|
| 41 |
+
structured, detailed profile that helps OUR COMPANY's sales team sell to them.
|
| 42 |
+
|
| 43 |
+
=== OUR COMPANY (the seller — use this to tailor relevance, fit, and decision-maker targeting) ===
|
| 44 |
+
{seller_context}
|
| 45 |
+
=== END OUR COMPANY CONTEXT ===
|
| 46 |
+
|
| 47 |
+
Fill in EVERY field below for the TARGET COMPANY "{company_name}". For fields where data is unavailable
|
| 48 |
+
after searching, write "Not publicly available" — never leave blank.
|
| 49 |
+
|
| 50 |
+
---
|
| 51 |
+
|
| 52 |
+
# Company Basics
|
| 53 |
+
|
| 54 |
+
- **Company Name**:
|
| 55 |
+
- **Website**:
|
| 56 |
+
- **LinkedIn URL**:
|
| 57 |
+
- **Industry**:
|
| 58 |
+
- **Sub-Industry**:
|
| 59 |
+
- **Business Description**: (3–5 sentence overview)
|
| 60 |
+
- **Products / Services**: (bullet list)
|
| 61 |
+
- **Customer Segments**: (who they sell to)
|
| 62 |
+
- **Headquarters**:
|
| 63 |
+
- **Other Locations**:
|
| 64 |
+
- **Founded Year**:
|
| 65 |
+
- **Employee Count**:
|
| 66 |
+
- **Revenue Estimate**:
|
| 67 |
+
- **Company Growth Rate**:
|
| 68 |
+
- **Ownership Type**: (Public / Private / PE-backed / etc.)
|
| 69 |
+
- **Parent Company**:
|
| 70 |
+
- **Funding History**: (rounds, amounts, investors)
|
| 71 |
+
- **Tech Stack**: (known tools, platforms, languages)
|
| 72 |
+
|
| 73 |
+
---
|
| 74 |
+
|
| 75 |
+
# Company Health & Growth Signals
|
| 76 |
+
|
| 77 |
+
- **Recent Revenue Growth**:
|
| 78 |
+
- **Recent Funding**:
|
| 79 |
+
- **Profitability Indicators**:
|
| 80 |
+
- **Expansion Plans**:
|
| 81 |
+
- **Market Position**: (leader / challenger / niche)
|
| 82 |
+
- **Key Competitors**:
|
| 83 |
+
|
| 84 |
+
---
|
| 85 |
+
|
| 86 |
+
# Trigger Events
|
| 87 |
+
|
| 88 |
+
Search for recent news (last 12 months) and fill each. Pay special attention to signals relevant to
|
| 89 |
+
OUR COMPANY's offering as described above — i.e. trigger events that would plausibly increase or decrease
|
| 90 |
+
{company_name}'s need for what OUR COMPANY sells:
|
| 91 |
+
|
| 92 |
+
- **Funding Event**:
|
| 93 |
+
- **Acquisition**:
|
| 94 |
+
- **Merger**:
|
| 95 |
+
- **New Product Launch**:
|
| 96 |
+
- **Executive Hire**:
|
| 97 |
+
- **Executive Departure**:
|
| 98 |
+
- **Office Expansion**:
|
| 99 |
+
- **International Expansion**:
|
| 100 |
+
- **Job Postings Surge**:
|
| 101 |
+
- **Layoffs**:
|
| 102 |
+
- **Digital Transformation Initiative**:
|
| 103 |
+
- **Partnership Announcement**:
|
| 104 |
+
- **Compliance / Regulatory Changes**:
|
| 105 |
+
- **Earnings Report**:
|
| 106 |
+
- **Major Customer Win**:
|
| 107 |
+
- **Major Customer Loss**:
|
| 108 |
+
- **New Facility / Production Line**:
|
| 109 |
+
- **Technology Migration**:
|
| 110 |
+
- **IPO Activity**:
|
| 111 |
+
|
| 112 |
+
---
|
| 113 |
+
|
| 114 |
+
# Industry Context
|
| 115 |
+
|
| 116 |
+
Based on {company_name}'s industry:
|
| 117 |
+
|
| 118 |
+
- **Industry Trends**:
|
| 119 |
+
- **Industry Growth Rate**:
|
| 120 |
+
- **Emerging Technologies**:
|
| 121 |
+
- **Labor Market Conditions**:
|
| 122 |
+
- **Regulatory Pressures**:
|
| 123 |
+
- **Competitive Pressures**:
|
| 124 |
+
- **Economic Factors**:
|
| 125 |
+
- **Common Industry Challenges**:
|
| 126 |
+
|
| 127 |
+
---
|
| 128 |
+
|
| 129 |
+
{search_results_context}
|
| 130 |
+
|
| 131 |
+
# Decision Makers
|
| 132 |
+
|
| 133 |
+
First, based on OUR COMPANY's context above (what is being sold, deal size, purchase frequency, and who
|
| 134 |
+
typically owns this kind of buying decision), infer which job titles/functions at {company_name} are most
|
| 135 |
+
likely to be the actual buyers or influencers for OUR COMPANY's offering. Then search (via web search,
|
| 136 |
+
LinkedIn, company "About/Team" pages, and job postings) for real people at {company_name} holding those
|
| 137 |
+
titles.
|
| 138 |
+
|
| 139 |
+
If a "PRE-FETCHED SEARCH RESULTS" section appears above with content, treat it as your primary evidence
|
| 140 |
+
for this section and the Contact Prioritization section below: extract real names, titles, and LinkedIn
|
| 141 |
+
URLs directly from those results first. Use your own web search only to fill gaps, verify ambiguous or
|
| 142 |
+
conflicting results, or find fields the pre-fetched results don't cover (e.g. email, phone). Do not mention
|
| 143 |
+
in your output that some evidence was pre-fetched — just fill in the fields as instructed below.
|
| 144 |
+
|
| 145 |
+
List the top 5–7 most relevant people found, prioritizing the roles most likely to own or influence this
|
| 146 |
+
specific purchase over generic executives, unless OUR COMPANY's context indicates this is genuinely a
|
| 147 |
+
senior/strategic sale. For each:
|
| 148 |
+
|
| 149 |
+
**Person 1:**
|
| 150 |
+
- Name:
|
| 151 |
+
- Title:
|
| 152 |
+
- Department:
|
| 153 |
+
- Seniority Level:
|
| 154 |
+
- LinkedIn URL:
|
| 155 |
+
- Email (if available):
|
| 156 |
+
- Phone (if available):
|
| 157 |
+
|
| 158 |
+
(repeat for each person found)
|
| 159 |
+
|
| 160 |
+
If the ideal roles can't be found publicly, fall back to the most relevant person available and note
|
| 161 |
+
this explicitly.
|
| 162 |
+
|
| 163 |
+
---
|
| 164 |
+
|
| 165 |
+
# Contact Prioritization
|
| 166 |
+
|
| 167 |
+
# Contact Prioritization
|
| 168 |
+
|
| 169 |
+
Based on OUR COMPANY's context above (what's being sold, typical deal size, and purchase pattern), reason
|
| 170 |
+
through the right outreach strategy at {company_name}:
|
| 171 |
+
|
| 172 |
+
- **Primary Contact**: (the person most likely to own this buying decision, given what OUR COMPANY sells)
|
| 173 |
+
- **Primary Contact Reason**:
|
| 174 |
+
- **Secondary Contact**: (an influencer or alternate budget holder)
|
| 175 |
+
- **Secondary Contact Reason**:
|
| 176 |
+
- **Tertiary Contact**: (a backup option, e.g. relevant for multi-site or specific organizational structures)
|
| 177 |
+
- **Tertiary Contact Reason**:
|
| 178 |
+
- **Too Senior To Cold Contact**: (name/title if found)
|
| 179 |
+
- **Reason**: (explain, based on OUR COMPANY's offering and typical deal size/frequency, why this person
|
| 180 |
+
is the wrong first point of contact, and why the contacts above are the right entry point instead)
|
| 181 |
+
|
| 182 |
+
---
|
| 183 |
+
|
| 184 |
+
Use web search extensively to find the most current and accurate information. Be specific with numbers,
|
| 185 |
+
dates, and sources where possible. Throughout, keep OUR COMPANY's offering in mind so the profile is
|
| 186 |
+
immediately actionable for outreach.
|
| 187 |
+
"""
|
| 188 |
+
|
| 189 |
+
def load_seller_context(path: str) -> str:
|
| 190 |
+
"""Load the seller's own company context from company.md, re-read fresh each run."""
|
| 191 |
+
if not os.path.exists(path):
|
| 192 |
+
print(f"⚠️ Warning: {path} not found. Proceeding without seller context.")
|
| 193 |
+
return "(No seller context provided — company.md was not found.)"
|
| 194 |
+
|
| 195 |
+
with open(path, "r", encoding="utf-8") as f:
|
| 196 |
+
content = f.read().strip()
|
| 197 |
+
|
| 198 |
+
if not content:
|
| 199 |
+
print(f"⚠️ Warning: {path} is empty. Proceeding without seller context.")
|
| 200 |
+
return "(No seller context provided — company.md was empty.)"
|
| 201 |
+
|
| 202 |
+
return content
|
| 203 |
+
|
| 204 |
+
def load_search_results(path: str | None) -> dict | None:
|
| 205 |
+
"""Load combined_results.json produced by run_pipeline.py (query -> list of
|
| 206 |
+
parsed Google result dicts). Returns None if no path/file is available so the
|
| 207 |
+
caller can proceed without it."""
|
| 208 |
+
if not path:
|
| 209 |
+
return None
|
| 210 |
+
if not os.path.exists(path):
|
| 211 |
+
print(f"ℹ️ No search-results file found at {path} — proceeding without pre-fetched results.")
|
| 212 |
+
return None
|
| 213 |
+
|
| 214 |
+
try:
|
| 215 |
+
with open(path, "r", encoding="utf-8") as f:
|
| 216 |
+
data = json.load(f)
|
| 217 |
+
except (json.JSONDecodeError, OSError) as e:
|
| 218 |
+
print(f"⚠️ Warning: could not read/parse {path} ({e}). Proceeding without pre-fetched results.")
|
| 219 |
+
return None
|
| 220 |
+
|
| 221 |
+
if not isinstance(data, dict) or not data:
|
| 222 |
+
print(f"⚠️ Warning: {path} is empty or not in the expected {{query: [...]}} shape. "
|
| 223 |
+
f"Proceeding without pre-fetched results.")
|
| 224 |
+
return None
|
| 225 |
+
|
| 226 |
+
return data
|
| 227 |
+
|
| 228 |
+
TRACKING_PARAMS = {
|
| 229 |
+
"utm_source",
|
| 230 |
+
"utm_medium",
|
| 231 |
+
"utm_campaign",
|
| 232 |
+
"utm_term",
|
| 233 |
+
"utm_content",
|
| 234 |
+
"fbclid",
|
| 235 |
+
"gclid",
|
| 236 |
+
"igshid",
|
| 237 |
+
"mc_cid",
|
| 238 |
+
"mc_eid",
|
| 239 |
+
}
|
| 240 |
+
|
| 241 |
+
def compact_url(url: str) -> str:
|
| 242 |
+
parsed = urlparse(url.strip())
|
| 243 |
+
if not parsed.scheme or not parsed.netloc:
|
| 244 |
+
return url.strip()
|
| 245 |
+
|
| 246 |
+
query = urlencode([
|
| 247 |
+
(key, value)
|
| 248 |
+
for key, value in parse_qsl(parsed.query, keep_blank_values=True)
|
| 249 |
+
if key.lower() not in TRACKING_PARAMS
|
| 250 |
+
])
|
| 251 |
+
path = parsed.path.rstrip("/") or "/"
|
| 252 |
+
return urlunparse((parsed.scheme, parsed.netloc.lower(), path, "", query, ""))
|
| 253 |
+
|
| 254 |
+
def result_score(result: dict) -> int:
|
| 255 |
+
text = " ".join(
|
| 256 |
+
str(result.get(key) or "").lower()
|
| 257 |
+
for key in ("title", "url", "byline", "snippet")
|
| 258 |
+
)
|
| 259 |
+
score = 0
|
| 260 |
+
if "linkedin.com/in" in text:
|
| 261 |
+
score += 8
|
| 262 |
+
elif "linkedin.com" in text:
|
| 263 |
+
score += 5
|
| 264 |
+
if any(term in text for term in ("procurement", "purchase", "supply chain", "facility", "facilities", "admin")):
|
| 265 |
+
score += 4
|
| 266 |
+
if any(term in text for term in ("manager", "head", "director", "vp", "chief", "officer")):
|
| 267 |
+
score += 3
|
| 268 |
+
if any(term in text for term in ("email", "phone", "contact")):
|
| 269 |
+
score += 2
|
| 270 |
+
return score
|
| 271 |
+
|
| 272 |
+
def trim_text(value: str, limit: int) -> str:
|
| 273 |
+
text = " ".join(str(value or "").split())
|
| 274 |
+
if len(text) <= limit:
|
| 275 |
+
return text
|
| 276 |
+
return text[:limit].rsplit(" ", 1)[0].rstrip(".,;:") + "..."
|
| 277 |
+
|
| 278 |
+
def compact_search_results(data: dict | None, max_results_per_query: int, max_total_results: int) -> dict:
|
| 279 |
+
if not data:
|
| 280 |
+
return {}
|
| 281 |
+
|
| 282 |
+
rows = []
|
| 283 |
+
for query_index, (query, results) in enumerate(data.items()):
|
| 284 |
+
if not isinstance(results, list):
|
| 285 |
+
continue
|
| 286 |
+
for result_index, result in enumerate(results):
|
| 287 |
+
if not isinstance(result, dict):
|
| 288 |
+
continue
|
| 289 |
+
url = compact_url(str(result.get("url") or ""))
|
| 290 |
+
if not url:
|
| 291 |
+
continue
|
| 292 |
+
rows.append((
|
| 293 |
+
result_score(result),
|
| 294 |
+
query_index,
|
| 295 |
+
result_index,
|
| 296 |
+
query,
|
| 297 |
+
{
|
| 298 |
+
"title": trim_text(result.get("title", ""), 120),
|
| 299 |
+
"url": url,
|
| 300 |
+
"byline": trim_text(result.get("byline", ""), 120),
|
| 301 |
+
"followers": trim_text(result.get("followers", ""), 80),
|
| 302 |
+
"snippet": trim_text(result.get("snippet", ""), 260),
|
| 303 |
+
},
|
| 304 |
+
))
|
| 305 |
+
|
| 306 |
+
seen_urls = set()
|
| 307 |
+
compacted = {}
|
| 308 |
+
kept_total = 0
|
| 309 |
+
rows.sort(key=lambda row: (-row[0], row[1], row[2]))
|
| 310 |
+
|
| 311 |
+
for _score, _query_index, _result_index, query, result in rows:
|
| 312 |
+
if kept_total >= max_total_results:
|
| 313 |
+
break
|
| 314 |
+
if result["url"] in seen_urls:
|
| 315 |
+
continue
|
| 316 |
+
bucket = compacted.setdefault(query, [])
|
| 317 |
+
if len(bucket) >= max_results_per_query:
|
| 318 |
+
continue
|
| 319 |
+
bucket.append(result)
|
| 320 |
+
seen_urls.add(result["url"])
|
| 321 |
+
kept_total += 1
|
| 322 |
+
|
| 323 |
+
return {query: compacted[query] for query in data if query in compacted}
|
| 324 |
+
|
| 325 |
+
def format_search_results(data: dict | None, max_results_per_query: int = 4, max_chars: int = 9000) -> str:
|
| 326 |
+
"""Render combined_results.json into a compact, readable text block the model
|
| 327 |
+
can use as grounding evidence for the Decision Makers / Contact Prioritization
|
| 328 |
+
sections. Truncates defensively so a large results file can't blow out the
|
| 329 |
+
prompt budget."""
|
| 330 |
+
if not data:
|
| 331 |
+
return "=== PRE-FETCHED SEARCH RESULTS ===\n(None provided for this run — rely on your own web search.)\n=== END PRE-FETCHED SEARCH RESULTS ==="
|
| 332 |
+
|
| 333 |
+
data = compact_search_results(data, max_results_per_query=max_results_per_query, max_total_results=28)
|
| 334 |
+
|
| 335 |
+
lines = ["=== PRE-FETCHED SEARCH RESULTS (from prior decision-maker search queries) ===",
|
| 336 |
+
"These are raw Google results already collected for this target company. Mine them for real "
|
| 337 |
+
"names, titles, and LinkedIn URLs before doing additional searches.\n"]
|
| 338 |
+
|
| 339 |
+
for query, results in data.items():
|
| 340 |
+
lines.append(f"Query: {query}")
|
| 341 |
+
if not results:
|
| 342 |
+
lines.append(" (no results)")
|
| 343 |
+
lines.append("")
|
| 344 |
+
continue
|
| 345 |
+
for r in results[:max_results_per_query]:
|
| 346 |
+
title = (r.get("title") or "").strip()
|
| 347 |
+
url = (r.get("url") or "").strip()
|
| 348 |
+
byline = (r.get("byline") or "").strip()
|
| 349 |
+
snippet = (r.get("snippet") or "").strip()
|
| 350 |
+
lines.append(f" - {title}")
|
| 351 |
+
if url:
|
| 352 |
+
lines.append(f" URL: {url}")
|
| 353 |
+
if byline:
|
| 354 |
+
lines.append(f" {byline}")
|
| 355 |
+
if snippet:
|
| 356 |
+
lines.append(f" {snippet}")
|
| 357 |
+
lines.append("")
|
| 358 |
+
|
| 359 |
+
lines.append("=== END PRE-FETCHED SEARCH RESULTS ===")
|
| 360 |
+
|
| 361 |
+
text = "\n".join(lines)
|
| 362 |
+
if len(text) > max_chars:
|
| 363 |
+
text = text[:max_chars] + "\n... [truncated for length] ...\n=== END PRE-FETCHED SEARCH RESULTS ==="
|
| 364 |
+
return text
|
| 365 |
+
|
| 366 |
+
def research_company(company_name: str, company_md_path: str, search_results_path: str | None = None) -> str:
|
| 367 |
+
"""Research a target company using Gemini 2.5 Flash with Google Search grounding,
|
| 368 |
+
tailored using the seller's own context loaded fresh from company.md, and grounded
|
| 369 |
+
further (for the Decision Makers / Contact Prioritization sections) using any
|
| 370 |
+
pre-fetched search results from run_pipeline.py's combined_results.json."""
|
| 371 |
+
|
| 372 |
+
def research_company_claude_fallback(prompt: str) -> str:
|
| 373 |
+
import anthropic
|
| 374 |
+
api_key = os.getenv("ANTHROPIC_API_KEY", "").strip()
|
| 375 |
+
if not api_key:
|
| 376 |
+
raise ValueError("ANTHROPIC_API_KEY is not configured.")
|
| 377 |
+
client = anthropic.Anthropic(api_key=api_key)
|
| 378 |
+
|
| 379 |
+
system_prompt = "You are a world-class B2B sales intelligence researcher. Provide detailed, well-structured answers."
|
| 380 |
+
tools = [{"type": "web_search_20250305", "name": "web_search"}]
|
| 381 |
+
|
| 382 |
+
messages = [{"role": "user", "content": prompt}]
|
| 383 |
+
full_response = ""
|
| 384 |
+
continuation_count = 0
|
| 385 |
+
max_continuations = 5
|
| 386 |
+
|
| 387 |
+
while True:
|
| 388 |
+
response = client.messages.create(
|
| 389 |
+
model="claude-haiku-4-5",
|
| 390 |
+
max_tokens=4000,
|
| 391 |
+
system=system_prompt,
|
| 392 |
+
messages=messages,
|
| 393 |
+
tools=tools
|
| 394 |
+
)
|
| 395 |
+
|
| 396 |
+
for block in response.content:
|
| 397 |
+
if block.type == "text":
|
| 398 |
+
full_response += block.text
|
| 399 |
+
|
| 400 |
+
stop_reason = response.stop_reason
|
| 401 |
+
|
| 402 |
+
if stop_reason == "end_turn":
|
| 403 |
+
break
|
| 404 |
+
elif stop_reason == "max_tokens":
|
| 405 |
+
continuation_count += 1
|
| 406 |
+
if continuation_count >= max_continuations:
|
| 407 |
+
break
|
| 408 |
+
messages.append({"role": "assistant", "content": response.content})
|
| 409 |
+
messages.append({"role": "user", "content": "Your previous response was cut off. Please continue exactly from where you left off."})
|
| 410 |
+
elif stop_reason == "pause_turn":
|
| 411 |
+
continuation_count += 1
|
| 412 |
+
if continuation_count >= max_continuations:
|
| 413 |
+
break
|
| 414 |
+
messages.append({"role": "assistant", "content": response.content})
|
| 415 |
+
elif stop_reason == "refusal":
|
| 416 |
+
break
|
| 417 |
+
else:
|
| 418 |
+
break
|
| 419 |
+
|
| 420 |
+
return full_response
|
| 421 |
+
|
| 422 |
+
def research_company(company_name: str, company_md_path: str, search_results_path: str | None = None) -> str:
|
| 423 |
+
"""Research a target company using Gemini 2.5 Flash with Google Search grounding,
|
| 424 |
+
tailored using the seller's own context loaded fresh from company.md, and grounded
|
| 425 |
+
further (for the Decision Makers / Contact Prioritization sections) using any
|
| 426 |
+
pre-fetched search results from run_pipeline.py's combined_results.json."""
|
| 427 |
+
|
| 428 |
+
seller_context = load_seller_context(company_md_path)
|
| 429 |
+
search_results_data = load_search_results(search_results_path)
|
| 430 |
+
search_results_context = format_search_results(search_results_data)
|
| 431 |
+
|
| 432 |
+
print(f"\n🔍 Researching: {company_name}")
|
| 433 |
+
print(f"📄 Seller context loaded from: {company_md_path}")
|
| 434 |
+
if search_results_data:
|
| 435 |
+
total_queries = len(search_results_data)
|
| 436 |
+
total_hits = sum(len(v) for v in search_results_data.values())
|
| 437 |
+
print(f"🗂️ Pre-fetched search results loaded from: {search_results_path} "
|
| 438 |
+
f"({total_queries} queries, {total_hits} results)")
|
| 439 |
+
else:
|
| 440 |
+
print("🗂️ No pre-fetched search results loaded — relying on ProspectIQ AI's own web search.")
|
| 441 |
+
print("=" * 60)
|
| 442 |
+
print("Initializing ProspectIQ AI Engine with web search...\n")
|
| 443 |
+
|
| 444 |
+
google_search_tool = Tool(google_search=GoogleSearch())
|
| 445 |
+
|
| 446 |
+
prompt = RESEARCH_PROMPT.format(
|
| 447 |
+
company_name=company_name,
|
| 448 |
+
seller_context=seller_context,
|
| 449 |
+
search_results_context=search_results_context,
|
| 450 |
+
)
|
| 451 |
+
|
| 452 |
+
print("🌐 Running web searches and compiling profile...\n")
|
| 453 |
+
|
| 454 |
+
keys = get_all_paid_gemini_api_keys()
|
| 455 |
+
if not keys:
|
| 456 |
+
raise ValueError("Limit reached try after 5 minutes please contact admin")
|
| 457 |
+
|
| 458 |
+
last_err = None
|
| 459 |
+
for api_key in keys:
|
| 460 |
+
try:
|
| 461 |
+
client = genai.Client(api_key=api_key)
|
| 462 |
+
response = client.models.generate_content(
|
| 463 |
+
model="gemini-2.5-flash",
|
| 464 |
+
contents=prompt,
|
| 465 |
+
config=GenerateContentConfig(
|
| 466 |
+
tools=[google_search_tool],
|
| 467 |
+
temperature=0.2,
|
| 468 |
+
service_tier=ServiceTier.PRIORITY,
|
| 469 |
+
),
|
| 470 |
+
)
|
| 471 |
+
return response.text
|
| 472 |
+
except Exception as e:
|
| 473 |
+
last_err = e
|
| 474 |
+
continue
|
| 475 |
+
|
| 476 |
+
try:
|
| 477 |
+
return research_company_claude_fallback(prompt)
|
| 478 |
+
except Exception:
|
| 479 |
+
pass
|
| 480 |
+
|
| 481 |
+
raise RuntimeError("Limit reached try after 5 minutes please contact admin")
|
| 482 |
+
|
| 483 |
+
def save_report(company_name: str, content: str, output_format: str = "md"):
|
| 484 |
+
"""Save the research report to a file."""
|
| 485 |
+
safe_name = company_name.lower().replace(" ", "_").replace("/", "-")
|
| 486 |
+
filename = f"{safe_name}_profile.{output_format}"
|
| 487 |
+
output_path = os.path.join(os.getcwd(), filename)
|
| 488 |
+
|
| 489 |
+
with open(output_path, "w", encoding="utf-8") as f:
|
| 490 |
+
if output_format == "md":
|
| 491 |
+
f.write(f"# Company Research: {company_name}\n\n")
|
| 492 |
+
f.write(content)
|
| 493 |
+
|
| 494 |
+
return output_path
|
| 495 |
+
|
| 496 |
+
def main():
|
| 497 |
+
parser = argparse.ArgumentParser(
|
| 498 |
+
description="Research a target company using Gemini 2.5 Flash + Web Search, "
|
| 499 |
+
"tailored to your own business context in company.md",
|
| 500 |
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
| 501 |
+
epilog="""
|
| 502 |
+
Examples:
|
| 503 |
+
python company_research.py "Infosys"
|
| 504 |
+
python company_research.py "Wipro" --output txt
|
| 505 |
+
python company_research.py "TCS" --no-save
|
| 506 |
+
python company_research.py "Zoho" --company-md /path/to/company.md
|
| 507 |
+
python company_research.py "Acme Manufacturing" --search-results ./pipeline_output/combined_results.json
|
| 508 |
+
"""
|
| 509 |
+
)
|
| 510 |
+
parser.add_argument(
|
| 511 |
+
"company_name",
|
| 512 |
+
type=str,
|
| 513 |
+
help="Name of the TARGET company to research (your prospect)"
|
| 514 |
+
)
|
| 515 |
+
parser.add_argument(
|
| 516 |
+
"--output",
|
| 517 |
+
choices=["md", "txt"],
|
| 518 |
+
default="md",
|
| 519 |
+
help="Output file format (default: md)"
|
| 520 |
+
)
|
| 521 |
+
parser.add_argument(
|
| 522 |
+
"--no-save",
|
| 523 |
+
action="store_true",
|
| 524 |
+
help="Print to stdout only, don't save to file"
|
| 525 |
+
)
|
| 526 |
+
parser.add_argument(
|
| 527 |
+
"--company-md",
|
| 528 |
+
type=str,
|
| 529 |
+
default=DEFAULT_COMPANY_MD_PATH,
|
| 530 |
+
help=f"Path to your own company.md context file (default: {DEFAULT_COMPANY_MD_PATH}). "
|
| 531 |
+
f"Re-read fresh on every run, so edit it anytime before running."
|
| 532 |
+
)
|
| 533 |
+
parser.add_argument(
|
| 534 |
+
"--search-results",
|
| 535 |
+
type=str,
|
| 536 |
+
default=DEFAULT_SEARCH_RESULTS_PATH,
|
| 537 |
+
help=f"Path to a combined_results.json produced by run_pipeline.py "
|
| 538 |
+
f"(default: {DEFAULT_SEARCH_RESULTS_PATH}). Used as grounding evidence for the "
|
| 539 |
+
f"Decision Makers / Contact Prioritization sections. Missing file is fine — "
|
| 540 |
+
f"the script falls back to Gemini's own web search."
|
| 541 |
+
)
|
| 542 |
+
parser.add_argument(
|
| 543 |
+
"--no-search-results",
|
| 544 |
+
action="store_true",
|
| 545 |
+
help="Ignore any combined_results.json and rely entirely on Gemini's own web search."
|
| 546 |
+
)
|
| 547 |
+
|
| 548 |
+
args = parser.parse_args()
|
| 549 |
+
|
| 550 |
+
try:
|
| 551 |
+
search_results_path = None if args.no_search_results else args.search_results
|
| 552 |
+
result = research_company(args.company_name, args.company_md, search_results_path)
|
| 553 |
+
|
| 554 |
+
print(result)
|
| 555 |
+
print("\n" + "=" * 60)
|
| 556 |
+
|
| 557 |
+
if not args.no_save:
|
| 558 |
+
saved_path = save_report(args.company_name, result, args.output)
|
| 559 |
+
print(f"\n✅ Report saved to: {saved_path}")
|
| 560 |
+
|
| 561 |
+
except Exception as e:
|
| 562 |
+
print(f"\n❌ Error: {e}")
|
| 563 |
+
if "api_key" in str(e).lower() or "api key" in str(e).lower():
|
| 564 |
+
print("Check that your GEMINI_API_KEY_prime_1/2/3 values are valid in the .env file.")
|
| 565 |
+
sys.exit(1)
|
| 566 |
+
|
| 567 |
+
if __name__ == "__main__":
|
| 568 |
+
main()
|
services/research/browser.py
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Google Search via Selenium + ChromeDriver.
|
| 3 |
+
|
| 4 |
+
Usage:
|
| 5 |
+
python browser.py "spaceship titanic kaggle"
|
| 6 |
+
python browser.py --headless "pytorch tutorial"
|
| 7 |
+
python browser.py
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import argparse
|
| 11 |
+
import os
|
| 12 |
+
import shutil
|
| 13 |
+
import sys
|
| 14 |
+
import time
|
| 15 |
+
import threading
|
| 16 |
+
import tempfile
|
| 17 |
+
from urllib.parse import parse_qs, quote_plus, urlparse
|
| 18 |
+
|
| 19 |
+
from selenium import webdriver
|
| 20 |
+
from selenium.webdriver.chrome.options import Options
|
| 21 |
+
from selenium.webdriver.chrome.service import Service
|
| 22 |
+
from selenium.webdriver.common.by import By
|
| 23 |
+
from selenium.webdriver.support import expected_conditions as EC
|
| 24 |
+
from selenium.webdriver.support.ui import WebDriverWait
|
| 25 |
+
from webdriver_manager.chrome import ChromeDriverManager
|
| 26 |
+
|
| 27 |
+
GOOGLE_URL = "https://www.google.com"
|
| 28 |
+
_CHROMEDRIVER_PATH = ""
|
| 29 |
+
_DRIVER_PATH_LOCK = threading.Lock()
|
| 30 |
+
|
| 31 |
+
if hasattr(sys.stdout, "reconfigure"):
|
| 32 |
+
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
| 33 |
+
|
| 34 |
+
def normalize_google_url(url: str) -> str:
|
| 35 |
+
"""Return the destination URL for Google redirect links."""
|
| 36 |
+
parsed = urlparse(url)
|
| 37 |
+
if parsed.netloc.endswith("google.com") and parsed.path == "/url":
|
| 38 |
+
target = parse_qs(parsed.query).get("q", [""])[0]
|
| 39 |
+
if target:
|
| 40 |
+
return target
|
| 41 |
+
return url
|
| 42 |
+
|
| 43 |
+
def is_search_result_url(url: str) -> bool:
|
| 44 |
+
if not url:
|
| 45 |
+
return False
|
| 46 |
+
|
| 47 |
+
parsed = urlparse(url)
|
| 48 |
+
if parsed.scheme not in {"http", "https"}:
|
| 49 |
+
return False
|
| 50 |
+
|
| 51 |
+
blocked_hosts = {
|
| 52 |
+
"accounts.google.com",
|
| 53 |
+
"maps.google.com",
|
| 54 |
+
"policies.google.com",
|
| 55 |
+
"support.google.com",
|
| 56 |
+
"www.google.com",
|
| 57 |
+
}
|
| 58 |
+
return parsed.netloc.lower() not in blocked_hosts
|
| 59 |
+
|
| 60 |
+
def build_driver(headless: bool = False) -> webdriver.Chrome:
|
| 61 |
+
options = Options()
|
| 62 |
+
options.page_load_strategy = "eager"
|
| 63 |
+
chrome_bin = os.environ.get("CHROME_BIN") or os.environ.get("GOOGLE_CHROME_BIN")
|
| 64 |
+
if chrome_bin:
|
| 65 |
+
options.binary_location = chrome_bin
|
| 66 |
+
|
| 67 |
+
if headless:
|
| 68 |
+
options.add_argument("--headless=new")
|
| 69 |
+
options.add_argument("--window-size=1920,1080")
|
| 70 |
+
|
| 71 |
+
options.add_argument("--no-sandbox")
|
| 72 |
+
options.add_argument("--disable-dev-shm-usage")
|
| 73 |
+
options.add_argument("--disable-gpu")
|
| 74 |
+
options.add_argument("--disable-extensions")
|
| 75 |
+
options.add_argument("--disable-background-networking")
|
| 76 |
+
options.add_argument("--disable-default-apps")
|
| 77 |
+
options.add_argument("--disable-sync")
|
| 78 |
+
options.add_argument("--metrics-recording-only")
|
| 79 |
+
options.add_argument("--mute-audio")
|
| 80 |
+
options.add_argument("--no-first-run")
|
| 81 |
+
options.add_argument("--remote-debugging-port=0")
|
| 82 |
+
options.add_argument("--disable-blink-features=AutomationControlled")
|
| 83 |
+
user_data_dir = tempfile.mkdtemp(prefix="prospectiq-chrome-")
|
| 84 |
+
options.add_argument(f"--user-data-dir={user_data_dir}")
|
| 85 |
+
options.add_experimental_option("excludeSwitches", ["enable-automation"])
|
| 86 |
+
options.add_experimental_option("useAutomationExtension", False)
|
| 87 |
+
options.add_argument(
|
| 88 |
+
"user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
| 89 |
+
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
| 90 |
+
"Chrome/124.0.0.0 Safari/537.36"
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
chromedriver_path = os.environ.get("CHROMEDRIVER_PATH") or get_chromedriver_path()
|
| 94 |
+
service = Service(chromedriver_path)
|
| 95 |
+
try:
|
| 96 |
+
driver = webdriver.Chrome(service=service, options=options)
|
| 97 |
+
driver._prospectiq_user_data_dir = user_data_dir
|
| 98 |
+
driver.execute_script(
|
| 99 |
+
"Object.defineProperty(navigator, 'webdriver', {get: () => undefined})"
|
| 100 |
+
)
|
| 101 |
+
return driver
|
| 102 |
+
except Exception:
|
| 103 |
+
shutil.rmtree(user_data_dir, ignore_errors=True)
|
| 104 |
+
raise
|
| 105 |
+
|
| 106 |
+
def close_driver(driver: webdriver.Chrome | None) -> None:
|
| 107 |
+
if driver is None:
|
| 108 |
+
return
|
| 109 |
+
user_data_dir = getattr(driver, "_prospectiq_user_data_dir", "")
|
| 110 |
+
try:
|
| 111 |
+
driver.quit()
|
| 112 |
+
finally:
|
| 113 |
+
if user_data_dir:
|
| 114 |
+
shutil.rmtree(user_data_dir, ignore_errors=True)
|
| 115 |
+
|
| 116 |
+
def get_chromedriver_path() -> str:
|
| 117 |
+
global _CHROMEDRIVER_PATH
|
| 118 |
+
if _CHROMEDRIVER_PATH:
|
| 119 |
+
return _CHROMEDRIVER_PATH
|
| 120 |
+
with _DRIVER_PATH_LOCK:
|
| 121 |
+
if not _CHROMEDRIVER_PATH:
|
| 122 |
+
_CHROMEDRIVER_PATH = ChromeDriverManager().install()
|
| 123 |
+
return _CHROMEDRIVER_PATH
|
| 124 |
+
|
| 125 |
+
def accept_consent(driver: webdriver.Chrome, wait: WebDriverWait) -> None:
|
| 126 |
+
"""Dismiss Google's cookie/consent banner if present."""
|
| 127 |
+
try:
|
| 128 |
+
btn = wait.until(EC.element_to_be_clickable(
|
| 129 |
+
(By.XPATH, "//button[.//div[contains(text(),'Accept all')] or contains(.,'I agree')]")
|
| 130 |
+
))
|
| 131 |
+
btn.click()
|
| 132 |
+
time.sleep(0.5)
|
| 133 |
+
except Exception:
|
| 134 |
+
pass
|
| 135 |
+
|
| 136 |
+
def google_search(driver: webdriver.Chrome, query: str, limit: int = 3) -> list[dict]:
|
| 137 |
+
"""Open Google, run the query, return up to `limit` organic results."""
|
| 138 |
+
driver.get(f"{GOOGLE_URL}/search?q={quote_plus(query)}&num={max(limit, 3)}")
|
| 139 |
+
print(f"\n [browser] Opened: {driver.current_url}")
|
| 140 |
+
|
| 141 |
+
wait = WebDriverWait(driver, 8)
|
| 142 |
+
accept_consent(driver, wait)
|
| 143 |
+
|
| 144 |
+
wait.until(EC.url_contains("google.com/search"))
|
| 145 |
+
print(f" [browser] Results page: {driver.current_url}")
|
| 146 |
+
|
| 147 |
+
wait.until(EC.presence_of_element_located((By.ID, "search")))
|
| 148 |
+
time.sleep(0.25)
|
| 149 |
+
|
| 150 |
+
results = []
|
| 151 |
+
seen_urls = set()
|
| 152 |
+
|
| 153 |
+
links = driver.find_elements(By.XPATH, "//a[@href][.//h3]")
|
| 154 |
+
for link in links:
|
| 155 |
+
if len(results) >= limit:
|
| 156 |
+
break
|
| 157 |
+
try:
|
| 158 |
+
title = link.find_element(By.XPATH, ".//h3").text.strip()
|
| 159 |
+
url = normalize_google_url(link.get_attribute("href"))
|
| 160 |
+
|
| 161 |
+
if not title or not is_search_result_url(url) or url in seen_urls:
|
| 162 |
+
continue
|
| 163 |
+
|
| 164 |
+
ancestor_divs = link.find_elements(By.XPATH, "./ancestor::div")
|
| 165 |
+
ancestor_divs.reverse()
|
| 166 |
+
|
| 167 |
+
container = link
|
| 168 |
+
for div in ancestor_divs:
|
| 169 |
+
h3_count = len(div.find_elements(By.TAG_NAME, "h3"))
|
| 170 |
+
if h3_count == 0:
|
| 171 |
+
continue
|
| 172 |
+
if h3_count == 1:
|
| 173 |
+
container = div
|
| 174 |
+
else:
|
| 175 |
+
break
|
| 176 |
+
|
| 177 |
+
raw_text = container.text.strip()
|
| 178 |
+
all_lines = []
|
| 179 |
+
for line in raw_text.splitlines():
|
| 180 |
+
line = line.strip()
|
| 181 |
+
if line and line != title and line not in all_lines:
|
| 182 |
+
all_lines.append(line)
|
| 183 |
+
|
| 184 |
+
byline = ""
|
| 185 |
+
followers = ""
|
| 186 |
+
description_lines = []
|
| 187 |
+
for line in all_lines:
|
| 188 |
+
if not byline and ("linkedin" in line.lower() or "·" in line or "·" in line):
|
| 189 |
+
byline = line
|
| 190 |
+
continue
|
| 191 |
+
if not followers and "follower" in line.lower():
|
| 192 |
+
followers = line
|
| 193 |
+
continue
|
| 194 |
+
description_lines.append(line)
|
| 195 |
+
|
| 196 |
+
snippet = " ".join(description_lines).replace("Read more", "").strip()
|
| 197 |
+
results.append({
|
| 198 |
+
"title": title,
|
| 199 |
+
"url": url,
|
| 200 |
+
"byline": byline,
|
| 201 |
+
"followers": followers,
|
| 202 |
+
"snippet": snippet,
|
| 203 |
+
"raw_lines": all_lines,
|
| 204 |
+
})
|
| 205 |
+
seen_urls.add(url)
|
| 206 |
+
except Exception:
|
| 207 |
+
continue
|
| 208 |
+
|
| 209 |
+
return results
|
| 210 |
+
|
| 211 |
+
def print_results(query: str, results: list[dict]) -> None:
|
| 212 |
+
sep = "=" * 70
|
| 213 |
+
print(f"\n{sep}")
|
| 214 |
+
print(f" Google results for: \"{query}\"")
|
| 215 |
+
print(f"{sep}\n")
|
| 216 |
+
if not results:
|
| 217 |
+
print(" No results parsed - Google may have changed its HTML structure.")
|
| 218 |
+
return
|
| 219 |
+
|
| 220 |
+
def wrap(text: str, width: int = 66, indent: str = " ") -> None:
|
| 221 |
+
words, line = text.split(), []
|
| 222 |
+
for word in words:
|
| 223 |
+
if sum(len(item) + 1 for item in line) + len(word) > width:
|
| 224 |
+
print(indent + " ".join(line))
|
| 225 |
+
line = [word]
|
| 226 |
+
else:
|
| 227 |
+
line.append(word)
|
| 228 |
+
if line:
|
| 229 |
+
print(indent + " ".join(line))
|
| 230 |
+
|
| 231 |
+
for index, result in enumerate(results, 1):
|
| 232 |
+
print(f" [{index}] {result['title']}")
|
| 233 |
+
print(f" URL: {result['url']}")
|
| 234 |
+
if result.get("byline"):
|
| 235 |
+
print(f" {result['byline']}")
|
| 236 |
+
if result.get("followers"):
|
| 237 |
+
print(f" {result['followers']}")
|
| 238 |
+
if result.get("snippet"):
|
| 239 |
+
print()
|
| 240 |
+
wrap(result["snippet"])
|
| 241 |
+
print(f"\n {'-' * 66}\n")
|
| 242 |
+
|
| 243 |
+
def interactive_loop(driver: webdriver.Chrome) -> None:
|
| 244 |
+
print("\n Google Search - type 'exit' to quit\n")
|
| 245 |
+
while True:
|
| 246 |
+
try:
|
| 247 |
+
query = input(" Query: ").strip()
|
| 248 |
+
except (EOFError, KeyboardInterrupt):
|
| 249 |
+
break
|
| 250 |
+
if not query:
|
| 251 |
+
continue
|
| 252 |
+
if query.lower() in {"exit", "quit", "q"}:
|
| 253 |
+
break
|
| 254 |
+
results = google_search(driver, query)
|
| 255 |
+
print_results(query, results)
|
| 256 |
+
|
| 257 |
+
def main() -> None:
|
| 258 |
+
parser = argparse.ArgumentParser(description="Search Google via Selenium")
|
| 259 |
+
parser.add_argument("query", nargs="?", help="Search query (omit for interactive mode)")
|
| 260 |
+
parser.add_argument("--headless", action="store_true", help="Run without a visible browser window")
|
| 261 |
+
parser.add_argument("--limit", type=int, default=3, help="Max results (default: 3)")
|
| 262 |
+
args = parser.parse_args()
|
| 263 |
+
|
| 264 |
+
driver = build_driver(headless=args.headless)
|
| 265 |
+
try:
|
| 266 |
+
if args.query:
|
| 267 |
+
results = google_search(driver, args.query, limit=args.limit)
|
| 268 |
+
print_results(args.query, results)
|
| 269 |
+
else:
|
| 270 |
+
interactive_loop(driver)
|
| 271 |
+
finally:
|
| 272 |
+
close_driver(driver)
|
| 273 |
+
|
| 274 |
+
if __name__ == "__main__":
|
| 275 |
+
main()
|
services/research/company.md
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Company Knowledge Base - Drinking Water Can Supply Business
|
| 2 |
+
|
| 3 |
+
# About Us
|
| 4 |
+
|
| 5 |
+
We are an India-based B2B drinking water supply company specializing in the manufacturing and distribution of high-quality packaged drinking water in reusable 20-litre water cans. Our primary focus is supplying businesses and organizations that require a continuous, reliable, and hygienic source of drinking water for their employees, workers, visitors, and guests.
|
| 6 |
+
|
| 7 |
+
Unlike retail bottled water brands, we operate on a recurring business model where customers receive scheduled deliveries of water cans based on their daily or weekly consumption.
|
| 8 |
+
|
| 9 |
+
Our objective is to become the preferred drinking water supplier for organizations with medium to large workforce requirements.
|
| 10 |
+
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
# What We Do
|
| 14 |
+
|
| 15 |
+
We manufacture purified drinking water that complies with Indian drinking water quality standards.
|
| 16 |
+
|
| 17 |
+
We supply:
|
| 18 |
+
|
| 19 |
+
* 20L reusable drinking water cans
|
| 20 |
+
* Bulk drinking water supply
|
| 21 |
+
* Scheduled recurring deliveries
|
| 22 |
+
* Emergency water deliveries
|
| 23 |
+
* Long-term corporate supply contracts
|
| 24 |
+
* Multi-location water supply solutions
|
| 25 |
+
|
| 26 |
+
We ensure consistent quality, timely deliveries, and uninterrupted supply.
|
| 27 |
+
|
services/research/pipeline.py
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
full_pipeline.py
|
| 4 |
+
|
| 5 |
+
ONE command that runs the entire chain:
|
| 6 |
+
|
| 7 |
+
generate_search_queries.py --> browser_search.py --> company_research.py
|
| 8 |
+
|
| 9 |
+
1. Gemini generates N pinpoint decision-maker search queries for the client company.
|
| 10 |
+
2. Every query is run through headless Google search (Selenium), with a randomized
|
| 11 |
+
delay between searches.
|
| 12 |
+
3. The combined search results are fed straight into Gemini (company_research.py's
|
| 13 |
+
research_company()) as grounding evidence for the Decision Makers / Contact
|
| 14 |
+
Prioritization sections of the profile.
|
| 15 |
+
4. The final profile is saved to disk.
|
| 16 |
+
|
| 17 |
+
Everything lands in one output folder -- no manual copy-pasting of paths between
|
| 18 |
+
steps, no second command to run.
|
| 19 |
+
|
| 20 |
+
Usage:
|
| 21 |
+
python full_pipeline.py \
|
| 22 |
+
--company company.md \
|
| 23 |
+
--client-company "Prestige Constructions India" \
|
| 24 |
+
--questions questionlist.md \
|
| 25 |
+
--out-dir ./pipeline_output
|
| 26 |
+
|
| 27 |
+
That single command replaces:
|
| 28 |
+
python run_pipeline.py --company company.md --client-company "..." --questions questionlist.md --out-dir ./pipeline_output
|
| 29 |
+
python company_research.py "..." --search-results ./pipeline_output/combined_results.json
|
| 30 |
+
|
| 31 |
+
Requires generate_search_queries.py, browser_search.py, and company_research.py to
|
| 32 |
+
sit next to this script (or be on PYTHONPATH). Same dependencies as before:
|
| 33 |
+
pip install google-genai selenium webdriver-manager python-dotenv --break-system-packages
|
| 34 |
+
Plus GEMINI_API_KEY_prime_1, GEMINI_API_KEY_prime_2, or GEMINI_API_KEY_prime_3
|
| 35 |
+
available via env vars or a .env file (used for both the query-generation step
|
| 36 |
+
and the research step).
|
| 37 |
+
"""
|
| 38 |
+
|
| 39 |
+
import sys
|
| 40 |
+
import time
|
| 41 |
+
import random
|
| 42 |
+
import argparse
|
| 43 |
+
import re
|
| 44 |
+
import json
|
| 45 |
+
import concurrent.futures
|
| 46 |
+
import os
|
| 47 |
+
from pathlib import Path
|
| 48 |
+
|
| 49 |
+
import query_generator as qgen
|
| 50 |
+
import browser as bsearch
|
| 51 |
+
import analyzer as crsrch
|
| 52 |
+
|
| 53 |
+
def elapsed(start: float) -> str:
|
| 54 |
+
seconds = time.perf_counter() - start
|
| 55 |
+
return f"{seconds:.1f}s"
|
| 56 |
+
|
| 57 |
+
# --------------------------------------------------------------------------- #
|
| 58 |
+
# Step 1: Gemini query generation
|
| 59 |
+
# --------------------------------------------------------------------------- #
|
| 60 |
+
|
| 61 |
+
def generate_queries(args) -> dict:
|
| 62 |
+
company_md = qgen.read_file(args.company)
|
| 63 |
+
questionlist_md = qgen.read_file(args.questions)
|
| 64 |
+
sections = qgen.parse_questionlist(questionlist_md)
|
| 65 |
+
|
| 66 |
+
system_prompt = qgen.build_system_prompt(args.num_queries)
|
| 67 |
+
user_prompt = qgen.build_user_prompt(company_md, args.client_company, questionlist_md, sections)
|
| 68 |
+
|
| 69 |
+
print(f"[1/4] Calling ProspectIQ AI Engine to generate {args.num_queries} queries...", file=sys.stderr)
|
| 70 |
+
raw = qgen.call_gemini(system_prompt, user_prompt)
|
| 71 |
+
|
| 72 |
+
data = qgen.extract_json(raw)
|
| 73 |
+
qgen.validate_queries(data, args.num_queries)
|
| 74 |
+
return data
|
| 75 |
+
|
| 76 |
+
# --------------------------------------------------------------------------- #
|
| 77 |
+
# Step 2: headless browser search
|
| 78 |
+
# --------------------------------------------------------------------------- #
|
| 79 |
+
|
| 80 |
+
def slugify(text: str, max_len: int = 60) -> str:
|
| 81 |
+
text = text.lower().strip()
|
| 82 |
+
text = re.sub(r"[^a-z0-9]+", "_", text)
|
| 83 |
+
text = re.sub(r"_+", "_", text).strip("_")
|
| 84 |
+
return text[:max_len] or "query"
|
| 85 |
+
|
| 86 |
+
def run_single_browser_search(index: int, total: int, query: str, results_per_query: int) -> tuple[int, str, list, str]:
|
| 87 |
+
driver = None
|
| 88 |
+
try:
|
| 89 |
+
driver = bsearch.build_driver(headless=True)
|
| 90 |
+
print(f" -> ({index}/{total}) {query}", file=sys.stderr)
|
| 91 |
+
results = bsearch.google_search(driver, query, limit=results_per_query)
|
| 92 |
+
return index, query, results, ""
|
| 93 |
+
except Exception as exc:
|
| 94 |
+
return index, query, [], str(exc)
|
| 95 |
+
finally:
|
| 96 |
+
bsearch.close_driver(driver)
|
| 97 |
+
|
| 98 |
+
def run_browser_searches(queries: list, out_dir: Path, results_per_query: int,
|
| 99 |
+
min_delay: float, max_delay: float, parallel_searches: int = 1) -> tuple[dict, dict]:
|
| 100 |
+
results_dir = out_dir / "results"
|
| 101 |
+
results_dir.mkdir(parents=True, exist_ok=True)
|
| 102 |
+
|
| 103 |
+
combined = {}
|
| 104 |
+
errors = {}
|
| 105 |
+
|
| 106 |
+
total = len(queries)
|
| 107 |
+
parallel_searches = max(1, min(int(parallel_searches or 1), total or 1))
|
| 108 |
+
if parallel_searches == 1:
|
| 109 |
+
print("[2/4] Launching headless Chrome...", file=sys.stderr)
|
| 110 |
+
driver = None
|
| 111 |
+
try:
|
| 112 |
+
driver = bsearch.build_driver(headless=True)
|
| 113 |
+
for i, query in enumerate(queries, start=1):
|
| 114 |
+
print(f" -> ({i}/{total}) {query}", file=sys.stderr)
|
| 115 |
+
try:
|
| 116 |
+
results = bsearch.google_search(driver, query, limit=results_per_query)
|
| 117 |
+
combined[query] = results
|
| 118 |
+
|
| 119 |
+
fname = f"{i:02d}_{slugify(query)}.json"
|
| 120 |
+
(results_dir / fname).write_text(
|
| 121 |
+
json.dumps({"query": query, "results": results}, indent=2),
|
| 122 |
+
encoding="utf-8",
|
| 123 |
+
)
|
| 124 |
+
except Exception as e:
|
| 125 |
+
print(f" ! failed: {e}", file=sys.stderr)
|
| 126 |
+
errors[query] = str(e)
|
| 127 |
+
combined[query] = []
|
| 128 |
+
|
| 129 |
+
if i < total and max_delay > 0:
|
| 130 |
+
time.sleep(random.uniform(max(0, min_delay), max_delay))
|
| 131 |
+
except Exception as e:
|
| 132 |
+
print(f" ! browser startup failed: {e}", file=sys.stderr)
|
| 133 |
+
for query in queries:
|
| 134 |
+
errors[query] = str(e)
|
| 135 |
+
combined[query] = []
|
| 136 |
+
finally:
|
| 137 |
+
bsearch.close_driver(driver)
|
| 138 |
+
return combined, errors
|
| 139 |
+
|
| 140 |
+
print(f"[2/4] Launching {parallel_searches} parallel headless Chrome workers...", file=sys.stderr)
|
| 141 |
+
indexed_results: dict[int, tuple[str, list, str]] = {}
|
| 142 |
+
with concurrent.futures.ThreadPoolExecutor(max_workers=parallel_searches) as executor:
|
| 143 |
+
futures = {
|
| 144 |
+
executor.submit(run_single_browser_search, i, total, query, results_per_query): (i, query)
|
| 145 |
+
for i, query in enumerate(queries, start=1)
|
| 146 |
+
}
|
| 147 |
+
for future in concurrent.futures.as_completed(futures):
|
| 148 |
+
fallback_i, fallback_query = futures[future]
|
| 149 |
+
try:
|
| 150 |
+
i, query, results, error = future.result()
|
| 151 |
+
except Exception as exc:
|
| 152 |
+
i, query, results, error = fallback_i, fallback_query, [], str(exc)
|
| 153 |
+
indexed_results[i] = (query, results, error)
|
| 154 |
+
if error:
|
| 155 |
+
print(f" ! ({i}/{total}) failed: {error}", file=sys.stderr)
|
| 156 |
+
|
| 157 |
+
for i in sorted(indexed_results):
|
| 158 |
+
query, results, error = indexed_results[i]
|
| 159 |
+
combined[query] = results
|
| 160 |
+
if error:
|
| 161 |
+
errors[query] = error
|
| 162 |
+
fname = f"{i:02d}_{slugify(query)}.json"
|
| 163 |
+
(results_dir / fname).write_text(
|
| 164 |
+
json.dumps({"query": query, "results": results}, indent=2),
|
| 165 |
+
encoding="utf-8",
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
return combined, errors
|
| 169 |
+
|
| 170 |
+
def render_combined_markdown(combined: dict) -> str:
|
| 171 |
+
lines = ["# Combined Search Results\n"]
|
| 172 |
+
for query, results in combined.items():
|
| 173 |
+
lines.append(f"## {query}\n")
|
| 174 |
+
if not results:
|
| 175 |
+
lines.append("_No results parsed._\n")
|
| 176 |
+
continue
|
| 177 |
+
for i, r in enumerate(results, start=1):
|
| 178 |
+
lines.append(f"{i}. **{r.get('title', '')}**")
|
| 179 |
+
lines.append(f" - URL: {r.get('url', '')}")
|
| 180 |
+
if r.get("byline"):
|
| 181 |
+
lines.append(f" - {r['byline']}")
|
| 182 |
+
if r.get("followers"):
|
| 183 |
+
lines.append(f" - {r['followers']}")
|
| 184 |
+
if r.get("snippet"):
|
| 185 |
+
lines.append(f" - {r['snippet']}")
|
| 186 |
+
lines.append("")
|
| 187 |
+
return "\n".join(lines)
|
| 188 |
+
|
| 189 |
+
# --------------------------------------------------------------------------- #
|
| 190 |
+
# Step 3: Gemini research, grounded with the combined results
|
| 191 |
+
# --------------------------------------------------------------------------- #
|
| 192 |
+
|
| 193 |
+
def run_research(client_company: str, company_md_path: str, combined_results_path: Path) -> str:
|
| 194 |
+
print("[3/4] Calling ProspectIQ AI Engine to compile the profile...", file=sys.stderr)
|
| 195 |
+
return crsrch.research_company(
|
| 196 |
+
company_name=client_company,
|
| 197 |
+
company_md_path=company_md_path,
|
| 198 |
+
search_results_path=str(combined_results_path),
|
| 199 |
+
)
|
| 200 |
+
|
| 201 |
+
# --------------------------------------------------------------------------- #
|
| 202 |
+
# Main
|
| 203 |
+
# --------------------------------------------------------------------------- #
|
| 204 |
+
|
| 205 |
+
def main():
|
| 206 |
+
parser = argparse.ArgumentParser(
|
| 207 |
+
description="One-shot pipeline: generate search queries -> run them headlessly -> "
|
| 208 |
+
"feed results into Gemini to produce a sales intelligence profile."
|
| 209 |
+
)
|
| 210 |
+
# query generation
|
| 211 |
+
parser.add_argument("--company", default="company.md", help="Path to your seller/vendor profile markdown")
|
| 212 |
+
parser.add_argument("--client-company", required=True, help="Exact client/target company name")
|
| 213 |
+
parser.add_argument("--questions", default="questionlist.md", help="Path to fixed question schema markdown")
|
| 214 |
+
parser.add_argument("--num-queries", type=int, default=8, help="Number of search queries to generate (default: 8)")
|
| 215 |
+
|
| 216 |
+
# browser search
|
| 217 |
+
parser.add_argument("--results-per-query", type=int, default=4, help="Max Google results to keep per query")
|
| 218 |
+
parser.add_argument("--min-delay", type=float, default=3.0, help="Min seconds between searches")
|
| 219 |
+
parser.add_argument("--max-delay", type=float, default=7.0, help="Max seconds between searches")
|
| 220 |
+
parser.add_argument(
|
| 221 |
+
"--parallel-searches",
|
| 222 |
+
type=int,
|
| 223 |
+
default=int(os.environ.get("COMPANY_EXTRACTOR_PARALLEL_SEARCHES", "6")),
|
| 224 |
+
help="Number of headless browser searches to run at once",
|
| 225 |
+
)
|
| 226 |
+
|
| 227 |
+
# output / research
|
| 228 |
+
parser.add_argument("--out-dir", default="pipeline_output", help="Folder to store everything, including the final profile")
|
| 229 |
+
parser.add_argument("--report-format", choices=["md", "txt"], default="md", help="Final profile file format (default: md)")
|
| 230 |
+
|
| 231 |
+
args = parser.parse_args()
|
| 232 |
+
|
| 233 |
+
out_dir = Path(args.out_dir)
|
| 234 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 235 |
+
|
| 236 |
+
# Step 1
|
| 237 |
+
step_start = time.perf_counter()
|
| 238 |
+
data = generate_queries(args)
|
| 239 |
+
print(f" Query generation completed in {elapsed(step_start)}", file=sys.stderr)
|
| 240 |
+
queries = data["queries"]
|
| 241 |
+
(out_dir / "queries.json").write_text(json.dumps(data, indent=2), encoding="utf-8")
|
| 242 |
+
(out_dir / "queries.md").write_text(qgen.render_markdown_report(data), encoding="utf-8")
|
| 243 |
+
|
| 244 |
+
# Step 2
|
| 245 |
+
step_start = time.perf_counter()
|
| 246 |
+
combined, errors = run_browser_searches(
|
| 247 |
+
queries, out_dir,
|
| 248 |
+
results_per_query=args.results_per_query,
|
| 249 |
+
min_delay=args.min_delay,
|
| 250 |
+
max_delay=args.max_delay,
|
| 251 |
+
parallel_searches=args.parallel_searches,
|
| 252 |
+
)
|
| 253 |
+
print(f" Browser evidence collection completed in {elapsed(step_start)}", file=sys.stderr)
|
| 254 |
+
combined_results_path = out_dir / "combined_results.json"
|
| 255 |
+
combined_results_path.write_text(json.dumps(combined, indent=2), encoding="utf-8")
|
| 256 |
+
(out_dir / "combined_results.md").write_text(render_combined_markdown(combined), encoding="utf-8")
|
| 257 |
+
if errors:
|
| 258 |
+
(out_dir / "errors.json").write_text(json.dumps(errors, indent=2), encoding="utf-8")
|
| 259 |
+
print(f" {len(errors)} quer{'y' if len(errors) == 1 else 'ies'} failed -> {out_dir / 'errors.json'}",
|
| 260 |
+
file=sys.stderr)
|
| 261 |
+
|
| 262 |
+
# Step 3
|
| 263 |
+
try:
|
| 264 |
+
step_start = time.perf_counter()
|
| 265 |
+
profile_text = run_research(args.client_company, args.company, combined_results_path)
|
| 266 |
+
print(f" ProspectIQ AI profile synthesis completed in {elapsed(step_start)}", file=sys.stderr)
|
| 267 |
+
except Exception as e:
|
| 268 |
+
print(f"\n❌ ProspectIQ AI research step failed: {e}", file=sys.stderr)
|
| 269 |
+
sys.exit(1)
|
| 270 |
+
|
| 271 |
+
# Step 4: save the final profile into the same out-dir
|
| 272 |
+
print("[4/4] Saving final profile...", file=sys.stderr)
|
| 273 |
+
safe_name = args.client_company.lower().replace(" ", "_").replace("/", "-")
|
| 274 |
+
report_path = out_dir / f"{safe_name}_profile.{args.report_format}"
|
| 275 |
+
with open(report_path, "w", encoding="utf-8") as f:
|
| 276 |
+
if args.report_format == "md":
|
| 277 |
+
f.write(f"# Company Research: {args.client_company}\n\n")
|
| 278 |
+
f.write(profile_text)
|
| 279 |
+
|
| 280 |
+
print(f"\n✅ Done. Everything is in: {out_dir.resolve()}")
|
| 281 |
+
print(f" Queries : {out_dir / 'queries.json'}")
|
| 282 |
+
print(f" Combined results : {combined_results_path}")
|
| 283 |
+
print(f" Final profile : {report_path}")
|
| 284 |
+
|
| 285 |
+
if __name__ == "__main__":
|
| 286 |
+
main()
|
services/research/query_generator.py
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
generate_search_queries.py
|
| 4 |
+
|
| 5 |
+
Reads a vendor knowledge-base file (company.md, content varies per run), the
|
| 6 |
+
name of a CLIENT COMPANY you actually want decision-maker contacts at, and a
|
| 7 |
+
FIXED question schema (questionlist.md). Uses Gemini 2.5 Flash to emit exactly
|
| 8 |
+
N (default 10) ready-to-paste-into-Google queries, targeted at that specific
|
| 9 |
+
client company, which collectively are enough to answer every field in
|
| 10 |
+
questionlist.md (decision-maker identification + contact prioritization) --
|
| 11 |
+
informed by the vendor's product/ICP so the right job titles/departments get
|
| 12 |
+
targeted.
|
| 13 |
+
|
| 14 |
+
No reasoning or persona justification is included in the output -- just the
|
| 15 |
+
queries themselves, since these are meant to be run directly as web searches.
|
| 16 |
+
|
| 17 |
+
company.md is expected to change between runs (different vendor businesses).
|
| 18 |
+
questionlist.md is expected to stay fixed (same data schema every time), but
|
| 19 |
+
the parser does not hard-code field names, so it still works if the schema
|
| 20 |
+
gains/loses fields later.
|
| 21 |
+
|
| 22 |
+
Usage:
|
| 23 |
+
python generate_search_queries.py \
|
| 24 |
+
--company company.md \
|
| 25 |
+
--client-company "Acme Manufacturing Pvt Ltd" \
|
| 26 |
+
--questions questionlist.md \
|
| 27 |
+
--num-queries 10 \
|
| 28 |
+
--out-json queries_output.json \
|
| 29 |
+
--out-md queries_output.md
|
| 30 |
+
"""
|
| 31 |
+
|
| 32 |
+
import os
|
| 33 |
+
import re
|
| 34 |
+
import sys
|
| 35 |
+
import json
|
| 36 |
+
import argparse
|
| 37 |
+
from pathlib import Path
|
| 38 |
+
|
| 39 |
+
try:
|
| 40 |
+
from google import genai
|
| 41 |
+
from google.genai.types import GenerateContentConfig, ServiceTier
|
| 42 |
+
except ImportError:
|
| 43 |
+
sys.exit(
|
| 44 |
+
"Missing dependency 'google-genai'. Install with:\n"
|
| 45 |
+
" pip install google-genai --break-system-packages"
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
MODEL = "gemini-2.5-flash"
|
| 49 |
+
PAID_GEMINI_ENV_NAMES = (
|
| 50 |
+
"GEMINI_API_KEY_prime_1",
|
| 51 |
+
"GEMINI_API_KEY_prime_2",
|
| 52 |
+
"GEMINI_API_KEY_prime_3",
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
def load_env_file(path: str = ".env") -> None:
|
| 56 |
+
env_path = Path(path)
|
| 57 |
+
if not env_path.is_absolute():
|
| 58 |
+
env_path = Path(__file__).resolve().parent / env_path
|
| 59 |
+
if not env_path.exists():
|
| 60 |
+
return
|
| 61 |
+
|
| 62 |
+
for line in env_path.read_text(encoding="utf-8-sig").splitlines():
|
| 63 |
+
line = line.strip()
|
| 64 |
+
if not line or line.startswith("#") or "=" not in line:
|
| 65 |
+
continue
|
| 66 |
+
|
| 67 |
+
key, value = line.split("=", 1)
|
| 68 |
+
key = key.strip().lstrip("\ufeff")
|
| 69 |
+
value = value.strip().strip('"').strip("'")
|
| 70 |
+
if key and key not in os.environ:
|
| 71 |
+
os.environ[key] = value
|
| 72 |
+
|
| 73 |
+
def get_all_paid_gemini_api_keys() -> list[str]:
|
| 74 |
+
keys = []
|
| 75 |
+
for name in PAID_GEMINI_ENV_NAMES:
|
| 76 |
+
key = os.environ.get(name, "").strip()
|
| 77 |
+
if key:
|
| 78 |
+
keys.append(key)
|
| 79 |
+
return keys
|
| 80 |
+
|
| 81 |
+
# --------------------------------------------------------------------------- #
|
| 82 |
+
# File / schema helpers
|
| 83 |
+
# --------------------------------------------------------------------------- #
|
| 84 |
+
|
| 85 |
+
def read_file(path: str) -> str:
|
| 86 |
+
p = Path(path)
|
| 87 |
+
if not p.exists():
|
| 88 |
+
sys.exit(f"File not found: {path}")
|
| 89 |
+
return p.read_text(encoding="utf-8")
|
| 90 |
+
|
| 91 |
+
def parse_questionlist(md_text: str) -> dict:
|
| 92 |
+
"""
|
| 93 |
+
Parses a markdown file of the form:
|
| 94 |
+
|
| 95 |
+
# Section Name
|
| 96 |
+
- Field
|
| 97 |
+
- Field
|
| 98 |
+
|
| 99 |
+
# Another Section
|
| 100 |
+
- Field
|
| 101 |
+
|
| 102 |
+
into {section_name: [field, field, ...]}. No field names are hard-coded,
|
| 103 |
+
so this keeps working even if questionlist.md is edited later.
|
| 104 |
+
"""
|
| 105 |
+
sections = {}
|
| 106 |
+
current = None
|
| 107 |
+
for line in md_text.splitlines():
|
| 108 |
+
line = line.strip()
|
| 109 |
+
if not line:
|
| 110 |
+
continue
|
| 111 |
+
heading = re.match(r"^#+\s*(.+)", line)
|
| 112 |
+
if heading:
|
| 113 |
+
current = heading.group(1).strip()
|
| 114 |
+
sections[current] = []
|
| 115 |
+
continue
|
| 116 |
+
bullet = re.match(r"^[-*]\s*(.+)", line)
|
| 117 |
+
if bullet and current is not None:
|
| 118 |
+
sections[current].append(bullet.group(1).strip())
|
| 119 |
+
if not sections:
|
| 120 |
+
sys.exit("Could not parse any sections/fields out of questionlist.md")
|
| 121 |
+
return sections
|
| 122 |
+
|
| 123 |
+
# --------------------------------------------------------------------------- #
|
| 124 |
+
# Prompt construction
|
| 125 |
+
# --------------------------------------------------------------------------- #
|
| 126 |
+
|
| 127 |
+
def build_system_prompt(num_queries: int) -> str:
|
| 128 |
+
return (
|
| 129 |
+
"You are a senior B2B sales-research analyst. You write precise, ready-to-"
|
| 130 |
+
"paste-into-Google search queries to find named decision-makers at ONE specific "
|
| 131 |
+
"client company.\n\n"
|
| 132 |
+
"You will receive:\n"
|
| 133 |
+
"1. VENDOR PROFILE - a markdown description of the vendor's product, business "
|
| 134 |
+
"model, and who it typically sells to. This changes between requests and exists "
|
| 135 |
+
"ONLY so you know which job titles/departments at the client company actually "
|
| 136 |
+
"decide, influence, or approve buying this kind of product. It is context, not "
|
| 137 |
+
"the search target.\n"
|
| 138 |
+
"2. CLIENT COMPANY - the exact name of the company you must find decision-maker "
|
| 139 |
+
"contacts at. EVERY query you write must be targeted at this named company, not "
|
| 140 |
+
"at the vendor and not at generic companies.\n"
|
| 141 |
+
"3. QUESTION SCHEMA - a fixed set of data fields that need to be answered for "
|
| 142 |
+
"decision-makers at the CLIENT COMPANY (e.g. name, title, department, seniority, "
|
| 143 |
+
"LinkedIn URL, email, phone, plus which contact is primary/secondary/tertiary/too-"
|
| 144 |
+
"senior-to-cold-contact). This does not change between requests.\n\n"
|
| 145 |
+
"Your job:\n"
|
| 146 |
+
f"- Produce EXACTLY {num_queries} search queries. No more, no fewer.\n"
|
| 147 |
+
f"- The {num_queries} queries, taken together, must be enough to find and answer "
|
| 148 |
+
"every field in the QUESTION SCHEMA for the CLIENT COMPANY -- i.e. enough to "
|
| 149 |
+
"identify the right decision-makers (using the vendor-context job titles/"
|
| 150 |
+
"departments), their seniority, LinkedIn URL, email, phone, AND to figure out "
|
| 151 |
+
"who among them is the primary/secondary/tertiary contact and who is too senior "
|
| 152 |
+
"to cold contact.\n"
|
| 153 |
+
"- Spread the queries across the different fields/needs of the QUESTION SCHEMA so "
|
| 154 |
+
"nothing in the schema is left uncoverable; do not waste multiple queries on the "
|
| 155 |
+
"same narrow need while ignoring others.\n\n"
|
| 156 |
+
"=== VALID GOOGLE SEARCH SYNTAX -- FOLLOW EXACTLY, THIS IS THE PART YOU KEEP "
|
| 157 |
+
"GETTING WRONG ===\n"
|
| 158 |
+
"Google has NO boolean 'AND' keyword and NO 'NOT' keyword. Writing them produces "
|
| 159 |
+
"queries that return zero results. The ONLY operators that actually work on Google "
|
| 160 |
+
"are:\n"
|
| 161 |
+
' - Quotes for an exact phrase: "Prestige Constructions India"\n'
|
| 162 |
+
" - Space between terms means AND implicitly -- never write the word AND.\n"
|
| 163 |
+
' - OR (uppercase) only inside parentheses to mean alternatives: ("CEO" OR "COO")\n'
|
| 164 |
+
" - A leading minus with NO space to exclude a term: -intern -trainee (never write "
|
| 165 |
+
"the word NOT)\n"
|
| 166 |
+
" - site: immediately followed by a domain, NO space, NO AND after it: "
|
| 167 |
+
"site:linkedin.com/in\n"
|
| 168 |
+
" - site: must sit directly next to the rest of the query with a space, never "
|
| 169 |
+
"joined to it with the word AND.\n\n"
|
| 170 |
+
"CORRECT example:\n"
|
| 171 |
+
' site:linkedin.com/in "Prestige Constructions India" ("Facilities Manager" OR "Procurement Manager" OR "Administration Manager")\n'
|
| 172 |
+
"WRONG example (never produce queries shaped like this):\n"
|
| 173 |
+
' site:linkedin.com/in AND ("Prestige Constructions India" AND ("Facilities Manager" OR "Procurement Manager"))\n'
|
| 174 |
+
"The WRONG example is exactly the kind of query you must never write: no literal "
|
| 175 |
+
"AND, no nesting the whole query inside one giant parenthesis, no wrapping the "
|
| 176 |
+
"client company name in parentheses with everything else.\n\n"
|
| 177 |
+
"Other rules:\n"
|
| 178 |
+
"- Keep each query to a realistic length a human would actually paste into Google "
|
| 179 |
+
"-- 1 site: operator, 1 quoted company name, at most one OR-group of 2-4 "
|
| 180 |
+
"alternatives, and at most one trailing -exclusion. Do not stack 3+ OR-groups in a "
|
| 181 |
+
"single query.\n"
|
| 182 |
+
"- Each query must be EXACT, PINPOINT, and immediately runnable as-is: use the "
|
| 183 |
+
"client company's exact name in quotes, relevant job-title synonyms, and site: "
|
| 184 |
+
"operators (e.g. site:linkedin.com/in) where useful. Never output a vague or "
|
| 185 |
+
"generic query.\n"
|
| 186 |
+
"- Do NOT include any reasoning, justification, explanation, persona labels, or "
|
| 187 |
+
"commentary anywhere in the output. Output ONLY the queries themselves -- they "
|
| 188 |
+
"are meant to be copy-pasted straight into Google.\n\n"
|
| 189 |
+
"Respond with STRICT JSON ONLY. No markdown code fences, no commentary before or "
|
| 190 |
+
"after. Match exactly this shape:\n"
|
| 191 |
+
"{\n"
|
| 192 |
+
' "client_company": "string - exact client company name as given",\n'
|
| 193 |
+
f' "queries": ["query1", "query2", ... exactly {num_queries} strings total ...]\n'
|
| 194 |
+
"}\n"
|
| 195 |
+
)
|
| 196 |
+
|
| 197 |
+
def build_user_prompt(company_md: str, client_company: str, questionlist_md: str, sections: dict) -> str:
|
| 198 |
+
return (
|
| 199 |
+
f"VENDOR PROFILE (context only - do not target searches at the vendor):\n"
|
| 200 |
+
f"{company_md.strip()}\n\n"
|
| 201 |
+
f"CLIENT COMPANY (target every query at this exact company):\n"
|
| 202 |
+
f"{client_company.strip()}\n\n"
|
| 203 |
+
f"QUESTION SCHEMA (raw markdown):\n{questionlist_md.strip()}\n\n"
|
| 204 |
+
f"QUESTION SCHEMA (parsed sections -> fields):\n{json.dumps(sections, indent=2)}\n\n"
|
| 205 |
+
"Generate the JSON output now."
|
| 206 |
+
)
|
| 207 |
+
|
| 208 |
+
# --------------------------------------------------------------------------- #
|
| 209 |
+
# Gemini call
|
| 210 |
+
# --------------------------------------------------------------------------- #
|
| 211 |
+
|
| 212 |
+
def call_claude_fallback(system_prompt: str, user_prompt: str) -> str:
|
| 213 |
+
import anthropic
|
| 214 |
+
api_key = os.environ.get("ANTHROPIC_API_KEY", "").strip()
|
| 215 |
+
if not api_key:
|
| 216 |
+
raise ValueError("ANTHROPIC_API_KEY is not configured.")
|
| 217 |
+
client = anthropic.Anthropic(api_key=api_key)
|
| 218 |
+
|
| 219 |
+
messages = [{"role": "user", "content": user_prompt}]
|
| 220 |
+
full_response = ""
|
| 221 |
+
continuation_count = 0
|
| 222 |
+
max_continuations = 5
|
| 223 |
+
|
| 224 |
+
while True:
|
| 225 |
+
response = client.messages.create(
|
| 226 |
+
model="claude-haiku-4-5",
|
| 227 |
+
max_tokens=4000,
|
| 228 |
+
system=system_prompt,
|
| 229 |
+
messages=messages,
|
| 230 |
+
)
|
| 231 |
+
|
| 232 |
+
for block in response.content:
|
| 233 |
+
if block.type == "text":
|
| 234 |
+
full_response += block.text
|
| 235 |
+
|
| 236 |
+
stop_reason = response.stop_reason
|
| 237 |
+
|
| 238 |
+
if stop_reason == "end_turn":
|
| 239 |
+
break
|
| 240 |
+
elif stop_reason == "max_tokens":
|
| 241 |
+
continuation_count += 1
|
| 242 |
+
if continuation_count >= max_continuations:
|
| 243 |
+
break
|
| 244 |
+
messages.append({"role": "assistant", "content": response.content})
|
| 245 |
+
messages.append({"role": "user", "content": "Your previous response was cut off. Please continue exactly from where you left off."})
|
| 246 |
+
elif stop_reason == "refusal":
|
| 247 |
+
break
|
| 248 |
+
else:
|
| 249 |
+
break
|
| 250 |
+
|
| 251 |
+
return full_response
|
| 252 |
+
|
| 253 |
+
def call_gemini(system_prompt: str, user_prompt: str) -> str:
|
| 254 |
+
load_env_file()
|
| 255 |
+
keys = get_all_paid_gemini_api_keys()
|
| 256 |
+
if not keys:
|
| 257 |
+
sys.exit("Limit reached try after 5 minutes please contact admin")
|
| 258 |
+
|
| 259 |
+
last_err = None
|
| 260 |
+
for api_key in keys:
|
| 261 |
+
try:
|
| 262 |
+
client = genai.Client(api_key=api_key)
|
| 263 |
+
resp = client.models.generate_content(
|
| 264 |
+
model=MODEL,
|
| 265 |
+
contents=user_prompt,
|
| 266 |
+
config=GenerateContentConfig(
|
| 267 |
+
system_instruction=system_prompt,
|
| 268 |
+
temperature=0.3,
|
| 269 |
+
max_output_tokens=4000,
|
| 270 |
+
service_tier=ServiceTier.STANDARD,
|
| 271 |
+
),
|
| 272 |
+
)
|
| 273 |
+
return resp.text
|
| 274 |
+
except Exception as e:
|
| 275 |
+
last_err = e
|
| 276 |
+
continue
|
| 277 |
+
|
| 278 |
+
try:
|
| 279 |
+
return call_claude_fallback(system_prompt, user_prompt)
|
| 280 |
+
except Exception as e:
|
| 281 |
+
pass
|
| 282 |
+
|
| 283 |
+
sys.exit("Limit reached try after 5 minutes please contact admin")
|
| 284 |
+
|
| 285 |
+
def extract_json(text: str) -> dict:
|
| 286 |
+
text = text.strip()
|
| 287 |
+
text = re.sub(r"^```(json)?", "", text).strip()
|
| 288 |
+
text = re.sub(r"```$", "", text).strip()
|
| 289 |
+
start = text.find("{")
|
| 290 |
+
end = text.rfind("}")
|
| 291 |
+
if start == -1 or end == -1:
|
| 292 |
+
raise ValueError("No JSON object found in model output")
|
| 293 |
+
return json.loads(text[start:end + 1])
|
| 294 |
+
|
| 295 |
+
def validate_queries(data: dict, num_queries: int) -> None:
|
| 296 |
+
queries = data.get("queries")
|
| 297 |
+
if not isinstance(queries, list) or not all(isinstance(q, str) for q in queries):
|
| 298 |
+
raise ValueError("'queries' must be a list of strings")
|
| 299 |
+
if len(queries) != num_queries:
|
| 300 |
+
raise ValueError(f"Expected exactly {num_queries} queries, got {len(queries)}")
|
| 301 |
+
|
| 302 |
+
# --------------------------------------------------------------------------- #
|
| 303 |
+
# Reporting
|
| 304 |
+
# --------------------------------------------------------------------------- #
|
| 305 |
+
|
| 306 |
+
def render_markdown_report(data: dict) -> str:
|
| 307 |
+
lines = [f"# Search Queries - {data.get('client_company', '')}\n"]
|
| 308 |
+
for i, q in enumerate(data.get("queries", []), start=1):
|
| 309 |
+
lines.append(f"{i}. {q}")
|
| 310 |
+
lines.append("")
|
| 311 |
+
return "\n".join(lines)
|
| 312 |
+
|
| 313 |
+
# --------------------------------------------------------------------------- #
|
| 314 |
+
# Main
|
| 315 |
+
# --------------------------------------------------------------------------- #
|
| 316 |
+
|
| 317 |
+
def main():
|
| 318 |
+
parser = argparse.ArgumentParser(
|
| 319 |
+
description="Generate a flat list of pinpoint, directly-runnable web search "
|
| 320 |
+
"queries to find and prioritize decision-maker contacts at a named client "
|
| 321 |
+
"company, informed by the vendor's product/ICP and a fixed question schema."
|
| 322 |
+
)
|
| 323 |
+
parser.add_argument("--company", default="company.md", help="Path to company.md (vendor profile, varies per run)")
|
| 324 |
+
parser.add_argument("--client-company", required=True, help="Exact name of the client company to find decision-makers at")
|
| 325 |
+
parser.add_argument("--questions", default="questionlist.md", help="Path to questionlist.md (fixed schema)")
|
| 326 |
+
parser.add_argument("--num-queries", type=int, default=10, help="Number of queries to generate (default: 10)")
|
| 327 |
+
parser.add_argument("--out-json", default="queries_output.json", help="Path for JSON output")
|
| 328 |
+
parser.add_argument("--out-md", default="queries_output.md", help="Path for Markdown report output")
|
| 329 |
+
args = parser.parse_args()
|
| 330 |
+
|
| 331 |
+
company_md = read_file(args.company)
|
| 332 |
+
questionlist_md = read_file(args.questions)
|
| 333 |
+
sections = parse_questionlist(questionlist_md)
|
| 334 |
+
|
| 335 |
+
system_prompt = build_system_prompt(args.num_queries)
|
| 336 |
+
user_prompt = build_user_prompt(company_md, args.client_company, questionlist_md, sections)
|
| 337 |
+
|
| 338 |
+
print("Calling ProspectIQ AI Engine...", file=sys.stderr)
|
| 339 |
+
raw = call_gemini(system_prompt, user_prompt)
|
| 340 |
+
|
| 341 |
+
try:
|
| 342 |
+
data = extract_json(raw)
|
| 343 |
+
validate_queries(data, args.num_queries)
|
| 344 |
+
except Exception as e:
|
| 345 |
+
sys.exit(f"Failed to parse/validate model output: {e}\n\nRaw output:\n{raw}")
|
| 346 |
+
|
| 347 |
+
Path(args.out_json).write_text(json.dumps(data, indent=2), encoding="utf-8")
|
| 348 |
+
Path(args.out_md).write_text(render_markdown_report(data), encoding="utf-8")
|
| 349 |
+
|
| 350 |
+
print(f"\nSearch queries for: {data.get('client_company')}\n")
|
| 351 |
+
for i, q in enumerate(data.get("queries", []), start=1):
|
| 352 |
+
print(f"{i}. {q}")
|
| 353 |
+
|
| 354 |
+
print(f"\nSaved JSON -> {args.out_json}", file=sys.stderr)
|
| 355 |
+
print(f"Saved Report -> {args.out_md}", file=sys.stderr)
|
| 356 |
+
|
| 357 |
+
if __name__ == "__main__":
|
| 358 |
+
main()
|
services/research/questionlist.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Decision Makers
|
| 2 |
+
|
| 3 |
+
- Name
|
| 4 |
+
- Title
|
| 5 |
+
- Department
|
| 6 |
+
- Seniority Level
|
| 7 |
+
- LinkedIn URL
|
| 8 |
+
- Email (if available)
|
| 9 |
+
- Phone (if available)
|
| 10 |
+
|
| 11 |
+
# Contact Prioritization
|
| 12 |
+
|
| 13 |
+
- Primary Contact
|
| 14 |
+
- Primary Contact Reason
|
| 15 |
+
- Secondary Contact
|
| 16 |
+
- Secondary Contact Reason
|
| 17 |
+
- Tertiary Contact
|
| 18 |
+
- Tertiary Contact Reason
|
| 19 |
+
- Too Senior To Cold Contact
|
| 20 |
+
- Reason
|
services/tool_router.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import re
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
from services.ai_service import new_job, now, update_job
|
| 7 |
+
|
| 8 |
+
TOOL_ROUTES: dict[str, dict[str, str]] = {
|
| 9 |
+
"ocr": {
|
| 10 |
+
"label": "Business Card Parser",
|
| 11 |
+
"route": "#/ocr",
|
| 12 |
+
"summary": "Open Business Card Parser to upload a business card or document image.",
|
| 13 |
+
},
|
| 14 |
+
"geo": {
|
| 15 |
+
"label": "Regional Prospecting",
|
| 16 |
+
"route": "#/geo-finder",
|
| 17 |
+
"summary": "Find and rank companies by location, category, industry, or coordinates.",
|
| 18 |
+
},
|
| 19 |
+
"research": {
|
| 20 |
+
"label": "Company Research",
|
| 21 |
+
"route": "#/company-extractor",
|
| 22 |
+
"summary": "Run company research and generate a structured company profile.",
|
| 23 |
+
},
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
def _clean_query(value: Any) -> str:
|
| 27 |
+
return re.sub(r"\s+", " ", str(value or "")).strip()
|
| 28 |
+
|
| 29 |
+
def _strip_tool_verbs(query: str, tool: str) -> str:
|
| 30 |
+
cleaned = query
|
| 31 |
+
replacements = {
|
| 32 |
+
"geo": (
|
| 33 |
+
r"\b(?:use|run|open|start|launch)?\s*(?:geo\s*finder|geo|geographic(?:al)?\s*(?:finder|search|discovery)|map\s*search|nearby\s*(?:company|companies)?)\b",
|
| 34 |
+
r"\b(?:find|search|discover|rank|list|show me|look for)\b",
|
| 35 |
+
),
|
| 36 |
+
"research": (
|
| 37 |
+
r"\b(?:use|run|open|start|launch)?\s*(?:company\s*(?:extractor|research|researcher|intelligence|profile|enrichment)|extractor)\b",
|
| 38 |
+
r"\b(?:research|extract|enrich|profile|analyze|analyse|make a profile for|build a profile for)\b",
|
| 39 |
+
),
|
| 40 |
+
"ocr": (
|
| 41 |
+
r"\b(?:use|run|open|start|launch)?\s*(?:ocr|business\s*card\s*(?:scanner|scan|ocr|reader)|card\s*(?:scanner|scan|reader)|image\s*(?:ocr|text extraction))\b",
|
| 42 |
+
r"\b(?:scan|read|extract text from|parse)\b",
|
| 43 |
+
),
|
| 44 |
+
}
|
| 45 |
+
for pattern in replacements.get(tool, ()):
|
| 46 |
+
cleaned = re.sub(pattern, " ", cleaned, flags=re.I)
|
| 47 |
+
cleaned = re.sub(r"^\s*\b(?:for|to|about|on|near|with|using)\b\s*", " ", cleaned, flags=re.I)
|
| 48 |
+
cleaned = re.sub(r"\b(?:for|to|about|on|near|with|using)\s*$", " ", cleaned, flags=re.I)
|
| 49 |
+
return _clean_query(cleaned).strip(" .,:;-")
|
| 50 |
+
|
| 51 |
+
def heuristic_tool_route(query: str) -> dict[str, Any] | None:
|
| 52 |
+
normalized = _clean_query(query).lower()
|
| 53 |
+
if not normalized:
|
| 54 |
+
return None
|
| 55 |
+
|
| 56 |
+
is_explicit_crm = bool(re.search(r"\b(?:database|crm|my companies|saved companies|in crm|from crm|in database|from database|add|create|register|insert|save|put|edit|change|update|set|modify|delete|remove|stage|stages|pipeline|new stage|shortlisted|contacted|engaged|proposal|won|paused|tasks?|contacts?|reps?|users?)\b", normalized))
|
| 57 |
+
if is_explicit_crm:
|
| 58 |
+
return None
|
| 59 |
+
|
| 60 |
+
crm_company_filter = re.search(r"\b(?:show|sow|shwo|list|view|rank|find|get|fetch|all|existing)\b.*\b(?:companies|prospects|records)\b", normalized) and not re.search(
|
| 61 |
+
r"\b(?:near|nearby|around|within|coordinates|latitude|longitude|geo\s*finder|map search|prospecting|discover new)\b",
|
| 62 |
+
normalized,
|
| 63 |
+
)
|
| 64 |
+
if crm_company_filter:
|
| 65 |
+
return None
|
| 66 |
+
|
| 67 |
+
ocr_terms = ("ocr", "business card", "scan card", "card scan", "extract text", "read image", "image text")
|
| 68 |
+
business_plural = r"(?:companies|manufacturers|suppliers|vendors|distributors|producers|firms|businesses|factories|plants)"
|
| 69 |
+
geo_discovery_patterns = (
|
| 70 |
+
rf"\b(?:top|best|leading|largest|major|biggest)\b.+\b{business_plural}\b.+\b(?:near|nearby|around|within)\b",
|
| 71 |
+
rf"\b{business_plural}\b.+\b(?:near|nearby|around|within)\b\s+[a-z0-9, .-]+",
|
| 72 |
+
rf"\b(?:geo\s*finder|map\s*search|discover\s+new\s+prospects)\b",
|
| 73 |
+
rf"\b(?:nearby|local)\b.+\b{business_plural}\b",
|
| 74 |
+
)
|
| 75 |
+
geo_terms = (
|
| 76 |
+
"geo finder",
|
| 77 |
+
"near ",
|
| 78 |
+
"nearby",
|
| 79 |
+
"around ",
|
| 80 |
+
"within ",
|
| 81 |
+
"coordinates",
|
| 82 |
+
"latitude",
|
| 83 |
+
"longitude",
|
| 84 |
+
"companies near",
|
| 85 |
+
"discover new companies",
|
| 86 |
+
"map search",
|
| 87 |
+
)
|
| 88 |
+
research_terms = (
|
| 89 |
+
"company extractor",
|
| 90 |
+
"company research",
|
| 91 |
+
"research company",
|
| 92 |
+
"research ",
|
| 93 |
+
"extract company",
|
| 94 |
+
"enrich company",
|
| 95 |
+
"profile for",
|
| 96 |
+
"company profile",
|
| 97 |
+
"analyze company",
|
| 98 |
+
"analyse company",
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
tool = ""
|
| 102 |
+
confidence = "heuristic"
|
| 103 |
+
if any(term in normalized for term in ocr_terms):
|
| 104 |
+
tool = "ocr"
|
| 105 |
+
confidence = "rule"
|
| 106 |
+
elif any(re.search(pattern, normalized, flags=re.I) for pattern in geo_discovery_patterns):
|
| 107 |
+
tool = "geo"
|
| 108 |
+
confidence = "rule"
|
| 109 |
+
elif any(term in normalized for term in geo_terms):
|
| 110 |
+
tool = "geo"
|
| 111 |
+
elif any(term in normalized for term in research_terms):
|
| 112 |
+
tool = "research"
|
| 113 |
+
confidence = "rule" if any(term in normalized for term in ("company extractor", "company research")) else "heuristic"
|
| 114 |
+
|
| 115 |
+
if not tool:
|
| 116 |
+
return None
|
| 117 |
+
|
| 118 |
+
route = dict(TOOL_ROUTES[tool])
|
| 119 |
+
tool_query = _strip_tool_verbs(query, tool) or _clean_query(query)
|
| 120 |
+
payload: dict[str, Any] = {
|
| 121 |
+
"action": "tool_route",
|
| 122 |
+
"tool": tool,
|
| 123 |
+
"tool_label": route["label"],
|
| 124 |
+
"route": route["route"],
|
| 125 |
+
"message": route["summary"],
|
| 126 |
+
"tool_input": {},
|
| 127 |
+
"confidence": confidence,
|
| 128 |
+
}
|
| 129 |
+
if tool == "geo":
|
| 130 |
+
payload["tool_input"] = {"query": tool_query}
|
| 131 |
+
payload["auto_start"] = True
|
| 132 |
+
elif tool == "research":
|
| 133 |
+
payload["tool_input"] = {"company": tool_query}
|
| 134 |
+
payload["auto_start"] = bool(tool_query)
|
| 135 |
+
else:
|
| 136 |
+
payload["tool_input"] = {"query": tool_query}
|
| 137 |
+
payload["auto_start"] = False
|
| 138 |
+
return payload
|
| 139 |
+
|
| 140 |
+
def normalize_tool_route(route_payload: dict[str, Any], original_query: str) -> dict[str, Any]:
|
| 141 |
+
tool = str(route_payload.get("tool") or "").strip().lower()
|
| 142 |
+
if tool in {"company_extractor", "company-extractor", "extractor"}:
|
| 143 |
+
tool = "research"
|
| 144 |
+
if tool in {"geo_finder", "geo-finder"}:
|
| 145 |
+
tool = "geo"
|
| 146 |
+
if tool not in TOOL_ROUTES:
|
| 147 |
+
fallback = heuristic_tool_route(original_query)
|
| 148 |
+
if fallback:
|
| 149 |
+
return fallback
|
| 150 |
+
return {"action": "answer", "reply": "I could not identify the right tool for that request."}
|
| 151 |
+
|
| 152 |
+
route = TOOL_ROUTES[tool]
|
| 153 |
+
tool_input = route_payload.get("tool_input") if isinstance(route_payload.get("tool_input"), dict) else {}
|
| 154 |
+
if tool == "geo":
|
| 155 |
+
tool_input["query"] = _clean_query(tool_input.get("query") or route_payload.get("query") or _strip_tool_verbs(original_query, tool))
|
| 156 |
+
elif tool == "research":
|
| 157 |
+
tool_input["company"] = _clean_query(tool_input.get("company") or route_payload.get("company") or _strip_tool_verbs(original_query, tool))
|
| 158 |
+
elif tool == "ocr":
|
| 159 |
+
tool_input["query"] = _clean_query(tool_input.get("query") or _strip_tool_verbs(original_query, tool))
|
| 160 |
+
|
| 161 |
+
return {
|
| 162 |
+
"action": "tool_route",
|
| 163 |
+
"tool": tool,
|
| 164 |
+
"tool_label": route["label"],
|
| 165 |
+
"route": route["route"],
|
| 166 |
+
"message": route_payload.get("message") or route["summary"],
|
| 167 |
+
"tool_input": tool_input,
|
| 168 |
+
"auto_start": bool(route_payload.get("auto_start", tool in {"geo", "research"})),
|
| 169 |
+
"confidence": route_payload.get("confidence") or "model",
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
def save_tool_route(query: str, route_payload: dict[str, Any]) -> str:
|
| 173 |
+
tool_label = route_payload.get("tool_label") or route_payload.get("tool") or "Tool"
|
| 174 |
+
job_id = new_job(
|
| 175 |
+
"tool_route",
|
| 176 |
+
f"{tool_label}: {_clean_query(query)[:90]}",
|
| 177 |
+
{
|
| 178 |
+
"query": _clean_query(query),
|
| 179 |
+
"selected_tool": route_payload.get("tool"),
|
| 180 |
+
"route": route_payload.get("route"),
|
| 181 |
+
"tool_input": route_payload.get("tool_input") or {},
|
| 182 |
+
"auto_start": bool(route_payload.get("auto_start")),
|
| 183 |
+
"confidence": route_payload.get("confidence") or "",
|
| 184 |
+
},
|
| 185 |
+
)
|
| 186 |
+
update_job(
|
| 187 |
+
job_id,
|
| 188 |
+
status="completed",
|
| 189 |
+
started_at=now(),
|
| 190 |
+
finished_at=now(),
|
| 191 |
+
result={
|
| 192 |
+
"selected_tool": route_payload.get("tool"),
|
| 193 |
+
"tool_label": route_payload.get("tool_label"),
|
| 194 |
+
"route": route_payload.get("route"),
|
| 195 |
+
"message": route_payload.get("message"),
|
| 196 |
+
"tool_input": route_payload.get("tool_input") or {},
|
| 197 |
+
},
|
| 198 |
+
)
|
| 199 |
+
return job_id
|
settings/base_company_context.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Company Knowledge Base - Drinking Water Can Supply Business
|
| 2 |
+
|
| 3 |
+
# About Us
|
| 4 |
+
|
| 5 |
+
We are an India-based B2B drinking water supply company specializing in the manufacturing and distribution of high-quality packaged drinking water in reusable 20-litre water cans. Our primary focus is supplying businesses and organizations that require a continuous, reliable, and hygienic source of drinking water for their employees, workers, visitors, and guests.
|
| 6 |
+
|
| 7 |
+
Unlike retail bottled water brands, we operate on a recurring business model where customers receive scheduled deliveries of water cans based on their daily or weekly consumption.
|
| 8 |
+
|
| 9 |
+
Our objective is to become the preferred drinking water supplier for organizations with medium to large workforce requirements.
|
| 10 |
+
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
# What We Do
|
| 14 |
+
|
| 15 |
+
We manufacture purified drinking water that complies with Indian drinking water quality standards.
|
| 16 |
+
|
| 17 |
+
We supply:
|
| 18 |
+
|
| 19 |
+
* 20L reusable drinking water cans
|
| 20 |
+
* Bulk drinking water supply
|
| 21 |
+
* Scheduled recurring deliveries
|
| 22 |
+
* Emergency water deliveries
|
| 23 |
+
* Long-term corporate supply contracts
|
| 24 |
+
* Multi-location water supply solutions
|
| 25 |
+
|
| 26 |
+
We ensure consistent quality, timely deliveries, and uninterrupted supply.
|
start-hf-space.sh
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/bin/sh
|
| 2 |
+
set -eu
|
| 3 |
+
|
| 4 |
+
export PORT="${PORT:-7860}"
|
| 5 |
+
export PYTHONPATH="${PYTHONPATH:-/app}"
|
| 6 |
+
export WEB_CONCURRENCY="${WEB_CONCURRENCY:-1}"
|
| 7 |
+
export WEB_TIMEOUT="${WEB_TIMEOUT:-180}"
|
| 8 |
+
export CELERY_WORKER_POOL="${CELERY_WORKER_POOL:-solo}"
|
| 9 |
+
export CELERY_WORKER_CONCURRENCY="${CELERY_WORKER_CONCURRENCY:-1}"
|
| 10 |
+
export REDIS_URL="${REDIS_URL:-redis://127.0.0.1:6379/0}"
|
| 11 |
+
export CELERY_BROKER_URL="${CELERY_BROKER_URL:-$REDIS_URL}"
|
| 12 |
+
export CELERY_RESULT_BACKEND="${CELERY_RESULT_BACKEND:-$REDIS_URL}"
|
| 13 |
+
|
| 14 |
+
if [ "$REDIS_URL" = "redis://127.0.0.1:6379/0" ] || [ "$REDIS_URL" = "redis://localhost:6379/0" ]; then
|
| 15 |
+
redis-server --daemonize yes
|
| 16 |
+
fi
|
| 17 |
+
|
| 18 |
+
celery -A celery_app.celery_app worker \
|
| 19 |
+
--loglevel=info \
|
| 20 |
+
--pool="${CELERY_WORKER_POOL}" \
|
| 21 |
+
--concurrency="${CELERY_WORKER_CONCURRENCY}" &
|
| 22 |
+
|
| 23 |
+
exec gunicorn app:app \
|
| 24 |
+
--bind "0.0.0.0:${PORT}" \
|
| 25 |
+
--workers "${WEB_CONCURRENCY}" \
|
| 26 |
+
--timeout "${WEB_TIMEOUT}"
|
static/js/api.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
async function loadDataFromDb() {
|
| 2 |
+
showGlobalLoader('Synchronizing workspace data...');
|
| 3 |
+
try {
|
| 4 |
+
const bootstrapRes = await fetch('/api/bootstrap');
|
| 5 |
+
if (bootstrapRes.ok) {
|
| 6 |
+
const data = await bootstrapRes.json();
|
| 7 |
+
|
| 8 |
+
// Apply stages
|
| 9 |
+
if (data.stages) {
|
| 10 |
+
dbStages = data.stages;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
// Apply companies
|
| 14 |
+
if (data.companies) {
|
| 15 |
+
mockCompanies = data.companies.map(c => ({
|
| 16 |
+
id: c.id,
|
| 17 |
+
name: c.name,
|
| 18 |
+
province: c.province || '',
|
| 19 |
+
city: c.city || '',
|
| 20 |
+
industry: c.industry || '',
|
| 21 |
+
stage: c.stage_name || 'New',
|
| 22 |
+
size: c.size_staff ? `${c.size_staff.toLocaleString()} staff` : 'Not Disclosed',
|
| 23 |
+
lastTouched: c.updated_at ? c.updated_at.split(' ')[0] : '',
|
| 24 |
+
rep: c.rep_name || 'User2',
|
| 25 |
+
score: c.maplempss_fit_score || 0.0,
|
| 26 |
+
...c
|
| 27 |
+
}));
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
// Apply contacts
|
| 31 |
+
if (data.contacts) {
|
| 32 |
+
mockContacts = data.contacts.map(c => ({
|
| 33 |
+
id: c.id,
|
| 34 |
+
first_name: c.first_name,
|
| 35 |
+
last_name: c.last_name,
|
| 36 |
+
company: c.company_name || '',
|
| 37 |
+
title: c.title || '',
|
| 38 |
+
email: c.email || '',
|
| 39 |
+
phone: c.phone || '',
|
| 40 |
+
is_primary: c.is_primary === 1,
|
| 41 |
+
...c
|
| 42 |
+
}));
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
// Apply column schemas
|
| 46 |
+
if (data.columns_companies && typeof loadColumnsFromBootstrap === 'function') {
|
| 47 |
+
loadColumnsFromBootstrap('companies', data.columns_companies);
|
| 48 |
+
}
|
| 49 |
+
if (data.columns_contacts && typeof loadColumnsFromBootstrap === 'function') {
|
| 50 |
+
loadColumnsFromBootstrap('contacts', data.columns_contacts);
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
hideGlobalLoader();
|
| 54 |
+
return; // Success — skip fallback
|
| 55 |
+
}
|
| 56 |
+
} catch (err) {
|
| 57 |
+
console.warn("Bootstrap endpoint failed, falling back to sequential loading:", err);
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
// Fallback: sequential loading (for when bootstrap is unavailable)
|
| 61 |
+
try {
|
| 62 |
+
await loadColumnsFromDb('companies');
|
| 63 |
+
await loadColumnsFromDb('contacts');
|
| 64 |
+
|
| 65 |
+
const stagesRes = await fetch('/api/stages');
|
| 66 |
+
if (stagesRes.ok) {
|
| 67 |
+
dbStages = await stagesRes.json();
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
const companiesRes = await fetch('/api/companies');
|
| 71 |
+
if (companiesRes.ok) {
|
| 72 |
+
const dbCompanies = await companiesRes.json();
|
| 73 |
+
mockCompanies = dbCompanies.map(c => ({
|
| 74 |
+
id: c.id,
|
| 75 |
+
name: c.name,
|
| 76 |
+
province: c.province || '',
|
| 77 |
+
city: c.city || '',
|
| 78 |
+
industry: c.industry || '',
|
| 79 |
+
stage: c.stage_name || 'New',
|
| 80 |
+
size: c.size_staff ? `${c.size_staff.toLocaleString()} staff` : 'Not Disclosed',
|
| 81 |
+
lastTouched: c.updated_at ? c.updated_at.split(' ')[0] : '',
|
| 82 |
+
rep: c.rep_name || 'User2',
|
| 83 |
+
score: c.maplempss_fit_score || 0.0,
|
| 84 |
+
...c
|
| 85 |
+
}));
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
const contactsRes = await fetch('/api/contacts');
|
| 89 |
+
if (contactsRes.ok) {
|
| 90 |
+
const dbContacts = await contactsRes.json();
|
| 91 |
+
mockContacts = dbContacts.map(c => ({
|
| 92 |
+
id: c.id,
|
| 93 |
+
first_name: c.first_name,
|
| 94 |
+
last_name: c.last_name,
|
| 95 |
+
company: c.company_name || '',
|
| 96 |
+
title: c.title || '',
|
| 97 |
+
email: c.email || '',
|
| 98 |
+
phone: c.phone || '',
|
| 99 |
+
is_primary: c.is_primary === 1,
|
| 100 |
+
...c
|
| 101 |
+
}));
|
| 102 |
+
}
|
| 103 |
+
} catch (err) {
|
| 104 |
+
console.error("Failed to load data from database: ", err);
|
| 105 |
+
} finally {
|
| 106 |
+
hideGlobalLoader();
|
| 107 |
+
}
|
| 108 |
+
}
|
| 109 |
+
|