Spaces:
Running
Running
Download src/agent.py from DevSchretl/UBCHelper: direct link, hf CLI and curl.
- Browser
- Download file 4.7 kB
-
https://huggingface.co/spaces/DevSchretl/UBCHelper/resolve/main/src/agent.py
- Command line
-
hf download hf://spaces/DevSchretl/UBCHelper/src/agent.py
-
curl -L -o agent.py https://huggingface.co/spaces/DevSchretl/UBCHelper/resolve/main/src/agent.py
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 | |