Spaces:
Sleeping
Sleeping
| """Phase-level context memory manager for Bodhi interviews. | |
| Maintains per-phase conversational memory in Redis during the interview, | |
| compacts it via LLM summarisation on phase transitions, and flushes | |
| everything to NeonDB at session end. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import logging | |
| from typing import Any | |
| from langchain_core.messages import AnyMessage, HumanMessage | |
| log = logging.getLogger("bodhi.memory") | |
| # ── Compaction prompt ──────────────────────────────────────────────────────── | |
| _COMPACT_PROMPT = """\ | |
| You are a senior technical interviewer. Analyse the following interview transcript | |
| from the **{phase}** phase and produce a JSON summary. | |
| TRANSCRIPT: | |
| {transcript} | |
| Return ONLY valid JSON with these keys: | |
| {{ | |
| "phase": "{phase}", | |
| "key_claims": ["list of specific factual claims the candidate made"], | |
| "strengths": ["specific things the candidate did well"], | |
| "weaknesses": ["specific gaps, vagueness, or mistakes"], | |
| "follow_up_hooks": [ | |
| "specific questions to ask in later phases to cross-reference or challenge" | |
| ], | |
| "topics_covered": ["list of technical/behavioral topics discussed"], | |
| "notable_quotes": ["1-2 short verbatim quotes that reveal depth or gaps"] | |
| }} | |
| Be concise but specific. Each list should have 2-5 items. | |
| """ | |
| def _messages_to_transcript(messages: list[AnyMessage]) -> str: | |
| """Convert LangGraph messages into a readable transcript string.""" | |
| lines: list[str] = [] | |
| for msg in messages: | |
| role = "Candidate" if isinstance(msg, HumanMessage) else "Interviewer" | |
| content = msg.content if hasattr(msg, "content") else str(msg) | |
| if isinstance(content, list): | |
| content = " ".join( | |
| part.get("text", "") if isinstance(part, dict) else str(part) | |
| for part in content | |
| ) | |
| content = content.strip() | |
| if content and not content.startswith(("TRANSITION:", "SCORE:", "DIFFICULTY:", "END:")): | |
| lines.append(f"{role}: {content}") | |
| return "\n".join(lines) | |
| def compact_phase( | |
| phase: str, | |
| messages: list[AnyMessage], | |
| llm: Any, | |
| ) -> dict: | |
| """Use the LLM to summarise a phase's conversation into a compact memory blob. | |
| Args: | |
| phase: The phase that just ended (e.g. "technical"). | |
| messages: The full messages list from InterviewState. | |
| llm: A ChatGoogleGenerativeAI instance. | |
| Returns: | |
| A dict with keys: phase, key_claims, strengths, weaknesses, | |
| follow_up_hooks, topics_covered, notable_quotes. | |
| """ | |
| transcript = _messages_to_transcript(messages) | |
| if not transcript.strip(): | |
| log.warning("[MEMORY] No transcript content for phase '%s'", phase) | |
| return _empty_memory(phase) | |
| prompt = _COMPACT_PROMPT.format(phase=phase, transcript=transcript[-6000:]) | |
| try: | |
| from src.services.llm import _extract_text | |
| response = llm.invoke([HumanMessage(content=prompt)]) | |
| raw = _extract_text(response.content).strip() | |
| # Strip markdown fences | |
| if raw.startswith("```json"): | |
| raw = raw[7:] | |
| if raw.startswith("```"): | |
| raw = raw[3:] | |
| if raw.endswith("```"): | |
| raw = raw[:-3] | |
| data = json.loads(raw.strip()) | |
| data["phase"] = phase | |
| log.info("[MEMORY] Compacted phase '%s': %d claims, %d hooks", | |
| phase, len(data.get("key_claims", [])), len(data.get("follow_up_hooks", []))) | |
| return data | |
| except Exception as e: | |
| log.error("[MEMORY] Compaction failed for phase '%s': %s", phase, e) | |
| return _empty_memory(phase) | |
| def build_cross_section_context(phase_memories: dict) -> str: | |
| """Build a human-readable cross-section context string from all compacted phase memories. | |
| Args: | |
| phase_memories: Dict of {phase: compacted_memory_dict}. | |
| Returns: | |
| A formatted string to inject into the system prompt. | |
| """ | |
| if not phase_memories: | |
| return "" | |
| sections: list[str] = [] | |
| all_hooks: list[str] = [] | |
| for phase, mem in phase_memories.items(): | |
| if not isinstance(mem, dict): | |
| continue | |
| claims = mem.get("key_claims", []) | |
| strengths = mem.get("strengths", []) | |
| weaknesses = mem.get("weaknesses", []) | |
| hooks = mem.get("follow_up_hooks", []) | |
| parts = [f"[{phase.upper()}]"] | |
| if claims: | |
| parts.append(f" Claims: {'; '.join(claims[:4])}") | |
| if strengths: | |
| parts.append(f" Strengths: {'; '.join(strengths[:3])}") | |
| if weaknesses: | |
| parts.append(f" Weaknesses: {'; '.join(weaknesses[:3])}") | |
| sections.append("\n".join(parts)) | |
| all_hooks.extend(hooks) | |
| context = "\n".join(sections) | |
| if all_hooks: | |
| context += "\n\nSUGGESTED FOLLOW-UPS FROM EARLIER PHASES:\n" | |
| context += "\n".join(f" - {h}" for h in all_hooks[:6]) | |
| return context | |
| def _empty_memory(phase: str) -> dict: | |
| return { | |
| "phase": phase, | |
| "key_claims": [], | |
| "strengths": [], | |
| "weaknesses": [], | |
| "follow_up_hooks": [], | |
| "topics_covered": [], | |
| "notable_quotes": [], | |
| } | |