| import os |
| import logging |
| import re |
| from typing import Dict, Optional |
|
|
| from pinecone import Pinecone |
| from dotenv import load_dotenv |
|
|
| load_dotenv() |
| logger = logging.getLogger(__name__) |
|
|
|
|
| class PineconeUserContextExtractor: |
| """ |
| Extracts structured user context from Pinecone. |
| |
| Design goals: |
| - Stable contract for SCN |
| - Extensible for future user attributes |
| - Safe against missing metadata |
| - No hardcoded vector dimension |
| """ |
|
|
| def __init__(self, index_name: Optional[str] = None): |
| self.index_name = index_name or os.getenv("PINECONE_INDEX_NAME") |
| self.api_key = os.getenv("PINECONE_API_KEY") |
|
|
| if not self.api_key: |
| raise ValueError("PINECONE_API_KEY not set") |
|
|
| if not self.index_name: |
| raise ValueError("PINECONE_INDEX_NAME not set") |
|
|
| self.pc = Pinecone(api_key=self.api_key) |
| self.index = self.pc.Index(self.index_name) |
|
|
| |
| stats = self.index.describe_index_stats() |
| self.dimension = stats.get("dimension") |
|
|
| if not self.dimension: |
| raise RuntimeError("Could not determine Pinecone index dimension") |
|
|
| logger.info( |
| f"Pinecone extractor initialized | index={self.index_name}, dimension={self.dimension}" |
| ) |
|
|
| |
| |
| |
|
|
| def get_user_context(self, namespace: str) -> Dict: |
| """ |
| Returns structured user context. |
| |
| Output contract (stable): |
| { |
| user_name: str | None, |
| role: str | None, |
| age: int | None, |
| summary: str | None, |
| resume_summary: str | None, |
| skills: list[str], |
| interests: list[str], |
| traits: list[str], |
| } |
| """ |
|
|
| context = { |
| "user_name": None, |
| "role": None, |
| "age": None, |
| "summary": None, |
| "resume_summary": None, |
| "skills": [], |
| "interests": [], |
| "traits": [], |
| } |
|
|
| try: |
| dummy_vector = [0.0] * self.dimension |
|
|
| results = self.index.query( |
| vector=dummy_vector, |
| top_k=20, |
| include_metadata=True, |
| namespace=str(namespace), |
| ) |
|
|
| matches = results.get("matches", []) or [] |
|
|
| for match in matches: |
| meta = match.get("metadata") or {} |
| doc_type = meta.get("doc_type") |
|
|
| |
| |
| |
| if doc_type == "conversation_summary": |
| if not context["summary"]: |
| context["summary"] = meta.get("text") |
|
|
| |
| |
| |
| elif doc_type == "resume_summary": |
| text = meta.get("text", "") |
|
|
| if not context["resume_summary"]: |
| context["resume_summary"] = text |
|
|
| |
| self._extract_from_resume_text(text, context) |
|
|
| |
| |
| |
| elif doc_type == "skills": |
| context["skills"].extend(meta.get("items", [])) |
|
|
| elif doc_type == "interests": |
| context["interests"].extend(meta.get("items", [])) |
|
|
| elif doc_type == "traits": |
| context["traits"].extend(meta.get("items", [])) |
|
|
| |
| for key in ["skills", "interests", "traits"]: |
| context[key] = list(set(context[key])) |
|
|
| logger.info( |
| f"Pinecone context extracted | namespace={namespace} | " |
| f"summary={'YES' if context['summary'] else 'NO'} | " |
| f"resume={'YES' if context['resume_summary'] else 'NO'}" |
| ) |
|
|
| except Exception as e: |
| logger.error( |
| f"Failed to extract Pinecone context for namespace={namespace}: {e}", |
| exc_info=True |
| ) |
|
|
| return context |
|
|
| |
| |
| |
|
|
| def _extract_from_resume_text(self, text: str, context: Dict) -> None: |
| if not text: |
| return |
|
|
| |
| |
| |
| if not context["user_name"]: |
| name_match = re.search(r"Name:\s*(.+)", text) |
| if name_match: |
| context["user_name"] = name_match.group(1).strip() |
|
|
| |
| |
| |
| if not context["role"]: |
| role_match = re.search(r"Role:\s*(.+)", text) |
| if role_match: |
| context["role"] = role_match.group(1).strip() |
|
|
| |
| |
| |
| if not context["age"]: |
| age_match = re.search(r"Age:\s*(\d+)", text) |
| if age_match: |
| context["age"] = int(age_match.group(1)) |
|
|
| |
| |
| |
| skills_match = re.search(r"Skills:\s*(.*?)\n\n", text, re.DOTALL) |
| if skills_match: |
| skills_block = skills_match.group(1) |
|
|
| skill_lines = re.findall(r"-\s*(.+)", skills_block) |
|
|
| parsed_skills = [] |
| for line in skill_lines: |
| parts = [s.strip() for s in line.split(",")] |
| parsed_skills.extend(parts) |
|
|
| context["skills"].extend(parsed_skills) |
|
|
| |
| |
| |
| goals_match = re.search(r"Learning Goals:\s*(.*?)\n\n", text, re.DOTALL) |
| if goals_match: |
| goals_block = goals_match.group(1) |
| goals = re.findall(r"-\s*(.+)", goals_block) |
| context["interests"].extend(goals) |
|
|
| |
| |
| |
| hobby_match = re.search(r"Hobbies:\s*(.*?)$", text, re.DOTALL) |
| if hobby_match: |
| hobby_block = hobby_match.group(1) |
| hobbies = re.findall(r"-\s*(.+)", hobby_block) |
| context["interests"].extend(hobbies) |
|
|
| def get_latest_roadmap(self, user_id: str) -> Optional[dict]: |
| import json |
|
|
| try: |
| dummy_vector = [0.0] * self.dimension |
|
|
| response = self.index.query( |
| vector=dummy_vector, |
| top_k=10, |
| namespace=str(user_id), |
| include_metadata=True, |
| filter={"doc_type": {"$eq": "roadmap"}} |
| ) |
|
|
| matches = response.get("matches", []) or [] |
|
|
| if not matches: |
| logger.warning(f"[ROADMAP FETCH] No roadmap found for user: {user_id}") |
| return None |
|
|
| matches.sort( |
| key=lambda x: (x.get("metadata") or {}).get("generated_at", ""), |
| reverse=True |
| ) |
|
|
| best_metadata = matches[0].get("metadata") or {} |
|
|
| if not best_metadata.get("full_roadmap_stored"): |
| logger.warning(f"[ROADMAP FETCH] full_roadmap_stored=False for user: {user_id}") |
| return None |
|
|
| raw_json = best_metadata.get("full_roadmap_json", "") |
| if not raw_json: |
| return None |
|
|
| roadmap = json.loads(raw_json) |
| roadmap = self._normalize_roadmap(roadmap) |
|
|
| logger.info( |
| f"[ROADMAP FETCH] ✓ Roadmap retrieved for user {user_id} | " |
| f"milestones={len(roadmap.get('milestones', []))} | " |
| f"target_role={roadmap.get('target_role', 'unknown')}" |
| ) |
| return roadmap |
|
|
| except Exception as e: |
| logger.error(f"[ROADMAP FETCH] Error: {e}", exc_info=True) |
| return None |
|
|
|
|
| def _normalize_roadmap(self, roadmap: dict) -> dict: |
| """ |
| Normalizes abbreviated milestone keys to standard names. |
| Handles both old schema (abbreviated) and new schema (full names). |
| """ |
| for milestone in roadmap.get("milestones", []): |
|
|
| |
| if not milestone.get("identity_label"): |
| milestone["identity_label"] = ( |
| milestone.get("t") or |
| milestone.get("label") or |
| milestone.get("milestone_id", "") |
| ) |
|
|
| if not milestone.get("market_value_display"): |
| milestone["market_value_display"] = ( |
| milestone.get("sal") or |
| milestone.get("salary") or |
| "" |
| ) |
|
|
| if not milestone.get("identity_statement"): |
| milestone["identity_statement"] = ( |
| milestone.get("o") or |
| milestone.get("outcome") or |
| "" |
| ) |
|
|
| |
| for module in milestone.get("modules", []): |
| if not module.get("module_id"): |
| module["module_id"] = module.get("id", "") |
|
|
| |
| for skill in module.get("skills", []): |
| if not skill.get("description"): |
| skill["description"] = skill.get("n", "") |
|
|
| |
| if not skill.get("why_this_skill"): |
| scenario = skill.get("content_flow", {}).get("scenario", {}) |
| skill["why_this_skill"] = ( |
| f"This skill is tested in: {scenario.get('title', '')}" |
| if scenario.get("title") else "" |
| ) |
|
|
| return roadmap |
|
|
| def get_onboarding_summary(self, user_id: str) -> Optional[dict]: |
| """ |
| Fetch structured onboarding summary stored by seed script or memory_service. |
| Returns the parsed summary dict or None. |
| """ |
| import json |
| try: |
| vector_id = f"{user_id}_onboarding_summary" |
| fetch_result = self.index.fetch( |
| ids=[vector_id], |
| namespace=str(user_id) |
| ) |
| if not (fetch_result and fetch_result.vectors and vector_id in fetch_result.vectors): |
| logger.warning(f"[SUMMARY] No onboarding_summary vector for: {user_id}") |
| return None |
|
|
| metadata = fetch_result.vectors[vector_id].metadata or {} |
| text = metadata.get("text", "") |
| if not text: |
| return None |
|
|
| data = json.loads(text) |
| logger.info(f"[SUMMARY] Found onboarding_summary for: {user_id}") |
| return data |
|
|
| except Exception as e: |
| logger.error(f"[SUMMARY] Error fetching onboarding_summary for {user_id}: {e}") |
| return None |