""" Self Vision API — core orchestration module. Implements the 7-step execution flow: 1. Validate request 2. Fetch onboarding context from Pinecone 3. Normalize context 4. Build LLM input (with truncation) 5. Generate Self Vision via Gemini 6. Persist raw transcript to Pinecone 7. Return response with observability metadata """ import os import time import logging from google import genai from dotenv import load_dotenv from pinecone_client import PineconeClient, KEY_ONBOARDING, KEY_SELF_VISION, KEY_ROADMAP from context_processor import build_llm_input, count_words load_dotenv() logger = logging.getLogger("self_vision.api") # ── Singleton Pinecone client ── _pinecone: PineconeClient | None = None def _get_pinecone() -> PineconeClient: global _pinecone if _pinecone is None: _pinecone = PineconeClient() return _pinecone # ── Singleton Gemini client ── _gemini_client = None def _get_gemini(): global _gemini_client if _gemini_client is None: api_key = os.getenv("GEMINI_API_KEY", "") if not api_key: raise RuntimeError("GEMINI_API_KEY not set") _gemini_client = genai.Client(api_key=api_key) return _gemini_client # ───────────────────────────────────────────────────────────────────── # STEP 1 — Validation # ───────────────────────────────────────────────────────────────────── def validate_request(data: dict) -> dict | None: """ Validate the incoming request payload. Returns an error dict if invalid, or None if valid. """ if not data: return {"error": "request body required", "status_code": 400} user_id = data.get("user_id") if user_id is None or (isinstance(user_id, str) and not user_id.strip()): return {"error": "user_id required", "status_code": 400} raw = data.get("raw_transcription") if raw is None: return {"error": "raw_transcription required", "status_code": 400} if isinstance(raw, str) and not raw.strip(): return {"error": "empty transcript", "status_code": 400} return None # ───────────────────────────────────────────────────────────────────── # STEP 5 — LLM Generation # ───────────────────────────────────────────────────────────────────── SELF_VISION_SYSTEM_PROMPT = """\ You are a career Self-Vision assistant. You receive three blocks of context: 1. **[ONBOARDING_CONTEXT]** — the user's onboarding conversation from a previous POC session. This contains their background, aspirations, and initial career information. 2. **[ROADMAP_CONTEXT]** — the user's generated career roadmap (including target role, milestones, vision profile, etc.). 3. **[SELF_VISION_TRANSCRIPT]** — the current Self-Vision session transcript capturing the user's reflections, goals, and career vision. Optionally you also receive: 4. **[LESSON_CONTEXT]** — additional lesson or learning context. Your job: - Synthesize the onboarding context with the current self-vision transcript. - Produce a rich, actionable **Self Vision** output that reflects the user's complete journey so far. - Maintain continuity: reference earlier context naturally. - Be specific, encouraging, and grounded in what the user has shared. - Output structured JSON with the following shape: { "vision_summary": "A 2-3 paragraph narrative synthesis of the user's self-vision", "key_themes": ["theme1", "theme2", ...], "strengths_identified": ["strength1", "strength2", ...], "growth_areas": ["area1", "area2", ...], "next_steps": ["step1", "step2", ...], "continuity_notes": "How this connects to their onboarding journey" } """ def _generate_self_vision(combined_text: str) -> dict: """ Call Gemini to generate the Self Vision result. Returns the parsed JSON result or a fallback dict on failure. """ client = _get_gemini() try: response = client.models.generate_content( model="gemini-2.5-flash", contents=[ {"role": "user", "parts": [{"text": f"{SELF_VISION_SYSTEM_PROMPT}\n\n---\n\n{combined_text}"}]} ], ) # Extract text from response result_text = "" if response.text: result_text = response.text # Try to parse as JSON import json import re # Strip markdown code fences if present cleaned = result_text.strip() cleaned = re.sub(r'^```(?:json)?\s*', '', cleaned) cleaned = re.sub(r'\s*```$', '', cleaned) try: return json.loads(cleaned) except (json.JSONDecodeError, ValueError): # Return as unstructured text if not valid JSON return { "vision_summary": result_text, "key_themes": [], "strengths_identified": [], "growth_areas": [], "next_steps": [], "continuity_notes": "", } except Exception as exc: logger.error("LLM generation failed: %s", exc) raise RuntimeError(f"self vision generation failed: {exc}") # ───────────────────────────────────────────────────────────────────── # Main orchestration # ───────────────────────────────────────────────────────────────────── def run_self_vision( user_id: str, raw_transcription: str, lesson_context: str = "", session_metadata: dict | None = None, ) -> dict: """ Execute the full Self Vision pipeline. Returns a dict with: - self_vision_result: the LLM-generated output - raw_transcription: the original transcript (echoed back) - metadata: observability data """ total_start = time.time() # ── STEP 1 — Validate (already done by caller, but double-check) ── validation_err = validate_request({ "user_id": user_id, "raw_transcription": raw_transcription, }) if validation_err: return {"error": validation_err["error"], "status_code": validation_err["status_code"]} pc = _get_pinecone() # ── STEP 2 — Fetch prior context (Onboarding and Roadmap) ── logger.info("Fetching onboarding context for user_id=%s", user_id) fetch_result = pc.fetch_conversation(user_id, KEY_ONBOARDING) onboarding_text = fetch_result["text"] fetch_status = fetch_result["status"] logger.info("Fetching roadmap context for user_id=%s", user_id) fetch_roadmap_result = pc.fetch_conversation(user_id, KEY_ROADMAP) roadmap_text = fetch_roadmap_result["text"] fetch_roadmap_status = fetch_roadmap_result["status"] # ── STEP 3 + 4 + 5 — Normalize, build input, apply truncation ── llm_input = build_llm_input( onboarding_text=onboarding_text, transcript_text=raw_transcription, roadmap_text=roadmap_text, lesson_context=lesson_context, ) context_word_count = llm_input["total_word_count"] truncation_applied = llm_input["truncation_applied"] logger.info( "LLM input built: words=%d truncated=%s", context_word_count, truncation_applied, ) # ── STEP 6 — Generate Self Vision ── try: self_vision_result = _generate_self_vision(llm_input["combined_text"]) except RuntimeError as exc: total_elapsed = (time.time() - total_start) * 1000 return { "error": "self vision generation failed", "status_code": 500, "metadata": { "user_id": user_id, "fetch_status": fetch_status, "fetch_roadmap_status": fetch_roadmap_status, "upsert_status": "skipped", "context_word_count": context_word_count, "truncation_applied": truncation_applied, "execution_time_ms": round(total_elapsed, 1), }, } # ── STEP 7 — Persist raw transcript ── logger.info("Upserting self_vision_conversation for user_id=%s", user_id) upsert_result = pc.upsert_conversation( user_id=user_id, key=KEY_SELF_VISION, text=raw_transcription, # Store RAW transcript, not LLM output ) upsert_status = upsert_result["status"] total_elapsed = (time.time() - total_start) * 1000 # ── Build response ── logger.info( "Self Vision complete: user_id=%s fetch=%s fetch_roadmap=%s upsert=%s time=%.0fms", user_id, fetch_status, fetch_roadmap_status, upsert_status, total_elapsed, ) return { "self_vision_result": self_vision_result, "raw_transcription": raw_transcription, "metadata": { "user_id": user_id, "fetch_status": fetch_status, "fetch_roadmap_status": fetch_roadmap_status, "upsert_status": upsert_status, "context_word_count": context_word_count, "truncation_applied": truncation_applied, "execution_time_ms": round(total_elapsed, 1), }, }