Anuragh Claude Sonnet 4.6 commited on
Commit
ec592cf
Β·
1 Parent(s): b2df971

sync: Replace Composio with Nango for LinkedIn integration

Browse files

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

backend/main.py CHANGED
@@ -108,7 +108,7 @@ from services import embedding_matcher
108
  from services import quality_gate
109
  from services import scoring_engine
110
  from services import latex_resume
111
- from services import linkedin_composio
112
  from core import auth as auth_module
113
 
114
  logger = logging.getLogger(__name__)
@@ -656,11 +656,11 @@ def linkedin_auth_url(
656
  user: dict = Depends(get_current_user),
657
  ):
658
  """Return a Composio OAuth URL for the user to connect their LinkedIn account."""
659
- if not linkedin_composio.COMPOSIO_API_KEY:
660
- raise HTTPException(status_code=503, detail="COMPOSIO_API_KEY not configured")
661
  entity_id = user.get("clerk_id") or user.get("id") or "default"
662
  try:
663
- url = linkedin_composio.get_oauth_url(entity_id=entity_id, redirect_url=redirect)
664
  return {"ok": True, "url": url}
665
  except Exception as e:
666
  logger.error("LinkedIn auth URL error: %s", e)
@@ -670,21 +670,21 @@ def linkedin_auth_url(
670
  @app.get("/api/linkedin/status")
671
  def linkedin_status(user: dict = Depends(get_current_user)):
672
  """Return whether the user has LinkedIn connected via Composio."""
673
- if not linkedin_composio.COMPOSIO_API_KEY:
674
- return {"ok": True, "connected": False, "reason": "COMPOSIO_API_KEY not configured"}
675
  entity_id = user.get("clerk_id") or user.get("id") or "default"
676
- connected = linkedin_composio.is_connected(entity_id)
677
  return {"ok": True, "connected": connected}
678
 
679
 
680
  @app.post("/api/linkedin/import")
681
  def linkedin_import(user: dict = Depends(get_current_user)):
682
  """Import user's LinkedIn profile as a parsed resume object."""
683
- if not linkedin_composio.COMPOSIO_API_KEY:
684
- raise HTTPException(status_code=503, detail="COMPOSIO_API_KEY not configured")
685
  entity_id = user.get("clerk_id") or user.get("id") or "default"
686
  try:
687
- data = linkedin_composio.import_profile(entity_id)
688
  return {"ok": True, "data": data}
689
  except Exception as e:
690
  logger.error("LinkedIn import error: %s", e)
@@ -694,10 +694,10 @@ def linkedin_import(user: dict = Depends(get_current_user)):
694
  @app.post("/api/linkedin/enrich-companies")
695
  def linkedin_enrich_companies(req: LinkedInEnrichRequest, user: dict = Depends(get_current_user)):
696
  """Enrich a list of job dicts with LinkedIn company_info. Skips silently on failure."""
697
- if not linkedin_composio.COMPOSIO_API_KEY:
698
  return {"ok": True, "jobs": req.jobs}
699
  entity_id = req.entity_id or user.get("clerk_id") or user.get("id") or "default"
700
- enriched = linkedin_composio.enrich_companies(req.jobs, entity_id)
701
  return {"ok": True, "jobs": enriched}
702
 
703
 
 
108
  from services import quality_gate
109
  from services import scoring_engine
110
  from services import latex_resume
111
+ from services import linkedin_nango
112
  from core import auth as auth_module
113
 
114
  logger = logging.getLogger(__name__)
 
656
  user: dict = Depends(get_current_user),
657
  ):
658
  """Return a Composio OAuth URL for the user to connect their LinkedIn account."""
659
+ if not linkedin_nango.NANGO_SECRET_KEY:
660
+ raise HTTPException(status_code=503, detail="NANGO_SECRET_KEY not configured")
661
  entity_id = user.get("clerk_id") or user.get("id") or "default"
662
  try:
663
+ url = linkedin_nango.get_connect_url(user_id=entity_id, redirect_url=redirect)
664
  return {"ok": True, "url": url}
665
  except Exception as e:
666
  logger.error("LinkedIn auth URL error: %s", e)
 
670
  @app.get("/api/linkedin/status")
671
  def linkedin_status(user: dict = Depends(get_current_user)):
672
  """Return whether the user has LinkedIn connected via Composio."""
673
+ if not linkedin_nango.NANGO_SECRET_KEY:
674
+ return {"ok": True, "connected": False, "reason": "NANGO_SECRET_KEY not configured"}
675
  entity_id = user.get("clerk_id") or user.get("id") or "default"
676
+ connected = linkedin_nango.is_connected(entity_id)
677
  return {"ok": True, "connected": connected}
678
 
679
 
680
  @app.post("/api/linkedin/import")
681
  def linkedin_import(user: dict = Depends(get_current_user)):
682
  """Import user's LinkedIn profile as a parsed resume object."""
683
+ if not linkedin_nango.NANGO_SECRET_KEY:
684
+ raise HTTPException(status_code=503, detail="NANGO_SECRET_KEY not configured")
685
  entity_id = user.get("clerk_id") or user.get("id") or "default"
686
  try:
687
+ data = linkedin_nango.import_profile(entity_id)
688
  return {"ok": True, "data": data}
689
  except Exception as e:
690
  logger.error("LinkedIn import error: %s", e)
 
694
  @app.post("/api/linkedin/enrich-companies")
695
  def linkedin_enrich_companies(req: LinkedInEnrichRequest, user: dict = Depends(get_current_user)):
696
  """Enrich a list of job dicts with LinkedIn company_info. Skips silently on failure."""
697
+ if not linkedin_nango.NANGO_SECRET_KEY:
698
  return {"ok": True, "jobs": req.jobs}
699
  entity_id = req.entity_id or user.get("clerk_id") or user.get("id") or "default"
700
+ enriched = linkedin_nango.enrich_companies(req.jobs, entity_id)
701
  return {"ok": True, "jobs": enriched}
702
 
703
 
backend/requirements.txt CHANGED
@@ -17,4 +17,3 @@ stripe>=8.0.0
17
  python-dotenv>=1.0.0
18
  # Deploy trigger v4.9.0
19
  python-jobspy>=1.1.80
20
- composio-core>=0.7.21
 
17
  python-dotenv>=1.0.0
18
  # Deploy trigger v4.9.0
19
  python-jobspy>=1.1.80
 
backend/services/linkedin_composio.py DELETED
@@ -1,230 +0,0 @@
1
- """
2
- LinkedIn integration via Composio.
3
- Provides profile import and company info enrichment.
4
-
5
- Actions used:
6
- LINKEDIN_GET_MY_INFO β€” fetch authenticated user's profile
7
- LINKEDIN_GET_COMPANY_INFO β€” fetch company details by name/vanity
8
-
9
- Requires:
10
- COMPOSIO_API_KEY in environment
11
- User must have connected their LinkedIn account via /api/linkedin/auth-url
12
- """
13
- import os
14
- import logging
15
- from typing import Dict, List, Optional
16
-
17
- logger = logging.getLogger(__name__)
18
-
19
- COMPOSIO_API_KEY = os.environ.get("COMPOSIO_API_KEY", "")
20
-
21
- _company_cache: Dict[str, dict] = {}
22
-
23
-
24
- def _toolset(entity_id: str):
25
- from composio import ComposioToolSet
26
- return ComposioToolSet(api_key=COMPOSIO_API_KEY, entity_id=entity_id)
27
-
28
-
29
- def get_oauth_url(entity_id: str, redirect_url: str) -> str:
30
- """Return LinkedIn OAuth URL for the given user entity."""
31
- from composio import Composio, App
32
- client = Composio(api_key=COMPOSIO_API_KEY)
33
- entity = client.get_entity(id=entity_id)
34
- conn = entity.initiate_connection(app_name=App.LINKEDIN, redirect_url=redirect_url)
35
- if not conn.redirectUrl:
36
- raise RuntimeError("Composio did not return a redirect URL")
37
- return conn.redirectUrl
38
-
39
-
40
- def is_connected(entity_id: str) -> bool:
41
- """Return True if user has a LinkedIn connected account."""
42
- try:
43
- from composio import Composio, App
44
- client = Composio(api_key=COMPOSIO_API_KEY)
45
- entity = client.get_entity(id=entity_id)
46
- entity.get_connection(app="linkedin")
47
- return True
48
- except Exception:
49
- return False
50
-
51
-
52
- def import_profile(entity_id: str) -> dict:
53
- """
54
- Fetch user's LinkedIn profile and return it in the same shape
55
- as resume_matcher_ai.parse_resume_structured().
56
- """
57
- ts = _toolset(entity_id)
58
- result = ts.execute_action(
59
- action="LINKEDIN_GET_MY_INFO",
60
- params={},
61
- entity_id=entity_id,
62
- )
63
-
64
- if not result.get("successfull") and not result.get("successful"):
65
- raise RuntimeError(f"LinkedIn profile fetch failed: {result.get('error', result)}")
66
-
67
- data = result.get("data", result)
68
- return _map_profile(data)
69
-
70
-
71
- def enrich_companies(jobs: List[dict], entity_id: str) -> List[dict]:
72
- """
73
- Attach company_info to each job using LINKEDIN_GET_COMPANY_INFO.
74
- Deduplicates lookups and caches results. Failures are silent β€” the
75
- job is returned unchanged.
76
- """
77
- if not COMPOSIO_API_KEY:
78
- return jobs
79
-
80
- try:
81
- ts = _toolset(entity_id)
82
- except Exception as e:
83
- logger.warning("Composio toolset init failed: %s", e)
84
- return jobs
85
-
86
- unique_companies = {j.get("company", "") for j in jobs if j.get("company")}
87
-
88
- for company_name in unique_companies:
89
- if company_name in _company_cache:
90
- continue
91
- try:
92
- result = ts.execute_action(
93
- action="LINKEDIN_GET_COMPANY_INFO",
94
- params={"company_name": company_name},
95
- entity_id=entity_id,
96
- )
97
- ok = result.get("successfull") or result.get("successful")
98
- if ok:
99
- raw = result.get("data", {})
100
- _company_cache[company_name] = _map_company(raw)
101
- else:
102
- _company_cache[company_name] = {}
103
- except Exception as e:
104
- logger.debug("Company enrichment failed for %s: %s", company_name, e)
105
- _company_cache[company_name] = {}
106
-
107
- for job in jobs:
108
- name = job.get("company", "")
109
- info = _company_cache.get(name)
110
- if info:
111
- job["company_info"] = info
112
-
113
- return jobs
114
-
115
-
116
- # ── Mappers ────────────────────────────────────────────────────────────────
117
-
118
- def _map_profile(data: dict) -> dict:
119
- """Convert LinkedIn profile response to resume_matcher_ai schema."""
120
- first = _deep_localized(data.get("firstName", {}))
121
- last = _deep_localized(data.get("lastName", {}))
122
- name = f"{first} {last}".strip() or data.get("name", "")
123
-
124
- headline = _deep_localized(data.get("headline", {})) or data.get("headline", "")
125
-
126
- location_data = data.get("location", {})
127
- location = (
128
- location_data.get("name", "") if isinstance(location_data, dict)
129
- else str(location_data)
130
- )
131
-
132
- email = ""
133
- for elem in (data.get("elements") or []):
134
- handle = elem.get("handle~", {})
135
- if handle.get("emailAddress"):
136
- email = handle["emailAddress"]
137
- break
138
-
139
- positions = data.get("positions", {}).get("values", []) or data.get("positions", [])
140
- work_experience = []
141
- for pos in positions:
142
- company_obj = pos.get("company", {})
143
- company_name = company_obj.get("name", "") if isinstance(company_obj, dict) else ""
144
- start = pos.get("startDate", {})
145
- end = pos.get("endDate", {})
146
- start_str = f"{start.get('year', '')}" if start else ""
147
- end_str = f"{end.get('year', '')}" if end else "Present"
148
- work_experience.append({
149
- "title": pos.get("title", ""),
150
- "company": company_name,
151
- "start_date": start_str,
152
- "end_date": end_str,
153
- "description": pos.get("summary", ""),
154
- "achievements": [],
155
- })
156
-
157
- educations = data.get("educations", {}).get("values", []) or data.get("educations", [])
158
- education = []
159
- for edu in educations:
160
- start = edu.get("startDate", {})
161
- end = edu.get("endDate", {})
162
- education.append({
163
- "degree": edu.get("degree", ""),
164
- "institution": edu.get("schoolName", ""),
165
- "field": edu.get("fieldOfStudy", ""),
166
- "graduation_year": str(end.get("year", "")) if end else "",
167
- "gpa": None,
168
- })
169
-
170
- skills_data = data.get("skills", {}).get("values", []) or data.get("skills", [])
171
- skills = []
172
- for s in skills_data:
173
- name_val = s.get("skill", {}).get("name", "") if isinstance(s.get("skill"), dict) else s.get("name", "")
174
- if name_val:
175
- skills.append(name_val)
176
-
177
- return {
178
- "personal_info": {
179
- "name": name,
180
- "email": email,
181
- "phone": "",
182
- "location": location,
183
- "linkedin": data.get("publicProfileUrl", ""),
184
- "website": "",
185
- },
186
- "summary": headline,
187
- "skills": skills,
188
- "technical_skills": {"languages": [], "frameworks": [], "tools": [], "databases": [], "cloud": []},
189
- "work_experience": work_experience,
190
- "education": education,
191
- "certifications": [],
192
- "projects": [],
193
- "total_years_experience": None,
194
- "seniority_level": "unknown",
195
- "_source": "linkedin",
196
- }
197
-
198
-
199
- def _map_company(data: dict) -> dict:
200
- """Extract relevant company fields from LinkedIn company response."""
201
- name_obj = data.get("name", {})
202
- name = _deep_localized(name_obj) if isinstance(name_obj, dict) else str(name_obj)
203
-
204
- industries = []
205
- for ind in (data.get("industries", {}).get("values", []) or data.get("industries", [])):
206
- if isinstance(ind, dict):
207
- industries.append(ind.get("name", ""))
208
- elif isinstance(ind, str):
209
- industries.append(ind)
210
-
211
- return {
212
- "name": name,
213
- "size": data.get("staffCount") or data.get("employeeCount"),
214
- "industry": industries[0] if industries else "",
215
- "followers": data.get("followersCount"),
216
- "linkedin_url": data.get("companyPageUrl") or data.get("url", ""),
217
- "description": _deep_localized(data.get("description", {})) or data.get("description", ""),
218
- }
219
-
220
-
221
- def _deep_localized(obj) -> str:
222
- """Extract text from LinkedIn's localized string objects."""
223
- if isinstance(obj, str):
224
- return obj
225
- if isinstance(obj, dict):
226
- localized = obj.get("localized", {})
227
- if localized:
228
- return next(iter(localized.values()), "")
229
- return obj.get("preferredLocale", {}).get("country", "") or ""
230
- return ""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
backend/services/linkedin_nango.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LinkedIn integration via Nango proxy (replaces Composio)."""
2
+ import os
3
+ import logging
4
+ import httpx
5
+ from typing import Dict, List, Optional
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+ NANGO_SECRET_KEY = os.environ.get("NANGO_SECRET_KEY", "")
10
+ NANGO_API = "https://api.nango.dev"
11
+ PROVIDER = "linkedin"
12
+
13
+ _company_cache: Dict[str, dict] = {}
14
+
15
+
16
+ def _auth_headers() -> dict:
17
+ return {"Authorization": f"Bearer {NANGO_SECRET_KEY}"}
18
+
19
+
20
+ def _proxy_headers(connection_id: str) -> dict:
21
+ return {
22
+ "Authorization": f"Bearer {NANGO_SECRET_KEY}",
23
+ "Provider-Config-Key": PROVIDER,
24
+ "Connection-Id": connection_id,
25
+ }
26
+
27
+
28
+ def get_connect_url(user_id: str, redirect_url: str) -> str:
29
+ """Create a Nango connect session and return the hosted connect_link."""
30
+ resp = httpx.post(
31
+ f"{NANGO_API}/connect/sessions",
32
+ headers={**_auth_headers(), "Content-Type": "application/json"},
33
+ json={
34
+ "end_user": {"id": user_id},
35
+ "allowed_integrations": [PROVIDER],
36
+ "callback_url": redirect_url,
37
+ },
38
+ timeout=10,
39
+ )
40
+ resp.raise_for_status()
41
+ return resp.json()["connect_link"]
42
+
43
+
44
+ def _get_connection_id(user_id: str) -> Optional[str]:
45
+ """Return the Nango connection_id for the user's LinkedIn connection."""
46
+ try:
47
+ resp = httpx.get(
48
+ f"{NANGO_API}/connections",
49
+ headers=_auth_headers(),
50
+ params={"tags[end_user_id]": user_id, "provider_config_key": PROVIDER},
51
+ timeout=10,
52
+ )
53
+ if resp.status_code != 200:
54
+ return None
55
+ connections = resp.json().get("connections", [])
56
+ return connections[0].get("connection_id") if connections else None
57
+ except Exception as e:
58
+ logger.debug("Nango connection lookup failed: %s", e)
59
+ return None
60
+
61
+
62
+ def is_connected(user_id: str) -> bool:
63
+ return _get_connection_id(user_id) is not None
64
+
65
+
66
+ def import_profile(user_id: str) -> dict:
67
+ conn_id = _get_connection_id(user_id)
68
+ if not conn_id:
69
+ raise RuntimeError("LinkedIn not connected β€” please connect via /api/linkedin/auth-url")
70
+
71
+ headers = _proxy_headers(conn_id)
72
+
73
+ # OpenID Connect userinfo β€” name, email, picture
74
+ ui_resp = httpx.get(f"{NANGO_API}/proxy/v2/userinfo", headers=headers, timeout=15)
75
+ ui_resp.raise_for_status()
76
+ userinfo = ui_resp.json()
77
+
78
+ # Basic profile β€” headline, location, vanityName
79
+ me_resp = httpx.get(
80
+ f"{NANGO_API}/proxy/v2/me",
81
+ headers=headers,
82
+ params={"projection": "(id,headline,location,vanityName)"},
83
+ timeout=15,
84
+ )
85
+ me = me_resp.json() if me_resp.status_code == 200 else {}
86
+
87
+ return _map_profile(userinfo, me)
88
+
89
+
90
+ def enrich_companies(jobs: List[dict], user_id: str) -> List[dict]:
91
+ """LinkedIn company enrichment requires partner API access β€” returns jobs unchanged."""
92
+ return jobs
93
+
94
+
95
+ # ── Mappers ────────────────────────────────────────────────────────────────
96
+
97
+ def _localized(obj) -> str:
98
+ if isinstance(obj, str):
99
+ return obj
100
+ if isinstance(obj, dict):
101
+ loc = obj.get("localized", {})
102
+ if loc:
103
+ return next(iter(loc.values()), "")
104
+ return ""
105
+
106
+
107
+ def _map_profile(userinfo: dict, me: dict) -> dict:
108
+ name = userinfo.get("name", "")
109
+ if not name:
110
+ name = f"{userinfo.get('given_name', '')} {userinfo.get('family_name', '')}".strip()
111
+
112
+ headline = _localized(me.get("headline", ""))
113
+
114
+ location_obj = me.get("location", {})
115
+ location = ""
116
+ if isinstance(location_obj, dict):
117
+ location = location_obj.get("geographicArea", "") or location_obj.get("country", {}).get("code", "")
118
+ elif isinstance(location_obj, str):
119
+ location = location_obj
120
+
121
+ vanity = me.get("vanityName", "")
122
+ linkedin_url = f"https://www.linkedin.com/in/{vanity}" if vanity else ""
123
+
124
+ return {
125
+ "personal_info": {
126
+ "name": name,
127
+ "email": userinfo.get("email", ""),
128
+ "phone": "",
129
+ "location": location,
130
+ "linkedin": linkedin_url,
131
+ "website": "",
132
+ },
133
+ "summary": headline,
134
+ "skills": [],
135
+ "technical_skills": {"languages": [], "frameworks": [], "tools": [], "databases": [], "cloud": []},
136
+ "work_experience": [],
137
+ "education": [],
138
+ "certifications": [],
139
+ "projects": [],
140
+ "total_years_experience": None,
141
+ "seniority_level": "unknown",
142
+ "_source": "linkedin_nango",
143
+ }