Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,16 @@ LANGSMITH_PROJECT=interviewgraph
LANGSMITH_API_KEY=

# LLM generation runtime config
# Supported providers in this repo: openai, anthropic
# Supported providers in this repo: openai, anthropic, ollama
INTERVIEWGRAPH_LLM_PROVIDER=openai
INTERVIEWGRAPH_LLM_MODEL=gpt-4o-mini
INTERVIEWGRAPH_LLM_TEMPERATURE=0.2

# Ollama (local inference)
# INTERVIEWGRAPH_LLM_PROVIDER=ollama
# INTERVIEWGRAPH_OLLAMA_MODEL=llama3.1:8b
# INTERVIEWGRAPH_OLLAMA_BASE_URL=http://127.0.0.1:11434

# Provider API keys (set the one matching provider)
OPENAI_API_KEY=
ANTHROPIC_API_KEY=
3 changes: 3 additions & 0 deletions app/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ class GenerateResponse(BaseModel):
markdown: str
errors: list[dict[str, object]]
generation_mode: str = "fallback"
generation_reason: str = "unknown"


@app.get("/health")
Expand All @@ -62,6 +63,7 @@ def generate_interview_questions(payload: GenerateRequest) -> GenerateResponse:
markdown=result.get("markdown", ""),
errors=result.get("errors", []),
generation_mode=str(result.get("generation_mode", "fallback")),
generation_reason=str(result.get("generation_reason", "unknown")),
)


Expand Down Expand Up @@ -96,4 +98,5 @@ async def generate_from_pdf(file: Annotated[UploadFile, File(...)]) -> GenerateR
markdown=result.get("markdown", ""),
errors=result.get("errors", []),
generation_mode=str(result.get("generation_mode", "fallback")),
generation_reason=str(result.get("generation_reason", "unknown")),
)
52 changes: 46 additions & 6 deletions casts/resume_ingestor/modules/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@


def get_generation_model() -> Any | None:
model, _reason = get_generation_model_with_reason()
return model


def get_generation_model_with_reason() -> tuple[Any | None, str]:
"""Returns a configured LangChain chat model, or None if unavailable.

Configuration (all optional):
Expand All @@ -25,33 +30,68 @@ def get_generation_model() -> Any | None:
- INTERVIEWGRAPH_LLM_TEMPERATURE (default: 0.2)
"""

provider = os.getenv("INTERVIEWGRAPH_LLM_PROVIDER", "openai").strip().lower()
model = os.getenv("INTERVIEWGRAPH_LLM_MODEL", "gpt-4o-mini").strip()
provider = _provider_name()
temp_raw = os.getenv("INTERVIEWGRAPH_LLM_TEMPERATURE", "0.2").strip()

if not _has_provider_credentials(provider):
return None
return None, "missing_credentials"

try:
temperature = float(temp_raw)
except ValueError:
temperature = 0.2

if provider == "ollama":
ollama_model = _ollama_model_name()
base_url = os.getenv("INTERVIEWGRAPH_OLLAMA_BASE_URL", "").strip()
try:
from langchain_ollama import ChatOllama

kwargs: dict[str, object] = {
"model": ollama_model,
"temperature": temperature,
}
if base_url:
kwargs["base_url"] = base_url
return ChatOllama(**kwargs), "ready"
except Exception as exc:
return None, f"ollama_init_error:{type(exc).__name__}"

model = os.getenv("INTERVIEWGRAPH_LLM_MODEL", "gpt-4o-mini").strip()
try:
from langchain.chat_models import init_chat_model

return init_chat_model(
model_obj = init_chat_model(
model=model,
model_provider=provider,
temperature=temperature,
)
except Exception:
return None
return model_obj, "ready"
except Exception as exc:
return None, f"provider_init_error:{type(exc).__name__}"


def _provider_name() -> str:
return os.getenv("INTERVIEWGRAPH_LLM_PROVIDER", "openai").strip().lower()


def _ollama_model_name() -> str:
explicit = os.getenv("INTERVIEWGRAPH_OLLAMA_MODEL", "").strip()
if explicit:
return explicit

generic = os.getenv("INTERVIEWGRAPH_LLM_MODEL", "").strip()
if generic:
return generic

return "llama3.1:8b"


def _has_provider_credentials(provider: str) -> bool:
if provider == "openai":
return bool(os.getenv("OPENAI_API_KEY", "").strip())
if provider == "anthropic":
return bool(os.getenv("ANTHROPIC_API_KEY", "").strip())
if provider == "ollama":
return True
return False
46 changes: 35 additions & 11 deletions casts/resume_ingestor/modules/nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from pathlib import Path

from casts.base_node import BaseNode
from casts.resume_ingestor.modules.models import get_generation_model
from casts.resume_ingestor.modules.models import get_generation_model_with_reason
from casts.resume_ingestor.modules.prompts import build_question_generation_messages


Expand Down Expand Up @@ -50,6 +50,7 @@ def execute(self, state):
"markdown": "",
"errors": [],
"generation_mode": "fallback",
"generation_reason": "not_generated_yet",
}

if isinstance(resume_path, str) and resume_path.strip():
Expand All @@ -75,6 +76,7 @@ def execute(self, state):
)
],
"generation_mode": "fallback",
"generation_reason": "extract_text_file_not_found",
}

try:
Expand All @@ -100,6 +102,7 @@ def execute(self, state):
)
],
"generation_mode": "fallback",
"generation_reason": "extract_text_read_failed",
}

if not loaded_text:
Expand All @@ -123,6 +126,7 @@ def execute(self, state):
)
],
"generation_mode": "fallback",
"generation_reason": "extract_text_empty_text",
}

return {
Expand All @@ -138,6 +142,7 @@ def execute(self, state):
"markdown": "",
"errors": [],
"generation_mode": "fallback",
"generation_reason": "not_generated_yet",
}

return {
Expand All @@ -160,6 +165,7 @@ def execute(self, state):
)
],
"generation_mode": "fallback",
"generation_reason": "extract_text_missing_input",
}


Expand Down Expand Up @@ -375,7 +381,11 @@ class GenerateQuestionsNode(BaseNode):
def execute(self, state):
existing_errors = list(state.get("errors", []))
if existing_errors:
return {"questions": [], "generation_mode": "fallback"}
return {
"questions": [],
"generation_mode": "fallback",
"generation_reason": "upstream_errors",
}

sections = state.get("sections")
signals = state.get("signals")
Expand All @@ -398,7 +408,7 @@ def execute(self, state):
keywords = self._as_list(signals.get("keywords"))
evidence = self._as_list(signals.get("evidence"))

llm_questions = self._generate_questions_with_llm(
llm_questions, llm_reason = self._generate_questions_with_llm(
raw_text=str(state.get("raw_text", "")),
sections=sections,
signals={
Expand All @@ -409,25 +419,33 @@ def execute(self, state):
},
)
if llm_questions is not None:
return {"questions": llm_questions, "generation_mode": "llm"}
return {
"questions": llm_questions,
"generation_mode": "llm",
"generation_reason": llm_reason,
}

prompts = self._build_prompt_seeds(skills, projects, keywords, evidence)
questions = [
self._make_question(index=idx + 1, seed=seed)
for idx, seed in enumerate(prompts[:15])
]
return {"questions": questions, "generation_mode": "fallback"}
return {
"questions": questions,
"generation_mode": "fallback",
"generation_reason": llm_reason,
}

def _generate_questions_with_llm(
self,
*,
raw_text: str,
sections: dict[str, object],
signals: dict[str, object],
) -> list[dict[str, object]] | None:
model = get_generation_model()
) -> tuple[list[dict[str, object]] | None, str]:
model, model_reason = get_generation_model_with_reason()
if model is None:
return None
return None, model_reason

try:
messages = build_question_generation_messages(
Expand All @@ -436,9 +454,12 @@ def _generate_questions_with_llm(
signals=signals,
)
response = model.invoke(messages)
return self._parse_llm_questions(response)
except Exception:
return None
parsed = self._parse_llm_questions(response)
if parsed is None:
return None, "llm_parse_failed"
return parsed, "llm_success"
except Exception as exc:
return None, f"llm_invoke_error:{type(exc).__name__}"

def _parse_llm_questions(self, response: object) -> list[dict[str, object]] | None:
content = getattr(response, "content", response)
Expand Down Expand Up @@ -909,6 +930,7 @@ def execute(self, state):
questions = state.get("questions")
errors = state.get("errors")
generation_mode = state.get("generation_mode", "fallback")
generation_reason = state.get("generation_reason", "unknown")

if not isinstance(errors, list):
errors = []
Expand All @@ -918,6 +940,7 @@ def execute(self, state):
"questions": [],
"markdown": "",
"generation_mode": "fallback",
"generation_reason": "format_output_invalid_questions",
"errors": errors
+ [
_error_item(
Expand All @@ -934,6 +957,7 @@ def execute(self, state):
"questions": questions,
"markdown": markdown,
"generation_mode": str(generation_mode),
"generation_reason": str(generation_reason),
}

def _render_markdown(self, questions: list[object]) -> str:
Expand Down
2 changes: 2 additions & 0 deletions casts/resume_ingestor/modules/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ class OutputState(TypedDict):
markdown: str
errors: list[ErrorItem]
generation_mode: str
generation_reason: str


class State(MessagesState):
Expand All @@ -75,3 +76,4 @@ class State(MessagesState):
markdown: str
errors: list[ErrorItem]
generation_mode: str
generation_reason: str
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ dependencies = [
"fastapi>=0.116.1",
"langchain-anthropic>=0.3.0",
"langchain>=1.0.0",
"langchain-ollama>=0.3.0",
"langchain-openai>=0.3.0",
"langgraph>=1.0.0",
"pypdf>=6.0.0",
Expand Down
21 changes: 20 additions & 1 deletion scripts/staging_quality_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,26 @@
import argparse
import json
import os
import sys
from pathlib import Path

from casts.resume_ingestor.graph import resume_ingestor_graph
from casts.resume_ingestor.modules.models import get_generation_model


def _load_runtime_env() -> None:
try:
from dotenv import load_dotenv
except Exception:
return

root = Path(__file__).resolve().parents[1]
load_dotenv(root / ".env", override=False)
load_dotenv(root / ".env.local", override=False)


_load_runtime_env()


def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Run staging quality check")
parser.add_argument(
Expand All @@ -35,13 +48,16 @@ def _has_provider_key() -> bool:
return bool(os.getenv("OPENAI_API_KEY", "").strip())
if provider == "anthropic":
return bool(os.getenv("ANTHROPIC_API_KEY", "").strip())
if provider == "ollama":
return True
return False


def _quality_report(result: dict[str, object]) -> dict[str, object]:
questions = result.get("questions", [])
errors = result.get("errors", [])
generation_mode = str(result.get("generation_mode", "fallback"))
generation_reason = str(result.get("generation_reason", "unknown"))

if not isinstance(questions, list):
questions = []
Expand All @@ -67,6 +83,7 @@ def _quality_report(result: dict[str, object]) -> dict[str, object]:
unique_categories = sorted(set(categories))
return {
"generation_mode": generation_mode,
"generation_reason": generation_reason,
"question_count": len(questions),
"unique_categories": unique_categories,
"category_count": len(unique_categories),
Expand Down Expand Up @@ -115,6 +132,7 @@ def main() -> int:
category_count = _as_int(report.get("category_count"), 0)

generation_mode = str(report.get("generation_mode", "fallback"))
generation_reason = str(report.get("generation_reason", "unknown"))

if question_count != 15:
return 1
Expand All @@ -124,6 +142,7 @@ def main() -> int:
return 1

if llm_ready and generation_mode != "llm":
print(f"LLM ready but fallback occurred: reason={generation_reason}")
return 1
return 0

Expand Down
Loading
Loading