quantlab-factor-agent 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.
- factor_agent/KUNQUANT_LICENSE +201 -0
- factor_agent/__init__.py +8 -0
- factor_agent/_acceptance_model.py +93 -0
- factor_agent/_admission.py +168 -0
- factor_agent/_expression_fold.py +48 -0
- factor_agent/_feedback.py +399 -0
- factor_agent/_max_drawdown.py +51 -0
- factor_agent/_model_cache.py +100 -0
- factor_agent/_scheduler.py +35 -0
- factor_agent/acceptance.py +808 -0
- factor_agent/accepted/__init__.py +1 -0
- factor_agent/aggregations.py +122 -0
- factor_agent/campaign.py +797 -0
- factor_agent/candidate.py +51 -0
- factor_agent/config.py +651 -0
- factor_agent/derived.py +98 -0
- factor_agent/evaluator.py +1096 -0
- factor_agent/events.py +343 -0
- factor_agent/exported.py +177 -0
- factor_agent/gate/__init__.py +359 -0
- factor_agent/gate/_canonical.py +331 -0
- factor_agent/gate/_checks.py +85 -0
- factor_agent/gate/_constraints.py +85 -0
- factor_agent/gate/_dimension.py +177 -0
- factor_agent/gate/_originality.py +146 -0
- factor_agent/gate/_sandbox.py +105 -0
- factor_agent/gate/graph.py +223 -0
- factor_agent/gate/stages.py +383 -0
- factor_agent/gate/vocabulary.py +1097 -0
- factor_agent/ledger.py +85 -0
- factor_agent/llm/__init__.py +0 -0
- factor_agent/llm/_common.py +18 -0
- factor_agent/llm/_raw.py +78 -0
- factor_agent/llm/anthropic.py +100 -0
- factor_agent/llm/base.py +124 -0
- factor_agent/llm/claude_code.py +101 -0
- factor_agent/llm/log.py +74 -0
- factor_agent/llm/openai_compatible.py +113 -0
- factor_agent/llm/replay.py +59 -0
- factor_agent/llm/scripted.py +39 -0
- factor_agent/period_lag.py +91 -0
- factor_agent/prompts.py +557 -0
- factor_agent/researcher.py +615 -0
- factor_agent/scoring.py +143 -0
- factor_agent/segments.py +184 -0
- factor_agent/session.py +500 -0
- factor_agent/snapshot.py +219 -0
- factor_agent/web/__init__.py +7 -0
- factor_agent/web/_acceptance.py +145 -0
- factor_agent/web/_config.py +51 -0
- factor_agent/web/_diagnosis.py +110 -0
- factor_agent/web/_files.py +78 -0
- factor_agent/web/_health.py +207 -0
- factor_agent/web/_ic.py +27 -0
- factor_agent/web/_ledger.py +293 -0
- factor_agent/web/_memory.py +42 -0
- factor_agent/web/_results.py +77 -0
- factor_agent/web/_round.py +219 -0
- factor_agent/web/_trials.py +112 -0
- factor_agent/web/app.py +118 -0
- factor_agent/web/run_view.py +329 -0
- factor_agent/web/static/assets/index-9QjjJgl3.css +1 -0
- factor_agent/web/static/assets/index-iOE9ei_S.js +74 -0
- factor_agent/web/static/index.html +13 -0
- factor_agent/zoo.py +136 -0
- quantlab_factor_agent-0.1.0.dist-info/METADATA +172 -0
- quantlab_factor_agent-0.1.0.dist-info/RECORD +72 -0
- quantlab_factor_agent-0.1.0.dist-info/WHEEL +5 -0
- quantlab_factor_agent-0.1.0.dist-info/entry_points.txt +2 -0
- quantlab_factor_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
- quantlab_factor_agent-0.1.0.dist-info/licenses/factor_agent/KUNQUANT_LICENSE +201 -0
- quantlab_factor_agent-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""The round in progress, replayed from the events since the run was last opened, and one LLM call read in full.
|
|
2
|
+
|
|
3
|
+
A round a resume dropped left its events before the resume, so it is not
|
|
4
|
+
among them; a run without events has no round in progress to show. An LLM
|
|
5
|
+
call is read by the number of its call file, from the file itself: tokens,
|
|
6
|
+
truncation and the model's reasoning are the provider's reply's; what it
|
|
7
|
+
concerned (round, slots) only the events file says.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import datetime, timedelta
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from factor_agent import events
|
|
16
|
+
from factor_agent.llm._raw import raw_facts
|
|
17
|
+
from factor_agent.llm.base import Reply
|
|
18
|
+
from factor_agent.web._files import Replayed, loads, utc
|
|
19
|
+
from factor_agent.web._health import Tokens
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Check:
|
|
24
|
+
"""One check of a pending trial's candidate: the parser and the Gate on one version of it, or the reviewer.
|
|
25
|
+
|
|
26
|
+
``by`` is ``"gate"`` or ``"reviewer"``. For the Gate, ``step`` is the
|
|
27
|
+
version's: ``"proposed"`` (the first), ``"fixed"`` (sent back by the
|
|
28
|
+
parser or the Gate) or ``"rewritten"`` (sent back by the reviewer), and
|
|
29
|
+
``stage`` is ``"parse"`` or ``"gate"`` when it refused the version,
|
|
30
|
+
``None`` when it passed it. ``passed`` is true when the Gate passed the
|
|
31
|
+
version or the reviewer found it consistent. ``reasons`` are the Gate's
|
|
32
|
+
reasons for a refusal, or the reviewer's reason for its verdict either
|
|
33
|
+
way; ``hints`` are the Gate's.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
by: str
|
|
37
|
+
step: str | None
|
|
38
|
+
passed: bool
|
|
39
|
+
stage: str | None
|
|
40
|
+
reasons: tuple[str, ...]
|
|
41
|
+
hints: tuple[str, ...]
|
|
42
|
+
time: datetime
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class PendingTrial:
|
|
47
|
+
"""A pending trial: what is known so far of one slot of the round in progress.
|
|
48
|
+
|
|
49
|
+
``stage`` is the furthest it went: ``"planned"`` (its research direction
|
|
50
|
+
is chosen), ``"proposed"``, ``"fixed"`` (after the Gate refused it),
|
|
51
|
+
``"rewritten"`` (after the reviewer found it inconsistent), and
|
|
52
|
+
``"consistent"`` or ``"inconsistent"`` as the reviewer last found it.
|
|
53
|
+
``direction`` is its research direction as the plan gives it. ``latest``
|
|
54
|
+
is its latest version as the proposer wrote it, ``None`` before the
|
|
55
|
+
first. ``checks`` are the Gate's outcomes and the reviewer's verdicts
|
|
56
|
+
so far, the earliest first. It becomes a trial when its round is written.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
slot: int
|
|
60
|
+
stage: str
|
|
61
|
+
direction: dict
|
|
62
|
+
latest: object
|
|
63
|
+
checks: tuple[Check, ...]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class RoundCall:
|
|
68
|
+
"""An LLM call of the round in progress, as the call timeline shows it.
|
|
69
|
+
|
|
70
|
+
``number`` is its call file's number, by which ``read_call`` reads it;
|
|
71
|
+
``None`` while it has not come back, or when it failed (``error`` says
|
|
72
|
+
how). ``group`` is its proposal group and ``slots`` the slots it
|
|
73
|
+
concerns; both are empty for the planner, whose call concerns the round.
|
|
74
|
+
``sent`` and ``returned`` are when it was sent and came back (UTC).
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
number: int | None
|
|
78
|
+
role: str
|
|
79
|
+
model: str
|
|
80
|
+
group: int | None
|
|
81
|
+
slots: tuple[int, ...]
|
|
82
|
+
sent: datetime
|
|
83
|
+
returned: datetime | None
|
|
84
|
+
error: str | None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True)
|
|
88
|
+
class RunningRound:
|
|
89
|
+
"""The round in progress, as its events tell since the run was last opened.
|
|
90
|
+
|
|
91
|
+
``started`` is when the round before it was written, or the run was
|
|
92
|
+
opened when it is the first since. ``calls`` are its LLM calls in the
|
|
93
|
+
order they were sent, and ``evaluation`` when the Evaluator started on
|
|
94
|
+
it (``None`` before). ``pending`` holds a pending trial per slot of its
|
|
95
|
+
plan, in slot order; it is empty until the plan is made.
|
|
96
|
+
"""
|
|
97
|
+
|
|
98
|
+
round: int
|
|
99
|
+
started: datetime
|
|
100
|
+
calls: tuple[RoundCall, ...]
|
|
101
|
+
evaluation: datetime | None
|
|
102
|
+
pending: tuple[PendingTrial, ...]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True)
|
|
106
|
+
class LLMCall:
|
|
107
|
+
"""One LLM call of a run, in full, as its call file holds it.
|
|
108
|
+
|
|
109
|
+
``number`` is the number of its call file. ``round``, ``group`` and
|
|
110
|
+
``slots`` are the round, proposal group and slots it concerned, as the
|
|
111
|
+
events file says (``None`` and empty without one). ``time`` is when it
|
|
112
|
+
came back (UTC) and ``seconds`` how long it took (``None`` without an
|
|
113
|
+
events file). ``tokens``, ``truncated`` (cut off at the output limit,
|
|
114
|
+
``None`` when the provider's reply does not say) and ``empty`` are read
|
|
115
|
+
from the provider's reply. ``reasoning`` is the model's reasoning when
|
|
116
|
+
the provider returns it, ``None`` otherwise.
|
|
117
|
+
"""
|
|
118
|
+
|
|
119
|
+
number: int
|
|
120
|
+
role: str
|
|
121
|
+
model: str
|
|
122
|
+
round: int | None
|
|
123
|
+
group: int | None
|
|
124
|
+
slots: tuple[int, ...]
|
|
125
|
+
time: datetime
|
|
126
|
+
seconds: float | None
|
|
127
|
+
tokens: Tokens
|
|
128
|
+
truncated: bool | None
|
|
129
|
+
empty: bool
|
|
130
|
+
prompt: str
|
|
131
|
+
reply: str
|
|
132
|
+
reasoning: str | None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def running(number: int, replayed: Replayed) -> RunningRound | None:
|
|
136
|
+
"""Round ``number``, in progress, from the events since the run was last opened; ``None`` without events."""
|
|
137
|
+
if replayed.opened is None:
|
|
138
|
+
return None
|
|
139
|
+
started = replayed.opened.time
|
|
140
|
+
calls: dict[int, RoundCall] = {}
|
|
141
|
+
evaluation = None
|
|
142
|
+
directions: list[dict] = []
|
|
143
|
+
stages: dict[int, str] = {}
|
|
144
|
+
latest: dict[int, object] = {}
|
|
145
|
+
checks: dict[int, list[Check]] = {}
|
|
146
|
+
for event in replayed.current:
|
|
147
|
+
if isinstance(event, events.RoundEnd):
|
|
148
|
+
started = event.time
|
|
149
|
+
if getattr(event, "round", None) != number:
|
|
150
|
+
continue
|
|
151
|
+
if isinstance(event, events.CallEnd):
|
|
152
|
+
sent = calls[event.call].sent if event.call in calls else event.time - timedelta(seconds=event.seconds)
|
|
153
|
+
calls[event.call] = RoundCall(
|
|
154
|
+
number=_call_number(event.file), role=event.role, model=event.model, group=event.group,
|
|
155
|
+
slots=tuple(event.slots), sent=sent, returned=event.time, error=event.error,
|
|
156
|
+
)
|
|
157
|
+
elif isinstance(event, events.CallStart):
|
|
158
|
+
calls[event.call] = RoundCall(number=None, role=event.role, model=event.model, group=event.group,
|
|
159
|
+
slots=tuple(event.slots), sent=event.time, returned=None, error=None)
|
|
160
|
+
elif isinstance(event, events.EvaluationStart):
|
|
161
|
+
evaluation = event.time
|
|
162
|
+
elif isinstance(event, events.Plan):
|
|
163
|
+
directions = event.directions
|
|
164
|
+
stages = dict.fromkeys(range(len(directions)), "planned")
|
|
165
|
+
elif isinstance(event, events.GateOutcome):
|
|
166
|
+
stages[event.slot], latest[event.slot] = event.step, event.item
|
|
167
|
+
checks.setdefault(event.slot, []).append(Check(
|
|
168
|
+
by="gate", step=event.step, passed=event.passed, stage=event.stage, reasons=tuple(event.reasons),
|
|
169
|
+
hints=tuple(event.hints), time=event.time,
|
|
170
|
+
))
|
|
171
|
+
elif isinstance(event, events.ReviewVerdict):
|
|
172
|
+
stages[event.slot] = "consistent" if event.consistent else "inconsistent"
|
|
173
|
+
checks.setdefault(event.slot, []).append(Check(
|
|
174
|
+
by="reviewer", step=None, passed=event.consistent, stage=None, reasons=(event.reason,), hints=(),
|
|
175
|
+
time=event.time,
|
|
176
|
+
))
|
|
177
|
+
pending = tuple(
|
|
178
|
+
PendingTrial(slot=slot, stage=stages[slot], direction=direction, latest=latest.get(slot),
|
|
179
|
+
checks=tuple(checks.get(slot, ())))
|
|
180
|
+
for slot, direction in enumerate(directions)
|
|
181
|
+
)
|
|
182
|
+
return RunningRound(round=number, started=started, calls=tuple(calls.values()), evaluation=evaluation,
|
|
183
|
+
pending=pending)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _call_number(file: str | None) -> int | None:
|
|
187
|
+
"""The number of the call file named ``file``, as ``<number>-<role>.json``."""
|
|
188
|
+
return int(file.partition("-")[0]) if file else None
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def read_call(run_dir: str | Path, number: int) -> LLMCall | None:
|
|
192
|
+
"""The LLM call of the run in ``run_dir`` whose call file has ``number``, ``None`` when it has none.
|
|
193
|
+
|
|
194
|
+
A call of a round a resume dropped is not read: its file is no longer
|
|
195
|
+
in the run's sequence of calls.
|
|
196
|
+
"""
|
|
197
|
+
run_dir = Path(run_dir)
|
|
198
|
+
files = list((run_dir / "llm_log").glob(f"{number:06d}-*.json")) if number > 0 else []
|
|
199
|
+
if not files:
|
|
200
|
+
return None
|
|
201
|
+
path = files[0]
|
|
202
|
+
try:
|
|
203
|
+
call = loads(path.read_text())
|
|
204
|
+
except (FileNotFoundError, json.JSONDecodeError):
|
|
205
|
+
return None
|
|
206
|
+
request, reply = call.get("request") or {}, call.get("reply") or {}
|
|
207
|
+
text, raw = reply.get("text") or "", reply.get("raw") or ""
|
|
208
|
+
facts = events.reply_facts(Reply(text=text, usage=reply.get("usage") or {}, model_id=reply.get("model_id") or "",
|
|
209
|
+
raw=raw))
|
|
210
|
+
end = next((e for e in reversed(events.read(run_dir / events.FILE))
|
|
211
|
+
if isinstance(e, events.CallEnd) and e.file == path.name), None)
|
|
212
|
+
return LLMCall(
|
|
213
|
+
number=number, role=request.get("role") or path.stem.partition("-")[2], model=request.get("model") or "",
|
|
214
|
+
round=end.round if end else None, group=end.group if end else None, slots=tuple(end.slots) if end else (),
|
|
215
|
+
time=end.time if end else utc(path.stat().st_mtime), seconds=end.seconds if end else None,
|
|
216
|
+
tokens=Tokens(facts["input_tokens"], facts["output_tokens"], facts["cache_hits"], facts["reasoning"]),
|
|
217
|
+
truncated=facts["truncated"], empty=facts["empty"], prompt=request.get("prompt") or "", reply=text,
|
|
218
|
+
reasoning=raw_facts(raw).reasoning,
|
|
219
|
+
)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""The trials page: the evaluated trials on the admission map, and the others grouped by what refused them."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from factor_agent.web._files import Replayed
|
|
7
|
+
from factor_agent.web._ledger import Trial, in_direction, monotonicity, thresholds
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class Placed:
|
|
12
|
+
"""An evaluated trial as the admission map places it, with its statistics in its direction.
|
|
13
|
+
|
|
14
|
+
``round`` and ``slot`` name the trial, ``status`` is accepted or rejected.
|
|
15
|
+
``factor_direction`` is +1 when a higher value went with a higher training
|
|
16
|
+
return, -1 otherwise. ``ic_mean``, ``ic_t``, ``partial_ic_mean`` and
|
|
17
|
+
``partial_ic_t`` are in that direction; the trial's ``metrics`` keep the
|
|
18
|
+
raw values. ``largest_correlation`` is the larger absolute correlation
|
|
19
|
+
with the nearest reference or accepted factor and the nearest known one,
|
|
20
|
+
``None`` when there was none to compare with. ``threshold`` is the t
|
|
21
|
+
threshold of its round, as ``Factor.threshold`` (``None`` while nothing gives it).
|
|
22
|
+
``monotonicity`` is its decile monotonicity in its direction, as admission's.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
round: int
|
|
26
|
+
slot: int
|
|
27
|
+
status: str
|
|
28
|
+
factor_direction: int
|
|
29
|
+
ic_mean: float | None
|
|
30
|
+
ic_t: float | None
|
|
31
|
+
partial_ic_mean: float | None
|
|
32
|
+
partial_ic_t: float | None
|
|
33
|
+
largest_correlation: float | None
|
|
34
|
+
threshold: float | None
|
|
35
|
+
monotonicity: float | None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class TrialRef:
|
|
40
|
+
"""A trial named by its round and its slot in the round."""
|
|
41
|
+
|
|
42
|
+
round: int
|
|
43
|
+
slot: int
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class Refusal:
|
|
48
|
+
"""The trials one stage refused for the same reason before the Evaluator scored them.
|
|
49
|
+
|
|
50
|
+
``stage`` is ``"parse"``, ``"gate"``, ``"review"`` or ``"evaluate"`` (code
|
|
51
|
+
the Evaluator could not build). ``reason`` is the first trial's final
|
|
52
|
+
reason; the others' may differ in the numbers, reply or code they quote.
|
|
53
|
+
``rounds`` are the rounds the trials belong to, the earliest first.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
stage: str
|
|
57
|
+
reason: str
|
|
58
|
+
rounds: tuple[int, ...]
|
|
59
|
+
trials: tuple[TrialRef, ...]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class TrialsPage:
|
|
64
|
+
"""The admission map's trials, the latest round's threshold and the trials never evaluated, as ``RunView`` holds them."""
|
|
65
|
+
|
|
66
|
+
evaluated: tuple[Placed, ...]
|
|
67
|
+
threshold: float | None
|
|
68
|
+
never_evaluated: tuple[Refusal, ...]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
#: The order of the stages that refuse a trial before the Evaluator scores it.
|
|
72
|
+
_STAGES = ("parse", "gate", "review", "evaluate")
|
|
73
|
+
|
|
74
|
+
#: What a reason quotes after its message: a reply, code or a value.
|
|
75
|
+
_QUOTED = re.compile(r": ['\"`].*$", re.DOTALL)
|
|
76
|
+
|
|
77
|
+
#: A number in a reason.
|
|
78
|
+
_NUMBER = re.compile(r"[-+]?\d+(?:\.\d+)?(?:e[-+]?\d+)?")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def trials_page(trials: tuple[Trial, ...], replayed: Replayed) -> TrialsPage:
|
|
82
|
+
"""What the trials page shows of ``trials``."""
|
|
83
|
+
by_round = thresholds(trials, replayed)
|
|
84
|
+
evaluated = []
|
|
85
|
+
for trial in trials:
|
|
86
|
+
if not trial.metrics:
|
|
87
|
+
continue
|
|
88
|
+
metrics = trial.metrics
|
|
89
|
+
sign, oriented = in_direction(metrics)
|
|
90
|
+
correlations = [abs(c) for c in (metrics.get("nearest_correlation"), metrics.get("nearest_known_correlation"))
|
|
91
|
+
if c is not None]
|
|
92
|
+
evaluated.append(Placed(
|
|
93
|
+
round=trial.round, slot=trial.slot, status=trial.status, factor_direction=sign,
|
|
94
|
+
ic_mean=oriented["ic_mean"], ic_t=oriented["ic_t"], partial_ic_mean=oriented["partial_ic_mean"],
|
|
95
|
+
partial_ic_t=oriented["partial_ic_t"], largest_correlation=max(correlations) if correlations else None,
|
|
96
|
+
threshold=by_round[trial.round][0],
|
|
97
|
+
monotonicity=monotonicity(tuple(metrics.get("decile_returns") or ()), sign),
|
|
98
|
+
))
|
|
99
|
+
groups: dict[tuple[str, str], list[Trial]] = {}
|
|
100
|
+
for trial in trials:
|
|
101
|
+
if trial.metrics or trial.status == "accepted":
|
|
102
|
+
continue
|
|
103
|
+
reason = trial.reasons[0] if trial.reasons else ""
|
|
104
|
+
groups.setdefault((trial.stage, _NUMBER.sub("#", _QUOTED.sub("", reason))), []).append(trial)
|
|
105
|
+
never = [Refusal(stage=stage, reason=members[0].reasons[0] if members[0].reasons else "",
|
|
106
|
+
rounds=tuple(sorted({t.round for t in members})),
|
|
107
|
+
trials=tuple(TrialRef(t.round, t.slot) for t in members))
|
|
108
|
+
for (stage, _), members in groups.items()]
|
|
109
|
+
never.sort(key=lambda g: (_STAGES.index(g.stage) if g.stage in _STAGES else len(_STAGES), g.trials[0].round,
|
|
110
|
+
g.trials[0].slot))
|
|
111
|
+
return TrialsPage(evaluated=tuple(evaluated), threshold=by_round[max(by_round)][0] if by_round else None,
|
|
112
|
+
never_evaluated=tuple(never))
|
factor_agent/web/app.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""The frontend's web service: JSON routes over ``run_view`` and the built page, on localhost only.
|
|
2
|
+
|
|
3
|
+
``factor-agent-web <folder>`` serves the run directories under a folder on
|
|
4
|
+
``http://127.0.0.1:<port>``. It binds the loopback interface and has no
|
|
5
|
+
option to bind another: the pages show prompts, code and statistics and
|
|
6
|
+
there is no login. On a server, forward the port over SSH.
|
|
7
|
+
|
|
8
|
+
Routes:
|
|
9
|
+
|
|
10
|
+
- ``GET /api/runs``: every run under the folder (``list_runs``), the latest written first;
|
|
11
|
+
- ``GET /api/run?name=<name>``: one run (``RunView.read``) by its name in the list, 404 for any other name;
|
|
12
|
+
- ``GET /api/run/call?name=<name>&number=<number>``: one LLM call of a run (``read_call``) by the number
|
|
13
|
+
of its call file, 404 when the run has no call file of that number;
|
|
14
|
+
- ``GET /api/run/files?name=<name>``: the config of each start or resume of a run and its snapshot's
|
|
15
|
+
files (``read_files``), read only when the page shows them;
|
|
16
|
+
- ``GET /api/run/ic?name=<name>&round=<round>&slot=<slot>``: a trial's daily IC and partial IC (``read_ic``),
|
|
17
|
+
404 when it was not evaluated or the run kept none;
|
|
18
|
+
- ``GET /api/run/version?name=<name>``: a text that changes whenever the run writes (``run_version``),
|
|
19
|
+
which the page polls before reading the run again;
|
|
20
|
+
- ``GET /``: the page, built from ``frontend/`` into ``factor_agent/web/static``.
|
|
21
|
+
|
|
22
|
+
FastAPI and uvicorn come with the ``web`` extra and are imported only when
|
|
23
|
+
the app is made, so the module imports without them.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import argparse
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
from factor_agent.web.run_view import RunView, find_run, list_runs, read_call, read_files, read_ic, run_version
|
|
30
|
+
|
|
31
|
+
#: The built page: ``npm run build`` in ``frontend/`` writes it, and it is committed.
|
|
32
|
+
STATIC = Path(__file__).parent / "static"
|
|
33
|
+
|
|
34
|
+
#: The interface the service binds: the loopback interface only.
|
|
35
|
+
HOST = "127.0.0.1"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def create_app(root: str | Path):
|
|
39
|
+
"""The web service over the run directories under ``root``.
|
|
40
|
+
|
|
41
|
+
Parameters
|
|
42
|
+
----------
|
|
43
|
+
root : str or Path
|
|
44
|
+
A folder of run directories, at any depth, or a run directory itself.
|
|
45
|
+
|
|
46
|
+
Returns
|
|
47
|
+
-------
|
|
48
|
+
fastapi.FastAPI
|
|
49
|
+
|
|
50
|
+
Raises
|
|
51
|
+
------
|
|
52
|
+
ImportError
|
|
53
|
+
When the ``web`` extra is not installed.
|
|
54
|
+
"""
|
|
55
|
+
from fastapi import FastAPI, HTTPException
|
|
56
|
+
from fastapi.staticfiles import StaticFiles
|
|
57
|
+
|
|
58
|
+
root = Path(root).resolve()
|
|
59
|
+
app = FastAPI(title="factor-agent", docs_url=None, redoc_url=None, openapi_url=None)
|
|
60
|
+
|
|
61
|
+
@app.get("/api/runs")
|
|
62
|
+
def runs():
|
|
63
|
+
return list_runs(root)
|
|
64
|
+
|
|
65
|
+
def run_dir(name: str) -> Path:
|
|
66
|
+
# Only a listed name is read: a name never reaches the file system unchecked.
|
|
67
|
+
found = find_run(root, name)
|
|
68
|
+
if found is None:
|
|
69
|
+
raise HTTPException(404, f"no run named {name!r} under {root}")
|
|
70
|
+
return found
|
|
71
|
+
|
|
72
|
+
@app.get("/api/run")
|
|
73
|
+
def run(name: str):
|
|
74
|
+
return RunView.read(run_dir(name), name=name)
|
|
75
|
+
|
|
76
|
+
@app.get("/api/run/call")
|
|
77
|
+
def call(name: str, number: int):
|
|
78
|
+
found = read_call(run_dir(name), number)
|
|
79
|
+
if found is None:
|
|
80
|
+
raise HTTPException(404, f"run {name!r} has no LLM call number {number}")
|
|
81
|
+
return found
|
|
82
|
+
|
|
83
|
+
@app.get("/api/run/files")
|
|
84
|
+
def files(name: str):
|
|
85
|
+
return read_files(run_dir(name))
|
|
86
|
+
|
|
87
|
+
@app.get("/api/run/ic")
|
|
88
|
+
def ic(name: str, round: int, slot: int):
|
|
89
|
+
found = read_ic(run_dir(name), round, slot)
|
|
90
|
+
if found is None:
|
|
91
|
+
raise HTTPException(404, f"run {name!r} kept no daily IC of round {round}, slot {slot}")
|
|
92
|
+
return found
|
|
93
|
+
|
|
94
|
+
@app.get("/api/run/version")
|
|
95
|
+
def version(name: str):
|
|
96
|
+
return run_version(run_dir(name))
|
|
97
|
+
|
|
98
|
+
app.mount("/", StaticFiles(directory=STATIC, html=True), name="page")
|
|
99
|
+
return app
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def main(argv: list[str] | None = None) -> None:
|
|
103
|
+
"""Serve the run directories under a folder on localhost: the ``factor-agent-web`` console script."""
|
|
104
|
+
parser = argparse.ArgumentParser(
|
|
105
|
+
prog="factor-agent-web",
|
|
106
|
+
description="Serve the run directories under a folder on http://127.0.0.1:<port> (localhost only).",
|
|
107
|
+
)
|
|
108
|
+
parser.add_argument("root", type=Path, help="a folder of run directories, or a run directory")
|
|
109
|
+
parser.add_argument("--port", type=int, default=8000, help="the port to listen on (default: 8000)")
|
|
110
|
+
args = parser.parse_args(argv)
|
|
111
|
+
try:
|
|
112
|
+
import uvicorn
|
|
113
|
+
except ImportError:
|
|
114
|
+
parser.exit(1, "factor-agent-web: the frontend needs the web extra: uv sync --extra web\n")
|
|
115
|
+
if not args.root.is_dir():
|
|
116
|
+
parser.exit(1, f"factor-agent-web: {args.root} is not a folder\n")
|
|
117
|
+
print(f"factor-agent-web: serving the runs under {args.root.resolve()} on http://{HOST}:{args.port}", flush=True)
|
|
118
|
+
uvicorn.run(create_app(args.root), host=HOST, port=args.port)
|