File size: 4,701 Bytes
494a4bf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
024efc3
494a4bf
 
 
024efc3
494a4bf
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
024efc3
 
 
494a4bf
 
 
024efc3
 
494a4bf
 
 
 
 
 
 
024efc3
494a4bf
 
 
024efc3
 
 
 
 
 
494a4bf
 
 
 
 
 
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
"""
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