"""LangGraph interview orchestration graph.""" import logging import os from datetime import datetime, timezone from langchain_core.messages import SystemMessage from langgraph.checkpoint.memory import MemorySaver from langgraph.graph import END, StateGraph from langgraph.prebuilt import ToolNode, tools_condition from src.memory import build_cross_section_context, compact_phase from src.prompts import build_jd_targeted_prompt, build_resume_based_prompt, build_system_prompt from src.state import PHASE_CONFIG, QUICK_DEMO_PHASE_CONFIG, InterviewState from src.tools import ALL_TOOLS log = logging.getLogger("bodhi.graph") def _pop_next_question(state: dict, phase: str) -> str: """Pop the next question from the queued_questions for a given phase. Returns the question string, or '' if the queue is empty or missing.""" queued = state.get("queued_questions", {}) if not isinstance(queued, dict): return "" phase_q = queued.get(phase, []) if not phase_q: return "" # Pop the first question next_q = phase_q[0] # Update the queue (remove the popped question) new_queued = dict(queued) new_queued[phase] = phase_q[1:] state["queued_questions"] = new_queued log.info(f"[QUEUE] Popped question for {phase}: {next_q}") log.info(f"[QUEUE] Remaining {phase} questions: {len(phase_q) - 1}") return next_q def _process_tool_results(state: InterviewState) -> dict: """Graph node: interpret tool outputs and update state accordingly. Handles the new multi-dimensional SCORE format: SCORE:{composite}:{a},{d},{c},{conf}:{PROBE|NOPROBE}:{probe_reason}:{feedback} On TRANSITION: pops the first question from the new phase queue. On SCORE: pops the next question from the current phase queue; tracks probing. Demo mode: Prevents phase transitions when demo_mode=True. """ updates: dict = {} last_msg = state["messages"][-1] if state["messages"] else None if last_msg is None: return updates content = last_msg.content if hasattr(last_msg, "content") else str(last_msg) if content.startswith("TRANSITION:"): # Block transitions in demo mode if state.get("demo_mode", False): log.info("[GRAPH] Demo mode: blocking phase transition") return updates new_phase = content.split(":", 1)[1] updates["current_phase"] = new_phase # Pop first question for the new phase next_q = _pop_next_question(state, new_phase) updates["target_question"] = next_q updates["queued_questions"] = state.get("queued_questions", {}) updates["phase_question_count"] = 0 updates["phase_start_time"] = datetime.now(timezone.utc).isoformat() updates["pending_probe"] = "" log.info(f"[GRAPH] Phase transition → {new_phase}, target_question: {next_q[:80] if next_q else '(ad-hoc)'}") elif content.startswith("SCORE:"): # Parse: SCORE:{composite}:{a},{d},{c},{conf}:{PROBE|NOPROBE}:{probe_reason}:{feedback} parts = content.split(":", 5) try: composite = float(parts[1]) metrics_str = parts[2] if len(parts) > 2 else "3,3,3,3" metrics = [int(x) for x in metrics_str.split(",")] accuracy = metrics[0] if len(metrics) > 0 else 3 depth_score = metrics[1] if len(metrics) > 1 else 3 comm = metrics[2] if len(metrics) > 2 else 3 conf = metrics[3] if len(metrics) > 3 else 3 except (ValueError, IndexError): composite = 3.0 accuracy = depth_score = comm = conf = 3 probe_flag = parts[3] if len(parts) > 3 else "NOPROBE" probe_reason = parts[4] if len(parts) > 4 else "" feedback = parts[5] if len(parts) > 5 else "" phase = state["current_phase"] scores = dict(state.get("phase_scores", {})) prev = scores.get(phase, {"total_score": 0, "questions": 0, "feedback": []}) prev = dict(prev) prev["total_score"] = prev.get("total_score", 0) + composite prev["questions"] = prev.get("questions", 0) + 1 prev["feedback"] = list(prev.get("feedback", [])) + [feedback] scores[phase] = prev updates["phase_scores"] = scores # Track per-question score details answer_scores = list(state.get("answer_scores", [])) q_count = state.get("phase_question_count", 0) + 1 answer_scores.append({ "phase": phase, "question_num": q_count, "accuracy": accuracy, "depth": depth_score, "communication": comm, "confidence": conf, "composite": composite, "feedback": feedback, "probed": probe_flag == "PROBE", "probe_reason": probe_reason, }) updates["answer_scores"] = answer_scores updates["phase_question_count"] = q_count # Handle probing if probe_flag == "PROBE" and probe_reason: updates["pending_probe"] = probe_reason log.info(f"[GRAPH] Probe requested: {probe_reason}") else: updates["pending_probe"] = "" # Pop next question from current phase queue (only if not probing) if probe_flag != "PROBE": next_q = _pop_next_question(state, phase) updates["target_question"] = next_q updates["queued_questions"] = state.get("queued_questions", {}) # In demo mode, auto-end after max questions if state.get("demo_mode", False): from src.state import DEMO_PHASE_CONFIG demo_phase = state.get("demo_phase", phase) max_q = DEMO_PHASE_CONFIG.get(demo_phase, {}).get("max_questions", 3) if q_count >= max_q: updates["should_end"] = True log.info(f"[GRAPH] Demo mode: reached max questions ({max_q}), ending session") # In quick_demo mode, auto-transition after max questions per phase if state.get("quick_demo", False): qd_max = QUICK_DEMO_PHASE_CONFIG.get(phase, {}).get("max_questions", 2) if q_count >= qd_max: from src.state import PHASES phase_idx = PHASES.index(phase) if phase in PHASES else -1 if phase_idx >= 0 and phase_idx < len(PHASES) - 1: # Force transition to the next phase next_phase = PHASES[phase_idx + 1] updates["current_phase"] = next_phase next_q = _pop_next_question(state, next_phase) updates["target_question"] = next_q updates["queued_questions"] = state.get("queued_questions", {}) updates["phase_question_count"] = 0 updates["phase_start_time"] = datetime.now(timezone.utc).isoformat() updates["pending_probe"] = "" from langchain_core.messages import HumanMessage updates["messages"] = [HumanMessage(content="[continue]")] log.info(f"[GRAPH] Quick demo: phase {phase} done ({q_count} Qs), → {next_phase}") else: # Last phase — end the session updates["should_end"] = True from langchain_core.messages import HumanMessage updates["messages"] = [HumanMessage(content="[continue] The interview is now complete. Please provide a brief closing statement to the candidate and end the session. Do NOT ask any more questions.")] log.info(f"[GRAPH] Quick demo: final phase {phase} done, ending session") log.info(f"[GRAPH] Score: {composite} (A:{accuracy} D:{depth_score} C:{comm} Cf:{conf}) " f"for {phase} Q{q_count}") elif content.startswith("DIFFICULTY:"): direction = content.split(":", 1)[1] level = state.get("difficulty_level", 3) if direction == "up": updates["difficulty_level"] = min(5, level + 1) elif direction == "down": updates["difficulty_level"] = max(1, level - 1) elif content.startswith("END:"): updates["should_end"] = True return updates def _compact_memory_node(state: InterviewState) -> dict: """Graph node: fires on phase transitions to compact the old phase's memory. Uses the LLM to summarise the conversation from the phase that just ended, stores it in phase_memories for cross-section context in later phases. """ # The phase has ALREADY been updated by _process_tool_results, # so we need to figure out which phase just ended. # We look at phase_memories to see what's already compacted. phase_memories = dict(state.get("phase_memories", {})) current_phase = state.get("current_phase", "intro") messages = state.get("messages", []) from src.state import PHASES current_idx = PHASES.index(current_phase) if current_phase in PHASES else 0 if current_idx == 0: return {} old_phase = PHASES[current_idx - 1] # Skip if already compacted if old_phase in phase_memories: return {} # Use a lightweight LLM for compaction try: from src.services.llm import create_llm import os compact_llm = create_llm(api_key=os.getenv("GOOGLE_API_KEY", "")) memory = compact_phase(old_phase, messages, compact_llm) phase_memories[old_phase] = memory log.info(f"[GRAPH] Compacted memory for phase '{old_phase}' " f"({len(memory.get('key_claims', []))} claims, " f"{len(memory.get('follow_up_hooks', []))} hooks)") return {"phase_memories": phase_memories} except Exception as e: log.error(f"[GRAPH] Memory compaction failed for '{old_phase}': {e}") return {} def create_durable_checkpointer(database_url: str | None): """Build a Postgres-backed checkpointer so interview state survives restarts and can be shared across multiple workers/containers. Returns None (caller should fall back to in-memory) if the optional dependencies aren't installed or the connection can't be established — the app must still boot. Install 'langgraph-checkpoint-postgres' and 'psycopg[binary,pool]' to enable durable state. """ if not database_url: return None # The sync PostgresSaver is incompatible with the async streaming path # (graph.astream_events → aget_tuple raises NotImplementedError). Until the # graph is migrated to an async checkpointer, durable state is opt-in; by # default we use the in-memory MemorySaver which supports both sync + async. if os.getenv("DURABLE_CHECKPOINTER", "").strip().lower() not in ("1", "true", "yes"): log.info( "Durable checkpointer disabled (set DURABLE_CHECKPOINTER=true once the " "async checkpointer migration is done). Using in-memory interview state." ) return None try: from langgraph.checkpoint.postgres import PostgresSaver from psycopg_pool import ConnectionPool except ImportError as e: log.warning( "Durable checkpointer unavailable (%s); using in-memory state. " "Interview sessions will be LOST on restart and cannot be shared " "across workers. Install langgraph-checkpoint-postgres + psycopg[pool].", e, ) return None try: pool = ConnectionPool( conninfo=database_url, max_size=int(os.getenv("CHECKPOINT_POOL_MAX", "10")), open=True, # NeonDB (and most managed Postgres) drop idle connections, which # would otherwise hand a dead connection to graph.invoke ("SSL # connection has been closed unexpectedly"). check_connection # validates each connection on checkout and discards stale ones; # max_lifetime/max_idle recycle them before Neon times them out. check=ConnectionPool.check_connection, max_lifetime=300, max_idle=120, kwargs={"autocommit": True}, ) checkpointer = PostgresSaver(pool) checkpointer.setup() # idempotent — creates checkpoint tables if missing log.info("Using PostgresSaver for durable interview state.") return checkpointer except Exception as e: log.warning( "Failed to initialize PostgresSaver (%s); using in-memory state.", e ) return None def build_interview_graph(llm, checkpointer=None): """Construct and compile the interview StateGraph. Args: llm: A ChatGoogleGenerativeAI instance (from create_llm). checkpointer: Optional LangGraph checkpointer. If None, an in-memory MemorySaver is used (suitable for the CLI / tests, but NOT durable). Returns: Compiled LangGraph graph. """ model_with_tools = llm.bind_tools(ALL_TOOLS) def interviewer_node(state: InterviewState) -> dict: mode = state.get("interview_mode", "standard") phase = state["current_phase"] difficulty = state["difficulty_level"] # Build cross-section context from compacted phase memories cross_context = build_cross_section_context(state.get("phase_memories", {})) pending_probe = state.get("pending_probe", "") # Phase timing info for prompt — quick_demo overrides standard budgets if state.get("quick_demo", False): config = QUICK_DEMO_PHASE_CONFIG.get(phase, {}) else: config = PHASE_CONFIG.get(phase, {}) q_count = state.get("phase_question_count", 0) target_q = config.get("target_questions", 5) max_q = config.get("max_questions", 7) if mode == "option_a": system = build_resume_based_prompt( candidate_profile=state.get("candidate_profile") or {}, current_phase=phase, difficulty_level=difficulty, cross_section_context=cross_context, pending_probe=pending_probe, questions_asked=q_count, target_questions=target_q, max_questions=max_q, ) elif mode == "option_b": system = build_jd_targeted_prompt( candidate_profile=state.get("candidate_profile") or {}, jd_context=state.get("jd_context") or "", gap_map=state.get("gap_map") or {}, current_phase=phase, difficulty_level=difficulty, cross_section_context=cross_context, pending_probe=pending_probe, questions_asked=q_count, target_questions=target_q, max_questions=max_q, ) else: system = build_system_prompt( candidate_name=state["candidate_name"], target_company=state["target_company"], target_role=state["target_role"], current_phase=phase, difficulty_level=difficulty, entity_context=state.get("entity_context", ""), suggested_topics=state.get("suggested_topics", ""), target_question=state.get("target_question", ""), cross_section_context=cross_context, pending_probe=pending_probe, questions_asked=q_count, target_questions=target_q, max_questions=max_q, ) all_messages = [SystemMessage(content=system)] + list(state["messages"]) response = model_with_tools.invoke(all_messages) return {"messages": [response]} tool_node = ToolNode(ALL_TOOLS) def should_compact(state: InterviewState) -> str: """After process_tools, check if a phase transition just happened.""" last_msg = state["messages"][-1] if state["messages"] else None if last_msg: content = last_msg.content if hasattr(last_msg, "content") else str(last_msg) if content.startswith("TRANSITION:") or content == "[continue]": return "compact" return "continue" builder = StateGraph(InterviewState) builder.add_node("interviewer", interviewer_node) builder.add_node("tools", tool_node) builder.add_node("process_tools", _process_tool_results) builder.add_node("compact_memory", _compact_memory_node) builder.set_entry_point("interviewer") builder.add_conditional_edges("interviewer", tools_condition) builder.add_edge("tools", "process_tools") builder.add_conditional_edges( "process_tools", should_compact, {"compact": "compact_memory", "continue": "interviewer"}, ) builder.add_edge("compact_memory", "interviewer") if checkpointer is None: checkpointer = MemorySaver() return builder.compile(checkpointer=checkpointer)