bodhi-backend / src /graph.py
Kcodess2807
Sync backend from main: quick_demo mode, remove unused scaffold files
5b778b8
Raw
History Blame Contribute Delete
17 kB
"""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)