praneelchettys commited on
Commit
5bcd5ab
·
0 Parent(s):

Replace main with mobile-friendly ProspectIQ codebase

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
.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
+ ![ProspectIQ Dashboard](docs/images/dashboard.png)
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
+ ![Home Dashboard](docs/images/dashboard.png)
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
+ ![Companies Table](docs/images/companies-table.png)
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
+ ![Companies Board](docs/images/companies-board.png)
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
+ ![Business Card OCR](docs/images/ocr.png)
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
+ ![Geo Finder](docs/images/geo-finder.png)
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
+ ![Company Extractor](docs/images/company-extractor.png)
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
+ ![Agent Space](docs/images/agent-space.png)
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
+