forkreduce 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- forkreduce/__init__.py +76 -0
- forkreduce/checks.py +274 -0
- forkreduce/context.py +157 -0
- forkreduce/contrib/__init__.py +5 -0
- forkreduce/contrib/temporal.py +349 -0
- forkreduce/coverage.py +187 -0
- forkreduce/documents.py +173 -0
- forkreduce/errors.py +39 -0
- forkreduce/executor.py +200 -0
- forkreduce/failures.py +239 -0
- forkreduce/followup.py +215 -0
- forkreduce/models.py +48 -0
- forkreduce/plan.py +355 -0
- forkreduce/policy.py +147 -0
- forkreduce/prefix.py +90 -0
- forkreduce/py.typed +0 -0
- forkreduce/query.py +77 -0
- forkreduce/reduce.py +625 -0
- forkreduce/schemas.py +30 -0
- forkreduce/session.py +447 -0
- forkreduce/stats.py +92 -0
- forkreduce/textutil.py +21 -0
- forkreduce-0.1.0.dist-info/METADATA +631 -0
- forkreduce-0.1.0.dist-info/RECORD +26 -0
- forkreduce-0.1.0.dist-info/WHEEL +4 -0
- forkreduce-0.1.0.dist-info/licenses/LICENSE +21 -0
forkreduce/followup.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""The hook between rounds, and the built-in gap-fill strategy.
|
|
2
|
+
|
|
3
|
+
``followup`` receives the round's merged output and query results. It may call
|
|
4
|
+
the model (another query on the same prefix) or stay a pure function. An empty
|
|
5
|
+
list ends the run, as does reaching ``max_rounds``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Awaitable, Callable, Sequence
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any, Protocol
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel
|
|
15
|
+
|
|
16
|
+
from forkreduce.coverage import ReduceReport
|
|
17
|
+
from forkreduce.failures import Failure, FailureKind
|
|
18
|
+
from forkreduce.policy import Accept, Action, Drop, Fail
|
|
19
|
+
from forkreduce.query import Query, QueryResult
|
|
20
|
+
|
|
21
|
+
FollowupFn = Callable[["RoundState", Any], Awaitable[list[Query[Any]]] | list[Query[Any]]]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class RoundState:
|
|
26
|
+
"""What a followup hook can see after one map/check/reduce round.
|
|
27
|
+
|
|
28
|
+
``side_results`` collects model calls the hook makes (gap-fill queries, for
|
|
29
|
+
example) so their usage is not dropped. ``failures`` is the same idea for
|
|
30
|
+
errors the hook observed.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
round: int
|
|
34
|
+
results: list[QueryResult[Any]]
|
|
35
|
+
outputs: list[Any]
|
|
36
|
+
merged: Any
|
|
37
|
+
report: ReduceReport | None
|
|
38
|
+
failures: list[Failure] = field(default_factory=list)
|
|
39
|
+
side_results: list[QueryResult[Any]] = field(default_factory=list)
|
|
40
|
+
|
|
41
|
+
def add_result(self, result: QueryResult[Any]) -> None:
|
|
42
|
+
self.side_results.append(result)
|
|
43
|
+
if result.failure is not None:
|
|
44
|
+
self.failures.append(result.failure)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class Scope:
|
|
49
|
+
"""One slice of the document a gap-fill query is responsible for."""
|
|
50
|
+
|
|
51
|
+
name: str
|
|
52
|
+
responsibility: str
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass
|
|
56
|
+
class LeadReport:
|
|
57
|
+
"""Positions a gap-fill pass already covered, and the new leads it found."""
|
|
58
|
+
|
|
59
|
+
kind: str
|
|
60
|
+
new_positions: list[str]
|
|
61
|
+
duplicate_positions: list[str]
|
|
62
|
+
leads: list[Any]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class LeadPolicy(Protocol):
|
|
66
|
+
"""Decide which new leads become the next round's queries."""
|
|
67
|
+
|
|
68
|
+
def decide(self, report: LeadReport) -> Action:
|
|
69
|
+
"""Return an action for ``report``."""
|
|
70
|
+
...
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class AcceptLeads:
|
|
74
|
+
"""Default lead policy: keep every lead whose position is not already covered."""
|
|
75
|
+
|
|
76
|
+
def decide(self, report: LeadReport) -> Action:
|
|
77
|
+
return Accept()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class GapFill:
|
|
82
|
+
"""One gap-fill query per scope, then a query per new lead.
|
|
83
|
+
|
|
84
|
+
Leads are deduped by position (``block_id`` by default) against the merged
|
|
85
|
+
output and against each other. The gap-fill query itself runs inside the
|
|
86
|
+
hook, on the same session, so it shares the prefix cache. Its
|
|
87
|
+
:class:`QueryResult` is attached to the round state.
|
|
88
|
+
|
|
89
|
+
``lead_output`` and ``item_output`` must both be registered on the session.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
lead_output: type[BaseModel]
|
|
93
|
+
item_output: type[BaseModel]
|
|
94
|
+
scopes: Sequence[Scope] | None = None
|
|
95
|
+
instructions: str = (
|
|
96
|
+
"Look for requirements in your scope that the merged list does not already cover. "
|
|
97
|
+
"Return leads only for those gaps."
|
|
98
|
+
)
|
|
99
|
+
policy: LeadPolicy | None = None
|
|
100
|
+
lead_items: Callable[[Any], Sequence[Any]] | None = None
|
|
101
|
+
lead_position: Callable[[Any], str] | None = None
|
|
102
|
+
covered_positions: Callable[[Any], Sequence[str]] | None = None
|
|
103
|
+
query_for_lead: Callable[[Any, RoundState], Query[Any]] | None = None
|
|
104
|
+
|
|
105
|
+
async def __call__(self, state: RoundState, session: Any) -> list[Query[Any]]:
|
|
106
|
+
scopes = list(self.scopes) if self.scopes else [Scope("all", "the whole document")]
|
|
107
|
+
covered = set(self._covered(state.merged))
|
|
108
|
+
fresh: list[Any] = []
|
|
109
|
+
duplicates: list[str] = []
|
|
110
|
+
for scope in scopes:
|
|
111
|
+
query = Query(
|
|
112
|
+
tail=self._gap_tail(state.merged, scope),
|
|
113
|
+
output=self.lead_output,
|
|
114
|
+
id=f"gapfill-r{state.round}-{scope.name}",
|
|
115
|
+
round=state.round,
|
|
116
|
+
)
|
|
117
|
+
result = await session.ask(query)
|
|
118
|
+
state.add_result(result)
|
|
119
|
+
if result.failure is not None or result.output is None:
|
|
120
|
+
continue
|
|
121
|
+
for lead in self._leads(result.output):
|
|
122
|
+
position = self._position(lead)
|
|
123
|
+
if not position or position in covered:
|
|
124
|
+
duplicates.append(position or "")
|
|
125
|
+
continue
|
|
126
|
+
covered.add(position)
|
|
127
|
+
fresh.append(lead)
|
|
128
|
+
report = LeadReport(
|
|
129
|
+
kind="leads",
|
|
130
|
+
new_positions=[self._position(lead) for lead in fresh],
|
|
131
|
+
duplicate_positions=[item for item in duplicates if item],
|
|
132
|
+
leads=list(fresh),
|
|
133
|
+
)
|
|
134
|
+
action = (self.policy or AcceptLeads()).decide(report)
|
|
135
|
+
if isinstance(action, Fail):
|
|
136
|
+
state.failures.append(
|
|
137
|
+
Failure(
|
|
138
|
+
kind=FailureKind.PERMANENT,
|
|
139
|
+
message=action.reason,
|
|
140
|
+
retryable=False,
|
|
141
|
+
code="leads_rejected",
|
|
142
|
+
)
|
|
143
|
+
)
|
|
144
|
+
return []
|
|
145
|
+
if isinstance(action, Drop):
|
|
146
|
+
banned = set(action.items)
|
|
147
|
+
fresh = [lead for lead in fresh if self._position(lead) not in banned]
|
|
148
|
+
elif not isinstance(action, Accept):
|
|
149
|
+
state.failures.append(
|
|
150
|
+
Failure(
|
|
151
|
+
kind=FailureKind.PERMANENT,
|
|
152
|
+
message=f"lead policy returned {type(action).__name__}, which gap-fill cannot run",
|
|
153
|
+
retryable=False,
|
|
154
|
+
code="bad_action",
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
return []
|
|
158
|
+
return [self._to_query(lead, state) for lead in fresh]
|
|
159
|
+
|
|
160
|
+
def _leads(self, output: Any) -> Sequence[Any]:
|
|
161
|
+
if self.lead_items is not None:
|
|
162
|
+
return list(self.lead_items(output))
|
|
163
|
+
for name in ("leads", "items"):
|
|
164
|
+
value = getattr(output, name, None)
|
|
165
|
+
if isinstance(value, list):
|
|
166
|
+
return value
|
|
167
|
+
return []
|
|
168
|
+
|
|
169
|
+
def _position(self, lead: Any) -> str:
|
|
170
|
+
if self.lead_position is not None:
|
|
171
|
+
return self.lead_position(lead)
|
|
172
|
+
block_id = getattr(lead, "block_id", None)
|
|
173
|
+
if isinstance(block_id, str):
|
|
174
|
+
return block_id
|
|
175
|
+
return ""
|
|
176
|
+
|
|
177
|
+
def _covered(self, merged: Any) -> list[str]:
|
|
178
|
+
if self.covered_positions is not None:
|
|
179
|
+
return list(self.covered_positions(merged))
|
|
180
|
+
return sorted(_collect_block_ids(merged))
|
|
181
|
+
|
|
182
|
+
def _to_query(self, lead: Any, state: RoundState) -> Query[Any]:
|
|
183
|
+
if self.query_for_lead is not None:
|
|
184
|
+
return self.query_for_lead(lead, state)
|
|
185
|
+
question = getattr(lead, "question", None) or getattr(lead, "text", None) or str(lead)
|
|
186
|
+
position = self._position(lead) or "lead"
|
|
187
|
+
return Query(
|
|
188
|
+
id=f"gap-r{state.round}-{position}",
|
|
189
|
+
tail=str(question),
|
|
190
|
+
output=self.item_output,
|
|
191
|
+
meta={"block_id": position, "gap": True},
|
|
192
|
+
round=state.round + 1,
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
def _gap_tail(self, merged: Any, scope: Scope) -> str:
|
|
196
|
+
if isinstance(merged, BaseModel):
|
|
197
|
+
rendered = merged.model_dump_json()
|
|
198
|
+
else:
|
|
199
|
+
rendered = "null" if merged is None else str(merged)
|
|
200
|
+
return f"Scope {scope.name}: {scope.responsibility}\n{self.instructions.strip()}\n\nAlready merged:\n{rendered}"
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _collect_block_ids(value: Any) -> set[str]:
|
|
204
|
+
found: set[str] = set()
|
|
205
|
+
if isinstance(value, BaseModel):
|
|
206
|
+
if "block_id" in type(value).model_fields:
|
|
207
|
+
block_id = getattr(value, "block_id") # noqa: B009 - field is optional on BaseModel
|
|
208
|
+
if isinstance(block_id, str) and block_id:
|
|
209
|
+
found.add(block_id)
|
|
210
|
+
for name in type(value).model_fields:
|
|
211
|
+
found |= _collect_block_ids(getattr(value, name))
|
|
212
|
+
elif isinstance(value, list):
|
|
213
|
+
for item in value:
|
|
214
|
+
found |= _collect_block_ids(item)
|
|
215
|
+
return found
|
forkreduce/models.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""OpenAI-compatible models.
|
|
2
|
+
|
|
3
|
+
forkreduce itself accepts any pydantic-ai ``Model``. This helper is only for the
|
|
4
|
+
common case of an OpenAI-style chat endpoint. Retries stay in forkreduce:
|
|
5
|
+
the OpenAI client is constructed with ``max_retries=0``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
|
|
12
|
+
from pydantic_ai.models.openai import OpenAIChatModel
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def openai_compatible_model(
|
|
16
|
+
model_name: str | None = None,
|
|
17
|
+
*,
|
|
18
|
+
api_key: str | None = None,
|
|
19
|
+
base_url: str | None = None,
|
|
20
|
+
) -> OpenAIChatModel:
|
|
21
|
+
"""Build an ``OpenAIChatModel`` from arguments or environment variables.
|
|
22
|
+
|
|
23
|
+
Environment variables, when the matching argument is omitted:
|
|
24
|
+
|
|
25
|
+
* ``OPENAI_MODEL`` (or ``FORKREDUCE_MODEL``) for the model name
|
|
26
|
+
* ``OPENAI_API_KEY``
|
|
27
|
+
* ``OPENAI_BASE_URL``
|
|
28
|
+
"""
|
|
29
|
+
from openai import AsyncOpenAI
|
|
30
|
+
from pydantic_ai.providers.openai import OpenAIProvider
|
|
31
|
+
|
|
32
|
+
from forkreduce.errors import ForkReduceError
|
|
33
|
+
|
|
34
|
+
name = model_name or os.environ.get("OPENAI_MODEL") or os.environ.get("FORKREDUCE_MODEL")
|
|
35
|
+
if not name:
|
|
36
|
+
raise ForkReduceError("Pass model_name or set OPENAI_MODEL")
|
|
37
|
+
key = api_key if api_key is not None else os.environ.get("OPENAI_API_KEY")
|
|
38
|
+
url = base_url if base_url is not None else os.environ.get("OPENAI_BASE_URL")
|
|
39
|
+
# A local OpenAI-compatible server often has no key. The client still wants
|
|
40
|
+
# a non-empty string when base_url is set.
|
|
41
|
+
resolved_key = key or ("api-key-not-set" if url else None)
|
|
42
|
+
if url and resolved_key is not None:
|
|
43
|
+
client = AsyncOpenAI(api_key=resolved_key, base_url=url, max_retries=0)
|
|
44
|
+
elif resolved_key is not None:
|
|
45
|
+
client = AsyncOpenAI(api_key=resolved_key, max_retries=0)
|
|
46
|
+
else:
|
|
47
|
+
client = AsyncOpenAI(max_retries=0)
|
|
48
|
+
return OpenAIChatModel(name, provider=OpenAIProvider(openai_client=client))
|
forkreduce/plan.py
ADDED
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
"""Chained map / check / filter / reduce / followup plan."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import inspect
|
|
6
|
+
from collections.abc import Callable, Sequence
|
|
7
|
+
from dataclasses import dataclass, field, replace
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from pydantic_ai.usage import RunUsage
|
|
11
|
+
|
|
12
|
+
from forkreduce.checks import Check, CheckReport, Repair
|
|
13
|
+
from forkreduce.context import ContextWarning
|
|
14
|
+
from forkreduce.coverage import ReduceReport
|
|
15
|
+
from forkreduce.failures import Failure, FailureKind
|
|
16
|
+
from forkreduce.followup import FollowupFn, RoundState
|
|
17
|
+
from forkreduce.query import Query, QueryResult
|
|
18
|
+
from forkreduce.reduce import (
|
|
19
|
+
ReduceItem,
|
|
20
|
+
annotate_round,
|
|
21
|
+
flatten_merged,
|
|
22
|
+
reduce_items,
|
|
23
|
+
)
|
|
24
|
+
from forkreduce.session import Session
|
|
25
|
+
from forkreduce.stats import UsageSummary, add_usage, summarize
|
|
26
|
+
|
|
27
|
+
FilterFn = Callable[[Any], bool]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class RoundRecord:
|
|
32
|
+
"""One map/check/reduce round."""
|
|
33
|
+
|
|
34
|
+
round: int
|
|
35
|
+
results: list[QueryResult[Any]]
|
|
36
|
+
outputs: list[Any]
|
|
37
|
+
merged: Any
|
|
38
|
+
report: ReduceReport | None
|
|
39
|
+
failures: list[Failure]
|
|
40
|
+
check_reports: list[CheckReport] = field(default_factory=list)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class RunResult:
|
|
45
|
+
"""The full plan: merged output, per-round detail, usage, and every failure."""
|
|
46
|
+
|
|
47
|
+
merged: Any
|
|
48
|
+
rounds: list[RoundRecord]
|
|
49
|
+
results: list[QueryResult[Any]]
|
|
50
|
+
usage: UsageSummary
|
|
51
|
+
failures: list[Failure]
|
|
52
|
+
reports: list[Any]
|
|
53
|
+
context_warning: ContextWarning | None = None
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def cache_hit_rate(self) -> float:
|
|
57
|
+
return self.usage.cache_hit_rate
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def context_warnings(self) -> list[ContextWarning]:
|
|
61
|
+
"""Prefix warning from the session, then each query warning, in run order."""
|
|
62
|
+
found: list[ContextWarning] = []
|
|
63
|
+
if self.context_warning is not None:
|
|
64
|
+
found.append(self.context_warning)
|
|
65
|
+
for result in self.results:
|
|
66
|
+
if result.context_warning is not None:
|
|
67
|
+
found.append(result.context_warning)
|
|
68
|
+
return found
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Plan:
|
|
72
|
+
"""A lazy pipeline. Nothing runs until :meth:`run`."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, session: Session, queries: list[Query[Any]]) -> None:
|
|
75
|
+
self._session = session
|
|
76
|
+
self._queries = queries
|
|
77
|
+
self._check: Check | None = None
|
|
78
|
+
self._repair: Repair | None = None
|
|
79
|
+
self._failure_mode = session.policy.repair_failure
|
|
80
|
+
self._filters: list[FilterFn] = []
|
|
81
|
+
self._reduce_output: type[Any] | None = None
|
|
82
|
+
self._reduce_instructions = ""
|
|
83
|
+
self._reduce_check: Any = None
|
|
84
|
+
self._reduce_max_batch = 200
|
|
85
|
+
self._reduce_coverage: Any = None
|
|
86
|
+
self._reduce_max_rounds = 3
|
|
87
|
+
self._followup: FollowupFn | None = None
|
|
88
|
+
self._max_rounds = 1
|
|
89
|
+
|
|
90
|
+
def check(
|
|
91
|
+
self,
|
|
92
|
+
check: Check,
|
|
93
|
+
*,
|
|
94
|
+
repair: Repair | None = None,
|
|
95
|
+
on_failure: str | None = None,
|
|
96
|
+
) -> Plan:
|
|
97
|
+
"""Validate each output. ``repair`` may continue the branch to fix it.
|
|
98
|
+
|
|
99
|
+
``on_failure`` overrides :attr:`Policy.repair_failure` for items that
|
|
100
|
+
are still failing when repair stops: ``needs_review``, ``drop``, or
|
|
101
|
+
``fail``.
|
|
102
|
+
"""
|
|
103
|
+
if on_failure is not None and on_failure not in {"needs_review", "drop", "fail"}:
|
|
104
|
+
raise ValueError("on_failure must be 'needs_review', 'drop', or 'fail'")
|
|
105
|
+
self._check = check
|
|
106
|
+
self._repair = repair
|
|
107
|
+
if on_failure is not None:
|
|
108
|
+
self._failure_mode = on_failure
|
|
109
|
+
return self
|
|
110
|
+
|
|
111
|
+
def filter(self, fn: FilterFn) -> Plan:
|
|
112
|
+
"""Keep outputs for which ``fn`` returns true. Others stay on the run, not in reduce."""
|
|
113
|
+
self._filters.append(fn)
|
|
114
|
+
return self
|
|
115
|
+
|
|
116
|
+
def reduce(
|
|
117
|
+
self,
|
|
118
|
+
*,
|
|
119
|
+
output: type[Any],
|
|
120
|
+
instructions: str = "",
|
|
121
|
+
check: Any = None,
|
|
122
|
+
max_batch: int = 200,
|
|
123
|
+
coverage: Any = None,
|
|
124
|
+
max_rounds: int = 3,
|
|
125
|
+
) -> Plan:
|
|
126
|
+
"""Semantically merge the kept outputs. ``output`` must be registered."""
|
|
127
|
+
self._reduce_output = output
|
|
128
|
+
self._reduce_instructions = instructions
|
|
129
|
+
self._reduce_check = check
|
|
130
|
+
self._reduce_max_batch = max_batch
|
|
131
|
+
self._reduce_coverage = coverage
|
|
132
|
+
self._reduce_max_rounds = max_rounds
|
|
133
|
+
return self
|
|
134
|
+
|
|
135
|
+
def followup(self, fn: FollowupFn, *, max_rounds: int = 2) -> Plan:
|
|
136
|
+
"""Call ``fn`` between rounds. ``max_rounds`` counts map rounds, including the first."""
|
|
137
|
+
if max_rounds < 1:
|
|
138
|
+
raise ValueError("max_rounds must be >= 1")
|
|
139
|
+
self._followup = fn
|
|
140
|
+
self._max_rounds = max_rounds
|
|
141
|
+
return self
|
|
142
|
+
|
|
143
|
+
async def run(self) -> RunResult:
|
|
144
|
+
"""Execute the pipeline."""
|
|
145
|
+
rounds: list[RoundRecord] = []
|
|
146
|
+
all_results: list[QueryResult[Any]] = []
|
|
147
|
+
all_failures: list[Failure] = []
|
|
148
|
+
reports: list[Any] = []
|
|
149
|
+
merged: Any = None
|
|
150
|
+
carried: list[ReduceItem] = []
|
|
151
|
+
queries = list(self._queries)
|
|
152
|
+
fatal = False
|
|
153
|
+
|
|
154
|
+
for round_index in range(1, self._max_rounds + 1):
|
|
155
|
+
if not queries:
|
|
156
|
+
break
|
|
157
|
+
for query in queries:
|
|
158
|
+
query.round = round_index
|
|
159
|
+
results = await self._session.fork(queries)
|
|
160
|
+
checked, check_reports, round_failures, stop = await self._apply_checks(results)
|
|
161
|
+
all_failures.extend(round_failures)
|
|
162
|
+
reports.extend(check_reports)
|
|
163
|
+
kept: list[QueryResult[Any]] = []
|
|
164
|
+
new_items: list[ReduceItem] = []
|
|
165
|
+
for result in checked:
|
|
166
|
+
if round_index > 0 and result.output is not None:
|
|
167
|
+
result.output = annotate_round(result.output, round_index)
|
|
168
|
+
if result.dropped or result.output is None:
|
|
169
|
+
continue
|
|
170
|
+
if result.failure is not None and not result.needs_review:
|
|
171
|
+
continue
|
|
172
|
+
if not self._keep(result.output):
|
|
173
|
+
continue
|
|
174
|
+
kept.append(result)
|
|
175
|
+
new_items.append(ReduceItem(id=result.query_id, value=result.output, round=round_index))
|
|
176
|
+
report: ReduceReport | None = None
|
|
177
|
+
reduce_calls: list[QueryResult[Any]] = []
|
|
178
|
+
reduce_failure: Failure | None = None
|
|
179
|
+
if self._reduce_output is not None and (new_items or carried) and not stop:
|
|
180
|
+
outcome = await reduce_items(
|
|
181
|
+
self._session,
|
|
182
|
+
[*carried, *new_items],
|
|
183
|
+
output=self._reduce_output,
|
|
184
|
+
instructions=self._reduce_instructions,
|
|
185
|
+
check=self._reduce_check,
|
|
186
|
+
max_batch=self._reduce_max_batch,
|
|
187
|
+
coverage=self._reduce_coverage,
|
|
188
|
+
max_rounds=self._reduce_max_rounds,
|
|
189
|
+
round_index=round_index,
|
|
190
|
+
)
|
|
191
|
+
merged = outcome.output
|
|
192
|
+
report = outcome.report
|
|
193
|
+
reports.append(report)
|
|
194
|
+
reduce_calls = list(outcome.calls)
|
|
195
|
+
if outcome.failure is not None:
|
|
196
|
+
all_failures.append(outcome.failure)
|
|
197
|
+
reduce_failure = outcome.failure
|
|
198
|
+
if outcome.fatal:
|
|
199
|
+
fatal = True
|
|
200
|
+
carried = flatten_merged(merged, round_index=round_index)
|
|
201
|
+
round_results = [*checked, *reduce_calls]
|
|
202
|
+
all_results.extend(round_results)
|
|
203
|
+
rounds.append(
|
|
204
|
+
RoundRecord(
|
|
205
|
+
round=round_index,
|
|
206
|
+
results=round_results,
|
|
207
|
+
outputs=[item.value for item in new_items],
|
|
208
|
+
merged=merged,
|
|
209
|
+
report=report,
|
|
210
|
+
failures=[*round_failures, *([reduce_failure] if reduce_failure is not None else [])],
|
|
211
|
+
check_reports=check_reports,
|
|
212
|
+
)
|
|
213
|
+
)
|
|
214
|
+
if fatal or stop or self._followup is None or round_index >= self._max_rounds:
|
|
215
|
+
break
|
|
216
|
+
state = RoundState(
|
|
217
|
+
round=round_index,
|
|
218
|
+
results=checked,
|
|
219
|
+
outputs=[item.value for item in new_items],
|
|
220
|
+
merged=merged,
|
|
221
|
+
report=report,
|
|
222
|
+
)
|
|
223
|
+
nxt = self._followup(state, self._session)
|
|
224
|
+
if inspect.isawaitable(nxt):
|
|
225
|
+
nxt = await nxt
|
|
226
|
+
all_results.extend(state.side_results)
|
|
227
|
+
all_failures.extend(state.failures)
|
|
228
|
+
# Side results were not part of this round record yet.
|
|
229
|
+
if rounds:
|
|
230
|
+
rounds[-1].results.extend(state.side_results)
|
|
231
|
+
rounds[-1].failures.extend(state.failures)
|
|
232
|
+
queries = list(nxt)
|
|
233
|
+
if not queries:
|
|
234
|
+
break
|
|
235
|
+
|
|
236
|
+
return RunResult(
|
|
237
|
+
merged=merged,
|
|
238
|
+
rounds=rounds,
|
|
239
|
+
results=all_results,
|
|
240
|
+
usage=summarize(all_results),
|
|
241
|
+
failures=all_failures,
|
|
242
|
+
reports=reports,
|
|
243
|
+
context_warning=self._session.context_warning,
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
async def _apply_checks(
|
|
247
|
+
self,
|
|
248
|
+
results: list[QueryResult[Any]],
|
|
249
|
+
) -> tuple[list[QueryResult[Any]], list[CheckReport], list[Failure], bool]:
|
|
250
|
+
if self._check is None:
|
|
251
|
+
return results, [], [result.failure for result in results if result.failure], False
|
|
252
|
+
updated: list[QueryResult[Any]] = []
|
|
253
|
+
reports: list[CheckReport] = []
|
|
254
|
+
failures: list[Failure] = []
|
|
255
|
+
stop = False
|
|
256
|
+
for result in results:
|
|
257
|
+
if result.failure is not None or result.output is None:
|
|
258
|
+
if result.failure is not None:
|
|
259
|
+
failures.append(result.failure)
|
|
260
|
+
updated.append(result)
|
|
261
|
+
continue
|
|
262
|
+
parts = [result]
|
|
263
|
+
output = result.output
|
|
264
|
+
messages = result.messages
|
|
265
|
+
problems = self._check(output, documents=self._session.documents)
|
|
266
|
+
repair_rounds = 0
|
|
267
|
+
while problems and self._repair is not None and repair_rounds < self._repair.max_rounds:
|
|
268
|
+
repair_rounds += 1
|
|
269
|
+
carrier = replace(result, output=output, messages=messages, query_id=result.query_id)
|
|
270
|
+
repaired = await self._session.ask(
|
|
271
|
+
self._repair.build_query(carrier, problems, round_index=repair_rounds)
|
|
272
|
+
)
|
|
273
|
+
parts.append(repaired)
|
|
274
|
+
if repaired.failure is not None or repaired.output is None:
|
|
275
|
+
if repaired.failure is not None:
|
|
276
|
+
failures.append(repaired.failure)
|
|
277
|
+
break
|
|
278
|
+
try:
|
|
279
|
+
output = self._repair.apply(output, repaired.output, problems)
|
|
280
|
+
except Exception as exc:
|
|
281
|
+
failures.append(
|
|
282
|
+
Failure(
|
|
283
|
+
kind=FailureKind.PERMANENT,
|
|
284
|
+
message=f"repair apply failed: {exc}",
|
|
285
|
+
retryable=False,
|
|
286
|
+
code="repair_apply",
|
|
287
|
+
)
|
|
288
|
+
)
|
|
289
|
+
break
|
|
290
|
+
messages = repaired.messages
|
|
291
|
+
problems = self._check(output, documents=self._session.documents)
|
|
292
|
+
usage, cache, attempts, elapsed = _fold_usage(parts)
|
|
293
|
+
notice = result.context_warning
|
|
294
|
+
if notice is None:
|
|
295
|
+
for part in parts:
|
|
296
|
+
if part.context_warning is not None:
|
|
297
|
+
notice = part.context_warning
|
|
298
|
+
break
|
|
299
|
+
current = replace(
|
|
300
|
+
result,
|
|
301
|
+
output=output,
|
|
302
|
+
messages=messages,
|
|
303
|
+
usage=usage,
|
|
304
|
+
cache_hit_tokens=cache,
|
|
305
|
+
attempts=attempts,
|
|
306
|
+
elapsed_ms=elapsed,
|
|
307
|
+
failure=None,
|
|
308
|
+
check_failures=list(problems),
|
|
309
|
+
context_warning=notice,
|
|
310
|
+
)
|
|
311
|
+
if not problems:
|
|
312
|
+
action = "repaired" if repair_rounds else "ok"
|
|
313
|
+
else:
|
|
314
|
+
action = self._failure_mode
|
|
315
|
+
if self._failure_mode == "needs_review":
|
|
316
|
+
current.needs_review = True
|
|
317
|
+
elif self._failure_mode == "drop":
|
|
318
|
+
current.dropped = True
|
|
319
|
+
else:
|
|
320
|
+
failure = Failure(
|
|
321
|
+
kind=FailureKind.PERMANENT,
|
|
322
|
+
message="; ".join(item.message for item in problems),
|
|
323
|
+
retryable=False,
|
|
324
|
+
code="check_failed",
|
|
325
|
+
)
|
|
326
|
+
current.failure = failure
|
|
327
|
+
failures.append(failure)
|
|
328
|
+
stop = True
|
|
329
|
+
reports.append(
|
|
330
|
+
CheckReport(
|
|
331
|
+
query_id=result.query_id,
|
|
332
|
+
round=result.round,
|
|
333
|
+
failures=list(problems),
|
|
334
|
+
repair_rounds=repair_rounds,
|
|
335
|
+
action=action,
|
|
336
|
+
)
|
|
337
|
+
)
|
|
338
|
+
updated.append(current)
|
|
339
|
+
return updated, reports, failures, stop
|
|
340
|
+
|
|
341
|
+
def _keep(self, output: Any) -> bool:
|
|
342
|
+
return all(fn(output) for fn in self._filters)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _fold_usage(parts: Sequence[QueryResult[Any]]) -> tuple[RunUsage, int, int, float]:
|
|
346
|
+
usage = RunUsage()
|
|
347
|
+
cache = 0
|
|
348
|
+
attempts = 0
|
|
349
|
+
elapsed = 0.0
|
|
350
|
+
for part in parts:
|
|
351
|
+
usage = add_usage(usage, part.usage)
|
|
352
|
+
cache += part.cache_hit_tokens
|
|
353
|
+
attempts += part.attempts
|
|
354
|
+
elapsed += part.elapsed_ms
|
|
355
|
+
return usage, cache, attempts, elapsed
|