UBCHelper / src /agent.py
Devdan Schretlen
Added FastAPI app for live HuggingFace demo
024efc3
Raw History Blame Contribute Delete
4.7 kB
"""
Agent — the complex path of the adaptive pipeline (Phase 4), framework-free.
A plain Python loop, no LangChain/LangGraph, so the whole thing stays readable:
decompose(question) -> sub-questions one LLM call
for each sub-question: retrieve.retrieve(...) multi-hop recall
round-robin merge + dedup the Result lists each hop's best hit rises to the top
synthesize(question, merged) one grounded answer over the union
The merge is what earns the complex path its keep: a single retrieval tends to satisfy one
part of a multi-hop question and starve the other, whereas retrieving each sub-question
separately and interleaving the rankings surfaces an excerpt for *every* hop — the recall
win the eval measures.
Synthesis deliberately reuses `generate.generate_answer`: the grounded SYSTEM_PROMPT and
`format_context` already turn a list of excerpts + the original question into one clean
answer, so there is nothing to reinvent here.
"""
from __future__ import annotations
from . import config, generate, retrieve, trace
from .retrieve import Result
DECOMPOSE_SYSTEM = (
"You break a complex question about the UBC Academic Calendar into a few focused "
"sub-questions, each of which can be answered from ONE calendar page (one course, "
"one program's requirements, one policy). Cover every distinct course, program, "
"student cohort, or side of a comparison the question asks about. Write one "
"sub-question per line, no numbering, and produce at most {max_subq}. If the "
"question is already single-focus, just restate it as the only line."
)
def decompose(question: str) -> list[str]:
"""Split `question` into up to AGENT_MAX_SUBQ sub-questions; fall back to the original."""
trace.step("AGENT - decompose into sub-questions")
system = DECOMPOSE_SYSTEM.format(max_subq=config.AGENT_MAX_SUBQ)
reply = generate.complete(system, question, purpose="decompose")
subqs = [_strip_bullet(line) for line in reply.splitlines()]
subqs = [s for s in subqs if s]
subqs = subqs[: config.AGENT_MAX_SUBQ] if subqs else [question]
trace.event("subquestions", items=subqs)
for i, sq in enumerate(subqs, start=1):
trace.detail(f"sub-question {i}", sq)
return subqs
def _strip_bullet(line: str) -> str:
"""Drop any leading list marker ("1.", "-", "*") the model may have added."""
return line.strip().lstrip("0123456789.)-*• \t").strip()
def _merge(result_lists: list[list[Result]]) -> list[Result]:
"""Round-robin interleave per-sub-question results, deduped by chunk id (first wins).
Taking rank-1 from every sub-question before any rank-2 keeps each hop's strongest hit
near the top, which is exactly what recall@k on a multi-hop question rewards.
"""
merged: list[Result] = []
seen: set[int] = set()
for rank in range(max((len(rl) for rl in result_lists), default=0)):
for rl in result_lists:
if rank < len(rl) and rl[rank].id not in seen:
seen.add(rl[rank].id)
merged.append(rl[rank])
return merged
def synthesize(question: str, results: list[Result]) -> str:
"""Answer the original question grounded in the merged excerpts (capped for prompt size)."""
return generate.generate_answer(question, results[: config.SYNTH_CONTEXT_K])
def run(
question: str, top_k: int | None = None, mode: str | None = None
) -> tuple[list[Result], str]:
"""Decompose -> multi-hop retrieve -> merge -> synthesize. Returns (merged_results, answer).
`top_k` sets how many excerpts to pull per sub-question (defaults to AGENT_SUBQ_TOP_K);
the merged list can be longer, since it unions every hop. `mode` is forwarded to every
hop so a caller comparing retrieval modes gets the same one on both routes.
"""
per_hop = top_k or config.AGENT_SUBQ_TOP_K
subqs = decompose(question)
result_lists = []
for i, sq in enumerate(subqs, start=1):
trace.step(f"AGENT - multi-hop retrieve, sub-question {i}/{len(subqs)}: {sq!r}")
result_lists.append(retrieve.retrieve(sq, top_k=per_hop, mode=mode))
merged = _merge(result_lists)
trace.step("AGENT - merge sub-question results (round-robin, dedup by chunk id)")
trace.event(
"merge",
order=[r.id for r in merged],
per_hop=[[r.id for r in rl] for rl in result_lists],
dropped=sum(len(rl) for rl in result_lists) - len(merged),
)
trace.detail("merged excerpts", len(merged))
trace.results(merged)
trace.step("AGENT - synthesize final answer over the merged excerpts")
answer = synthesize(question, merged)
return merged, answer