File size: 8,236 Bytes
f39e265 be97847 f39e265 87149fa f39e265 87149fa f39e265 4638978 e287216 f39e265 87149fa 4638978 9d8bbd6 87149fa be97847 87149fa be97847 87149fa be97847 87149fa f39e265 be97847 4638978 f39e265 be97847 87149fa be97847 87149fa be97847 87149fa be97847 87149fa 0ea997a 87149fa 0ea997a 87149fa 9d8bbd6 e287216 9d8bbd6 e287216 f39e265 87149fa be97847 f39e265 9d8bbd6 87149fa 9d8bbd6 87149fa be97847 9d8bbd6 be97847 87149fa 9d8bbd6 d7d27a3 9d8bbd6 87149fa d7d27a3 87149fa f39e265 63808a2 87149fa 63808a2 87149fa 63808a2 87149fa be97847 f39e265 87149fa f39e265 be97847 4638978 be97847 87149fa 4638978 f39e265 87149fa be97847 87149fa be97847 87149fa f39e265 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 | """LangGraph application graph.
Each skill runs as its own node with the full profile. A button names its skill
directly (the action); free-form chat is classified by the semantic router,
which either picks a skill or decides whether the question needs no profile,
retrieved excerpts, or all of it.
"""
import logging
import sqlite3
from functools import partial
from typing import Annotated, TypedDict
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, trim_messages
from langchain_openai import ChatOpenAI
from langgraph.checkpoint.sqlite import SqliteSaver
from langgraph.graph import START, StateGraph
from langgraph.graph.message import add_messages
import router as router_module
from ats import ATSAnalysis, format_ats_report
from config import ConfigError, OPENROUTER_BASE_URL, get_settings
from embeddings import embed
from prompts import (
NO_JD_PLACEHOLDER,
build_qa_prompt,
build_rag_prompt,
build_skill_prompt,
build_trivial_prompt,
render_skill_prompt,
)
from retrieval import ProfileIndex, chunk_documents
from router import SemanticRouter
from skills import DRAFTING_SKILLS, SKILLS, load_skill
logger = logging.getLogger(__name__)
class ChatState(TypedDict):
# add_messages appends rather than overwrites, so each turn extends the thread.
messages: Annotated[list, add_messages]
job_description: str
# A skill key from skills.SKILLS when a button was pressed; empty for free-form chat.
action: str
ATS_NODE = "ats_analysis"
def skill_node(key: str) -> str:
"""Graph node that runs a skill; ATS has its own structured-output node."""
return ATS_NODE if key == "ats" else f"skill_{key}"
NODE_BY_ROUTE = {
router_module.TRIVIAL: "trivial_answer",
router_module.SPECIFIC: "rag_answer",
router_module.FULL: "answer",
**{key: skill_node(key) for key in SKILLS},
}
def latest_text(messages) -> str:
"""Text of the most recent message.
Message content survives a checkpoint round trip as a list of content
blocks rather than a plain string, so `.content` cannot be used directly.
`.text` returns a str for either shape.
"""
if not messages:
return ""
return str(messages[-1].text)
def route_action(state, semantic_router=None) -> str:
"""Pick the node for this turn.
A button names its skill, which always gets the full profile: a retrieval
miss would silently corrupt an ATS score or a resume review. Only free-form
chat is routed.
"""
action = state.get("action")
if action in SKILLS:
logger.info("Route %s | skill button, router bypassed", action)
return skill_node(action)
if action:
logger.info("Route answer | preset action %r, router bypassed", action)
return "answer"
if semantic_router is None:
logger.info("Route answer | no router configured")
return "answer"
messages = state.get("messages") or []
query = latest_text(messages)
return NODE_BY_ROUTE[semantic_router.route(query)]
def build_llm(temperature=None, max_tokens=None, api_key=None):
settings = get_settings(require_api_key=False)
api_key = api_key or settings.openrouter_api_key
if not api_key:
raise ConfigError("An OpenRouter API key is required to build the model.")
return ChatOpenAI(
api_key=api_key,
base_url=OPENROUTER_BASE_URL,
model=settings.model,
temperature=settings.temperature if temperature is None else temperature,
max_tokens=settings.max_tokens if max_tokens is None else max_tokens,
)
def build_graph(api_key=None, profile_data=None):
settings = get_settings(require_api_key=False)
api_key = api_key or settings.openrouter_api_key
if not api_key:
raise ConfigError("An OpenRouter API key is required to build the graph.")
if not profile_data or not profile_data.strip():
raise ConfigError("Upload profile documents before building the graph.")
prompt = build_qa_prompt()
llm = build_llm(api_key=api_key)
chain = prompt | llm
skill_prompt = build_skill_prompt()
skill_chains = {
key: skill_prompt | build_llm(
settings.drafting_temperature if key in DRAFTING_SKILLS else None,
settings.skill_max_tokens,
api_key=api_key,
)
for key in SKILLS
if key != "ats"
}
rag_chain = build_rag_prompt() | llm
trivial_chain = build_trivial_prompt() | llm
structured_llm = build_llm(
max_tokens=settings.ats_max_tokens, api_key=api_key
).with_structured_output(ATSAnalysis)
chunks = chunk_documents(profile_data)
index = ProfileIndex(chunks, embed)
semantic_router = SemanticRouter(embed)
logger.info("Indexed %d profile chunks for retrieval.", len(index.chunks))
# Two messages per turn (human + ai).
max_history_messages = settings.max_history_turns * 2
def recent_history(state):
# Older turns remain in the checkpoint; they are simply not resent,
# which keeps per-turn cost flat instead of growing with the thread.
return trim_messages(
state["messages"][:-1],
max_tokens=max_history_messages,
token_counter=len,
strategy="last",
start_on="human",
include_system=False,
)
def answer(state: ChatState):
response = chain.invoke({
"context_text": profile_data,
"job_description": state.get("job_description") or NO_JD_PLACEHOLDER,
"user_query": latest_text(state["messages"]),
"history": recent_history(state),
})
return {"messages": [response]}
def rag_answer(state: ChatState):
"""Narrow question: send only the excerpts that match it."""
query = latest_text(state["messages"])
response = rag_chain.invoke({
"context_text": index.search_text(query, k=settings.retrieval_top_k),
"user_query": query,
"history": recent_history(state),
})
return {"messages": [response]}
def trivial_answer(state: ChatState):
"""Greeting or pleasantry: no profile at all."""
response = trivial_chain.invoke({
"user_query": latest_text(state["messages"]),
"history": recent_history(state),
})
return {"messages": [response]}
def make_skill_node(key):
skill_text = load_skill(key)
def run_skill(state: ChatState):
"""Run one skill with the full profile and job description."""
response = skill_chains[key].invoke({
"skill_text": skill_text,
"context_text": profile_data,
"job_description": state.get("job_description") or NO_JD_PLACEHOLDER,
"user_query": latest_text(state["messages"]),
"history": recent_history(state),
})
return {"messages": [response]}
return run_skill
def ats_analysis(state: ChatState):
"""Classify keywords with structured output, then score them in Python."""
analysis = structured_llm.invoke([
SystemMessage(content=render_skill_prompt(
load_skill("ats"), profile_data, state.get("job_description")
)),
HumanMessage(content=latest_text(state["messages"])),
])
return {"messages": [AIMessage(content=format_ats_report(analysis))]}
builder = StateGraph(ChatState)
builder.add_node("answer", answer)
builder.add_node("rag_answer", rag_answer)
builder.add_node("trivial_answer", trivial_answer)
builder.add_node(ATS_NODE, ats_analysis)
for key in skill_chains:
builder.add_node(skill_node(key), make_skill_node(key))
builder.add_conditional_edges(
START,
partial(route_action, semantic_router=semantic_router),
["answer", "rag_answer", "trivial_answer", ATS_NODE, *map(skill_node, skill_chains)],
)
# check_same_thread=False: Gradio serves requests from a pool of threads.
conn = sqlite3.connect(str(settings.checkpoint_db), check_same_thread=False)
return builder.compile(checkpointer=SqliteSaver(conn))
|