File size: 6,126 Bytes
656439d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Agentic multi-hop loop β€” decomposition, sufficiency judging, and end-to-end
multi-hop retrieval on the offline stack (hash embedder + memory store)."""

from __future__ import annotations

from auralynq.agent.agentic import _decompose, _judge_sufficiency
from auralynq.ingest.models import Chunk, Document, SourceType
from auralynq.llm.fallback import ExtractiveLLM


class _LLM(ExtractiveLLM):
    """ExtractiveLLM (keeps .answer for synthesis) with a scripted .generate."""

    name = "scripted"

    def __init__(self, script):
        super().__init__()
        self._script = script
        self.prompts = []

    def generate(self, prompt, **kw):
        self.prompts.append(prompt)
        for needle, reply in self._script:
            if needle in prompt:
                return reply(self) if callable(reply) else reply
        return super().generate(prompt, **kw)


# ── unit: decomposition ─────────────────────────────────────────────────────


def test_decompose_parses_sub_questions():
    llm = _LLM([("Break the QUESTION", "Who is X?\nWhat did X create?")])
    subs = _decompose(llm, "What did the founder of X create?", 4)
    assert subs == ["Who is X?", "What did X create?"]


def test_decompose_falls_back_to_original_on_junk():
    llm = _LLM([("Break the QUESTION", "")])  # empty β†’ fallback
    assert _decompose(llm, "simple question", 4) == ["simple question"]
    llm2 = _LLM([("Break the QUESTION", lambda s: (_ for _ in ()).throw(RuntimeError()))])
    assert _decompose(llm2, "q", 4) == ["q"]


# ── unit: sufficiency ───────────────────────────────────────────────────────


class _State:
    def __init__(self, contexts):
        self.contexts = contexts


class _Ctx:
    def __init__(self, text):
        self.chunk = type("C", (), {"text": text})()


def test_judge_sufficiency():
    llm = _LLM([("PASSAGES:", "SUFFICIENT")])
    st = _State([_Ctx("some evidence")])
    assert _judge_sufficiency(llm, "q", st) is None  # sufficient β†’ stop
    llm2 = _LLM([("PASSAGES:", "who founded Nokia")])
    assert _judge_sufficiency(llm2, "q", st) == "who founded Nokia"  # follow-up
    # no contexts β†’ nothing to judge
    assert _judge_sufficiency(llm2, "q", _State([])) is None


# ── integration: multi-hop retrieval ────────────────────────────────────────


def _seed_two_hop_corpus():
    from auralynq.pipeline import index_documents

    docs = [
        Document(
            id="d1",
            source="a.txt",
            source_type=SourceType.text,
            title="a",
            content_hash="h1",
            chunks=[
                Chunk(
                    id=Chunk.make_id("d1", 0),
                    doc_id="d1",
                    ordinal=0,
                    source="a.txt",
                    text="Ericsson's main competitor in mobile network equipment is Nokia.",
                )
            ],
        ),
        Document(
            id="d2",
            source="b.txt",
            source_type=SourceType.text,
            title="b",
            content_hash="h2",
            chunks=[
                Chunk(
                    id=Chunk.make_id("d2", 0),
                    doc_id="d2",
                    ordinal=0,
                    source="b.txt",
                    text="Nokia was founded by Fredrik Idestam in 1865 as a pulp mill company.",
                )
            ],
        ),
    ]
    index_documents(docs)


def _run_state(monkeypatch, llm, question):
    """Drive the agentic executor and return the final AgentState (so we can
    inspect hops + accumulated contexts directly)."""
    monkeypatch.setattr("auralynq.agent.runner.get_llm", lambda: llm)
    from auralynq.agent.graph import run_agent
    from auralynq.agent.runner import _build_deps, _new_state
    from auralynq.telemetry.tracing import Trace

    deps = _build_deps(Trace(trace_id="t"), None)
    state = _new_state(question, None, agentic=True)
    return run_agent(state, deps)


def _sources(state):
    return {c.chunk.source for c in state.contexts}


def test_multihop_decomposition_retrieves_both_docs(monkeypatch):
    _seed_two_hop_corpus()
    # decompose into two hops (one per document); then judge sufficiency β†’ stop
    llm = _LLM(
        [
            ("Break the QUESTION", "Ericsson main competitor\nwho founded Nokia pulp mill"),
            ("PASSAGES:", "SUFFICIENT"),
        ]
    )
    state = _run_state(
        monkeypatch, llm, "What did the founder of Ericsson's main competitor create?"
    )
    assert state.sub_questions == ["Ericsson main competitor", "who founded Nokia pulp mill"]
    assert state.hops >= 2
    assert state.answer.strip()
    # multi-hop ACCUMULATED evidence from BOTH documents (the whole point)
    srcs = _sources(state)
    assert any("a.txt" in s for s in srcs) and any("b.txt" in s for s in srcs)


def test_followup_hop_from_sufficiency_judge(monkeypatch):
    _seed_two_hop_corpus()
    calls = {"n": 0}

    def sufficiency(_s):
        calls["n"] += 1
        return "who founded Nokia pulp mill" if calls["n"] == 1 else "SUFFICIENT"

    # single sub-question (echo) + a follow-up produced by the sufficiency judge
    llm = _LLM(
        [("Break the QUESTION", "Ericsson main competitor Nokia"), ("PASSAGES:", sufficiency)]
    )
    state = _run_state(monkeypatch, llm, "Ericsson main competitor Nokia")
    assert state.hops >= 2  # original hop + one follow-up hop
    assert calls["n"] >= 1
    # the follow-up hop pulled in the second document's evidence
    assert any("b.txt" in s for s in _sources(state))


def test_agentic_strategy_registered():
    from auralynq.rag.strategy_registry import get_registry

    reg = get_registry()
    strat = reg.get("agentic")
    assert strat is not None
    available, _ = strat.is_available()
    assert available is True