scn-consultation-api / src /pinecone_extractor.py
SS-2005's picture
Initial HF Space deployment
59ec65a
Raw
History Blame Contribute Delete
11.7 kB
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)
# 🔥 Read actual index dimension dynamically
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}"
)
# -------------------------------------------------------
# Public API
# -------------------------------------------------------
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")
# -------------------------------------------
# Conversation Summary (Highest Signal)
# -------------------------------------------
if doc_type == "conversation_summary":
if not context["summary"]:
context["summary"] = meta.get("text")
# -------------------------------------------
# Resume Summary
# -------------------------------------------
elif doc_type == "resume_summary":
text = meta.get("text", "")
if not context["resume_summary"]:
context["resume_summary"] = text
# Extract structured fields from text if needed
self._extract_from_resume_text(text, context)
# -------------------------------------------
# Optional structured metadata types
# -------------------------------------------
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", []))
# Deduplicate lists
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
# -------------------------------------------------------
# Internal Helpers
# -------------------------------------------------------
def _extract_from_resume_text(self, text: str, context: Dict) -> None:
if not text:
return
# ---------------------------
# Extract Name
# ---------------------------
if not context["user_name"]:
name_match = re.search(r"Name:\s*(.+)", text)
if name_match:
context["user_name"] = name_match.group(1).strip()
# ---------------------------
# Extract Role
# ---------------------------
if not context["role"]:
role_match = re.search(r"Role:\s*(.+)", text)
if role_match:
context["role"] = role_match.group(1).strip()
# ---------------------------
# Extract Age
# ---------------------------
if not context["age"]:
age_match = re.search(r"Age:\s*(\d+)", text)
if age_match:
context["age"] = int(age_match.group(1))
# ---------------------------
# Extract Skills Block
# ---------------------------
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)
# ---------------------------
# Extract Learning Goals
# ---------------------------
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)
# ---------------------------
# Extract Hobbies
# ---------------------------
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", []):
# Normalize milestone-level fields
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
""
)
# Normalize module-level fields
for module in milestone.get("modules", []):
if not module.get("module_id"):
module["module_id"] = module.get("id", "")
# Normalize skill-level fields
for skill in module.get("skills", []):
if not skill.get("description"):
skill["description"] = skill.get("n", "")
# Ensure why_this_skill exists (needed for Beat 5)
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