-
Notifications
You must be signed in to change notification settings - Fork 4.5k
Expand file tree
/
Copy pathtest_ask_graph.py
More file actions
165 lines (144 loc) · 6.13 KB
/
Copy pathtest_ask_graph.py
File metadata and controls
165 lines (144 loc) · 6.13 KB
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
"""
Unit tests for the Ask graph (open_notebook.graphs.ask).
Covers the output token budget shared by the three model stages and the
handling of empty strategies / empty partial answers produced by reasoning
models that exhaust their budget while thinking (#1221).
"""
import json
from typing import cast
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from langchain_core.runnables import RunnableConfig
from open_notebook.exceptions import ExternalServiceError
from open_notebook.graphs.ask import (
ASK_MAX_TOKENS,
Search,
Strategy,
ThreadState,
call_model_with_messages,
provide_answer,
write_final_answer,
)
EMPTY_CONFIG = cast(RunnableConfig, {"configurable": {}})
def _model_returning(content: str) -> MagicMock:
model = MagicMock()
model.ainvoke = AsyncMock(return_value=MagicMock(content=content))
return model
def _strategy_json(terms: list[str]) -> str:
return json.dumps(
{
"reasoning": "look things up",
"searches": [{"term": t, "instructions": "extract"} for t in terms],
}
)
class TestAskTokenBudget:
def test_budget_matches_other_workflows(self):
"""Ask uses the same 8192 budget as chat and transformations."""
assert ASK_MAX_TOKENS == 8192
@pytest.mark.asyncio
async def test_strategy_stage_uses_shared_budget(self):
state = cast(ThreadState, {"question": "q"})
with patch(
"open_notebook.graphs.ask.provision_langchain_model",
new=AsyncMock(return_value=_model_returning(_strategy_json(["rag"]))),
) as provision:
await call_model_with_messages(state, EMPTY_CONFIG)
assert provision.call_args.kwargs["max_tokens"] == ASK_MAX_TOKENS
@pytest.mark.asyncio
async def test_answer_stage_uses_shared_budget(self):
state = {"question": "q", "term": "rag", "instructions": "extract"}
with (
patch(
"open_notebook.graphs.ask.vector_search",
new=AsyncMock(return_value=[{"id": "source:1", "content": "x"}]),
),
patch(
"open_notebook.graphs.ask.provision_langchain_model",
new=AsyncMock(return_value=_model_returning("partial")),
) as provision,
):
await provide_answer(state, EMPTY_CONFIG) # type: ignore[arg-type]
assert provision.call_args.kwargs["max_tokens"] == ASK_MAX_TOKENS
@pytest.mark.asyncio
async def test_final_stage_uses_shared_budget(self):
state = cast(
ThreadState,
{
"question": "q",
"strategy": Strategy(reasoning="r", searches=[]),
"answers": ["a"],
},
)
with patch(
"open_notebook.graphs.ask.provision_langchain_model",
new=AsyncMock(return_value=_model_returning("final")),
) as provision:
result = await write_final_answer(state, EMPTY_CONFIG)
assert provision.call_args.kwargs["max_tokens"] == ASK_MAX_TOKENS
assert result == {"final_answer": "final"}
class TestEmptyStrategyHandling:
@pytest.mark.asyncio
async def test_blank_search_terms_are_dropped(self):
state = cast(ThreadState, {"question": "q"})
with patch(
"open_notebook.graphs.ask.provision_langchain_model",
new=AsyncMock(
return_value=_model_returning(_strategy_json(["", " ", "rag"]))
),
):
result = await call_model_with_messages(state, EMPTY_CONFIG)
assert [s.term for s in result["strategy"].searches] == ["rag"]
@pytest.mark.asyncio
async def test_all_blank_terms_raise_instead_of_silent_no_results(self):
state = cast(ThreadState, {"question": "q"})
with patch(
"open_notebook.graphs.ask.provision_langchain_model",
new=AsyncMock(return_value=_model_returning(_strategy_json(["", "", ""]))),
):
with pytest.raises(ExternalServiceError, match="no search terms"):
await call_model_with_messages(state, EMPTY_CONFIG)
@pytest.mark.asyncio
async def test_no_searches_raise(self):
state = cast(ThreadState, {"question": "q"})
with patch(
"open_notebook.graphs.ask.provision_langchain_model",
new=AsyncMock(return_value=_model_returning(_strategy_json([]))),
):
with pytest.raises(ExternalServiceError):
await call_model_with_messages(state, EMPTY_CONFIG)
@pytest.mark.asyncio
async def test_thinking_only_partial_answer_is_skipped(self):
state = {"question": "q", "term": "rag", "instructions": "extract"}
with (
patch(
"open_notebook.graphs.ask.vector_search",
new=AsyncMock(return_value=[{"id": "source:1", "content": "x"}]),
),
patch(
"open_notebook.graphs.ask.provision_langchain_model",
new=AsyncMock(
return_value=_model_returning("<think>only reasoning</think>")
),
),
):
result = await provide_answer(state, EMPTY_CONFIG) # type: ignore[arg-type]
assert result == {"answers": []}
@pytest.mark.asyncio
async def test_truncated_thinking_partial_answer_is_skipped(self):
"""Budget exhausted inside <think> must not leak reasoning as an answer."""
state = {"question": "q", "term": "rag", "instructions": "extract"}
with (
patch(
"open_notebook.graphs.ask.vector_search",
new=AsyncMock(return_value=[{"id": "source:1", "content": "x"}]),
),
patch(
"open_notebook.graphs.ask.provision_langchain_model",
new=AsyncMock(return_value=_model_returning("<think>cut off mid")),
),
):
result = await provide_answer(state, EMPTY_CONFIG) # type: ignore[arg-type]
assert result == {"answers": []}
def test_search_model_accepts_blank_term(self):
"""The filter, not the schema, is responsible for blank terms."""
assert Search(term="", instructions="x").term == ""