forgeoptimizer 1.0.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.
- forgecli/__init__.py +4 -0
- forgecli/build/__init__.py +135 -0
- forgecli/build/apply.py +218 -0
- forgecli/build/caveman_optimize.py +37 -0
- forgecli/build/diff_extract.py +218 -0
- forgecli/build/llm.py +167 -0
- forgecli/build/optimize.py +37 -0
- forgecli/build/pipeline.py +76 -0
- forgecli/build/retrieval.py +157 -0
- forgecli/build/summarize.py +76 -0
- forgecli/build/test_run.py +75 -0
- forgecli/builder/__init__.py +11 -0
- forgecli/builder/builder.py +53 -0
- forgecli/builder/editor.py +36 -0
- forgecli/builder/formatter.py +31 -0
- forgecli/cli/__init__.py +5 -0
- forgecli/cli/bootstrap.py +281 -0
- forgecli/cli/commands_graph.py +137 -0
- forgecli/cli/commands_wrappers.py +63 -0
- forgecli/cli/main.py +122 -0
- forgecli/cli/ui.py +56 -0
- forgecli/config/__init__.py +28 -0
- forgecli/config/loader.py +85 -0
- forgecli/config/settings.py +204 -0
- forgecli/config/writer.py +90 -0
- forgecli/core/__init__.py +35 -0
- forgecli/core/container.py +68 -0
- forgecli/core/context.py +54 -0
- forgecli/core/credentials.py +114 -0
- forgecli/core/errors.py +27 -0
- forgecli/core/events.py +67 -0
- forgecli/core/logging.py +47 -0
- forgecli/core/models.py +159 -0
- forgecli/core/plugins.py +56 -0
- forgecli/core/service.py +22 -0
- forgecli/docs/__init__.py +5 -0
- forgecli/docs/generator.py +119 -0
- forgecli/engine/__init__.py +105 -0
- forgecli/engine/context.py +171 -0
- forgecli/engine/defaults.py +89 -0
- forgecli/engine/events.py +185 -0
- forgecli/engine/execution.py +526 -0
- forgecli/engine/plugins.py +146 -0
- forgecli/engine/runner.py +155 -0
- forgecli/engine/stages/__init__.py +33 -0
- forgecli/engine/stages/caveman_optimizer.py +47 -0
- forgecli/engine/stages/context_optimizer.py +44 -0
- forgecli/engine/stages/execution_engine_stage.py +108 -0
- forgecli/engine/stages/git_engine.py +30 -0
- forgecli/engine/stages/intent_analyzer.py +38 -0
- forgecli/engine/stages/model_router.py +66 -0
- forgecli/engine/stages/planning_engine.py +45 -0
- forgecli/engine/stages/repository_analyzer.py +54 -0
- forgecli/engine/stages/validation_engine.py +89 -0
- forgecli/git/__init__.py +9 -0
- forgecli/git/repo.py +55 -0
- forgecli/git/service.py +45 -0
- forgecli/graph/__init__.py +56 -0
- forgecli/graph/backend_graphify.py +453 -0
- forgecli/graph/edge.py +19 -0
- forgecli/graph/graph.py +85 -0
- forgecli/graph/graphify.py +412 -0
- forgecli/graph/indexer.py +82 -0
- forgecli/graph/node.py +47 -0
- forgecli/graph/repository.py +164 -0
- forgecli/memory/__init__.py +12 -0
- forgecli/memory/cache.py +61 -0
- forgecli/memory/history.py +138 -0
- forgecli/memory/store.py +87 -0
- forgecli/optimizer/__init__.py +14 -0
- forgecli/optimizer/caveman/__init__.py +173 -0
- forgecli/optimizer/caveman/cli.py +64 -0
- forgecli/optimizer/caveman/decorator.py +67 -0
- forgecli/optimizer/caveman/factory.py +51 -0
- forgecli/optimizer/caveman/ruleset.py +156 -0
- forgecli/optimizer/caveman/state.py +52 -0
- forgecli/optimizer/chunker.py +85 -0
- forgecli/optimizer/optimizer.py +81 -0
- forgecli/optimizer/ponytail/__init__.py +183 -0
- forgecli/optimizer/ponytail/cli.py +168 -0
- forgecli/optimizer/ponytail/decorator.py +70 -0
- forgecli/optimizer/ponytail/factory.py +51 -0
- forgecli/optimizer/ponytail/ruleset.py +168 -0
- forgecli/optimizer/ponytail/state.py +53 -0
- forgecli/optimizer/ranker.py +37 -0
- forgecli/optimizer/summarizer.py +44 -0
- forgecli/orchestrator/__init__.py +707 -0
- forgecli/planner/__init__.py +56 -0
- forgecli/planner/agent.py +61 -0
- forgecli/planner/plan.py +65 -0
- forgecli/planner/planner.py +17 -0
- forgecli/planner/render.py +271 -0
- forgecli/planner/serialize.py +106 -0
- forgecli/planner/software.py +834 -0
- forgecli/platform/__init__.py +91 -0
- forgecli/platform/core.py +201 -0
- forgecli/platform/deps.py +354 -0
- forgecli/platform/paths.py +236 -0
- forgecli/platform/shell.py +176 -0
- forgecli/platform/update.py +252 -0
- forgecli/plugins/__init__.py +251 -0
- forgecli/prompts/__init__.py +11 -0
- forgecli/prompts/loader.py +28 -0
- forgecli/prompts/registry.py +32 -0
- forgecli/prompts/renderer.py +23 -0
- forgecli/providers/__init__.py +39 -0
- forgecli/providers/anthropic.py +206 -0
- forgecli/providers/base.py +206 -0
- forgecli/providers/builtin.py +26 -0
- forgecli/providers/conversation.py +78 -0
- forgecli/providers/google.py +295 -0
- forgecli/providers/http_base.py +211 -0
- forgecli/providers/mock.py +113 -0
- forgecli/providers/openai.py +202 -0
- forgecli/providers/openai_compatible.py +728 -0
- forgecli/providers/router.py +345 -0
- forgecli/providers/router_state.py +89 -0
- forgecli/review/__init__.py +45 -0
- forgecli/review/analyzer.py +124 -0
- forgecli/review/analyzers/__init__.py +1 -0
- forgecli/review/analyzers/architecture.py +258 -0
- forgecli/review/analyzers/complexity.py +167 -0
- forgecli/review/analyzers/dead_code.py +262 -0
- forgecli/review/analyzers/duplicates.py +163 -0
- forgecli/review/analyzers/performance.py +165 -0
- forgecli/review/analyzers/security.py +251 -0
- forgecli/review/finding.py +68 -0
- forgecli/review/report.py +311 -0
- forgecli/review/repository.py +131 -0
- forgecli/review/suggestions.py +98 -0
- forgecli/runtime/__init__.py +6 -0
- forgecli/runtime/cache_store.py +77 -0
- forgecli/runtime/prepare.py +199 -0
- forgecli/runtime/wrappers.py +152 -0
- forgecli/sdk/__init__.py +132 -0
- forgecli/sdk/events.py +206 -0
- forgecli/sdk/interfaces.py +282 -0
- forgecli/sdk/loader.py +243 -0
- forgecli/sdk/manager.py +693 -0
- forgecli/sdk/manifest.py +397 -0
- forgecli/sdk/sandbox.py +197 -0
- forgecli/sdk/version.py +316 -0
- forgecli/templates/__init__.py +9 -0
- forgecli/templates/engine.py +37 -0
- forgecli/templates/registry.py +32 -0
- forgecli/utils/__init__.py +19 -0
- forgecli/utils/fs.py +91 -0
- forgecli/utils/ids.py +11 -0
- forgecli/utils/io.py +27 -0
- forgecli/utils/paths.py +70 -0
- forgecli/utils/timing.py +25 -0
- forgeoptimizer-1.0.0.dist-info/METADATA +169 -0
- forgeoptimizer-1.0.0.dist-info/RECORD +156 -0
- forgeoptimizer-1.0.0.dist-info/WHEEL +4 -0
- forgeoptimizer-1.0.0.dist-info/entry_points.txt +2 -0
- forgeoptimizer-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Self-contained Python implementation of the Caveman ruleset.
|
|
2
|
+
|
|
3
|
+
The ruleset mirrors the behavior described on https://github.com/JuliusBrussee/caveman.
|
|
4
|
+
It does not shell out to anything; it rewrites the system message of a
|
|
5
|
+
:class:`ChatRequest` to bias the model toward token-efficient output.
|
|
6
|
+
|
|
7
|
+
The five intensity levels match the official Caveman project:
|
|
8
|
+
|
|
9
|
+
* ``off`` - pass-through (handled by the composite, not this class).
|
|
10
|
+
* ``lite`` - concise communication; drop filler words and pleasantries.
|
|
11
|
+
* ``full`` - full caveman: fragments, [thing][action][reason] pattern.
|
|
12
|
+
* ``ultra`` - maximum compression; grunt-level communication.
|
|
13
|
+
* ``wenyan`` - Classical Chinese literary style for max semantic density.
|
|
14
|
+
|
|
15
|
+
The output is deterministic for a given (intensity, prompt) pair, so
|
|
16
|
+
tests can pin it.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from forgecli.optimizer.caveman import (
|
|
22
|
+
CavemanIntensity,
|
|
23
|
+
CavemanPromptOptimizer,
|
|
24
|
+
OptimizedRequest,
|
|
25
|
+
_clone_request,
|
|
26
|
+
_ensure_user_message,
|
|
27
|
+
)
|
|
28
|
+
from forgecli.providers.base import ChatMessage, ChatRequest, Role
|
|
29
|
+
|
|
30
|
+
_CAVEMAN_GUIDANCE: dict[CavemanIntensity, str] = {
|
|
31
|
+
CavemanIntensity.OFF: "",
|
|
32
|
+
CavemanIntensity.LITE: (
|
|
33
|
+
"CAVEMAN (lite): communicate concisely. "
|
|
34
|
+
"Drop filler words (just, really, basically, actually). "
|
|
35
|
+
"Drop pleasantries (sure, certainly, happy to, I think). "
|
|
36
|
+
"Use short sentences. Keep technical terms exact."
|
|
37
|
+
),
|
|
38
|
+
CavemanIntensity.FULL: (
|
|
39
|
+
"CAVEMAN (full): talk like caveman. Brain still big, mouth small.\n\n"
|
|
40
|
+
"Rules:\n"
|
|
41
|
+
"- Drop filler words, pleasantries, transition phrases\n"
|
|
42
|
+
"- Use sentence fragments where meaning is clear\n"
|
|
43
|
+
"- Keep technical terms exact \u2014 never simplify them\n"
|
|
44
|
+
"- Follow [thing] [action] [reason] pattern\n"
|
|
45
|
+
"- No summaries, no disclaimers, no meta-commentary\n"
|
|
46
|
+
"- Code blocks and error messages stay unmodified"
|
|
47
|
+
),
|
|
48
|
+
CavemanIntensity.ULTRA: (
|
|
49
|
+
"CAVEMAN (ultra): maximum compression.\n\n"
|
|
50
|
+
"- One sentence max per idea\n"
|
|
51
|
+
"- No articles (a, an, the) unless essential\n"
|
|
52
|
+
"- No polite prefixes \u2014 get straight to the point\n"
|
|
53
|
+
"- [action] [thing] directly\n"
|
|
54
|
+
"- Grunt-level communication preferred\n"
|
|
55
|
+
"- Code unchanged. Technical terms unchanged."
|
|
56
|
+
),
|
|
57
|
+
CavemanIntensity.WENYAN: (
|
|
58
|
+
"CAVEMAN (wenyan): respond in Classical Chinese literary style "
|
|
59
|
+
"(\u6587\u8a00 / wenyanwen).\n\n"
|
|
60
|
+
"- Maximum semantic density per character\n"
|
|
61
|
+
"- Use classical parallel structure where appropriate\n"
|
|
62
|
+
"- Eliminate all modern conversational padding\n"
|
|
63
|
+
"- \u4e00\u8a00\u4ee5\u84cb\u4e4b: express essence in minimum characters\n"
|
|
64
|
+
"- Code blocks, technical terms, and proper nouns stay in original form"
|
|
65
|
+
),
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class CavemanRulesetOptimizer(CavemanPromptOptimizer):
|
|
70
|
+
"""Rewrite system messages to apply the Caveman ruleset."""
|
|
71
|
+
|
|
72
|
+
name = "caveman.ruleset"
|
|
73
|
+
|
|
74
|
+
def __init__(self, *, intensity: CavemanIntensity = CavemanIntensity.LITE) -> None:
|
|
75
|
+
self._intensity = CavemanIntensity.parse(intensity)
|
|
76
|
+
|
|
77
|
+
@property
|
|
78
|
+
def intensity(self) -> CavemanIntensity:
|
|
79
|
+
return self._intensity
|
|
80
|
+
|
|
81
|
+
def set_intensity(self, intensity: CavemanIntensity | str) -> None:
|
|
82
|
+
self._intensity = CavemanIntensity.parse(intensity)
|
|
83
|
+
|
|
84
|
+
async def optimize_chat(self, request: ChatRequest) -> OptimizedRequest:
|
|
85
|
+
if self._intensity is CavemanIntensity.OFF:
|
|
86
|
+
return OptimizedRequest(
|
|
87
|
+
request=request,
|
|
88
|
+
notes=("caveman off",),
|
|
89
|
+
intensity=CavemanIntensity.OFF,
|
|
90
|
+
source="caveman-ruleset",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
guidance = _CAVEMAN_GUIDANCE[self._intensity]
|
|
94
|
+
messages = list(request.messages)
|
|
95
|
+
|
|
96
|
+
if not _ensure_user_message(messages):
|
|
97
|
+
return OptimizedRequest(
|
|
98
|
+
request=request,
|
|
99
|
+
notes=("no user message; caveman passthrough",),
|
|
100
|
+
intensity=self._intensity,
|
|
101
|
+
source="caveman-ruleset",
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
rewritten = _rewrite_messages(messages, guidance)
|
|
105
|
+
notes = _build_notes(self._intensity)
|
|
106
|
+
return OptimizedRequest(
|
|
107
|
+
request=_clone_request(request, rewritten),
|
|
108
|
+
notes=notes,
|
|
109
|
+
intensity=self._intensity,
|
|
110
|
+
source="caveman-ruleset",
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _rewrite_messages(
|
|
115
|
+
messages: list[ChatMessage],
|
|
116
|
+
guidance: str,
|
|
117
|
+
) -> list[ChatMessage]:
|
|
118
|
+
"""Insert Caveman guidance at the head of the conversation.
|
|
119
|
+
|
|
120
|
+
Rules:
|
|
121
|
+
* If the first message is a system message, prepend the guidance
|
|
122
|
+
to it (separated by two newlines).
|
|
123
|
+
* If there is no system message, insert one.
|
|
124
|
+
* If the first system message is *empty*, replace it.
|
|
125
|
+
* Otherwise the user/assistant turns are left untouched.
|
|
126
|
+
"""
|
|
127
|
+
if not messages:
|
|
128
|
+
return [ChatMessage(role=Role.SYSTEM, content=guidance)]
|
|
129
|
+
|
|
130
|
+
first = messages[0]
|
|
131
|
+
if first.role is Role.SYSTEM:
|
|
132
|
+
existing = first.content.strip()
|
|
133
|
+
if not existing:
|
|
134
|
+
return [ChatMessage(role=Role.SYSTEM, content=guidance), *messages[1:]]
|
|
135
|
+
new_content = f"{guidance}\n\n{first.content}" if guidance else first.content
|
|
136
|
+
return [ChatMessage(role=Role.SYSTEM, content=new_content), *messages[1:]]
|
|
137
|
+
|
|
138
|
+
return [ChatMessage(role=Role.SYSTEM, content=guidance), *messages]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _build_notes(intensity: CavemanIntensity) -> tuple[str, ...]:
|
|
142
|
+
notes: list[str] = [f"caveman intensity={intensity.value}"]
|
|
143
|
+
if intensity is CavemanIntensity.FULL:
|
|
144
|
+
notes.append("caveman full mode \u2014 fragments + [thing][action][reason]")
|
|
145
|
+
elif intensity is CavemanIntensity.ULTRA:
|
|
146
|
+
notes.append("caveman ultra \u2014 maximum compression")
|
|
147
|
+
elif intensity is CavemanIntensity.WENYAN:
|
|
148
|
+
notes.append("caveman wenyan \u2014 classical Chinese literary style")
|
|
149
|
+
elif intensity is CavemanIntensity.LITE:
|
|
150
|
+
notes.append("caveman lite \u2014 concise, no filler")
|
|
151
|
+
return tuple(notes)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
__all__ = [
|
|
155
|
+
"CavemanRulesetOptimizer",
|
|
156
|
+
]
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Live runtime state for the Caveman optimizer.
|
|
2
|
+
|
|
3
|
+
The intensity can be flipped at runtime. Because the setting is held
|
|
4
|
+
in :class:`AppContext.extras`, every subcommand that resolves the
|
|
5
|
+
optimizer sees the latest value. Tests can override it directly.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
from forgecli.optimizer.caveman import CavemanIntensity
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class CavemanState:
|
|
17
|
+
"""Mutable intensity for the running CLI process."""
|
|
18
|
+
|
|
19
|
+
intensity: CavemanIntensity = CavemanIntensity.LITE
|
|
20
|
+
backend: str = "ruleset" # "ruleset" | "cli" | "auto"
|
|
21
|
+
binary: str = "caveman"
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def from_extras(cls, extras: dict[str, object]) -> CavemanState:
|
|
25
|
+
state = cls()
|
|
26
|
+
intensity = extras.get("caveman.intensity") or extras.get(
|
|
27
|
+
"caveman_intensity"
|
|
28
|
+
)
|
|
29
|
+
backend = extras.get("caveman.backend") or extras.get("caveman_backend")
|
|
30
|
+
binary = extras.get("caveman.binary") or extras.get("caveman_binary")
|
|
31
|
+
if isinstance(intensity, str):
|
|
32
|
+
try:
|
|
33
|
+
state.intensity = CavemanIntensity.parse(intensity)
|
|
34
|
+
except ValueError:
|
|
35
|
+
state.intensity = CavemanIntensity.LITE
|
|
36
|
+
elif isinstance(intensity, CavemanIntensity):
|
|
37
|
+
state.intensity = intensity
|
|
38
|
+
if isinstance(backend, str):
|
|
39
|
+
state.backend = backend
|
|
40
|
+
if isinstance(binary, str):
|
|
41
|
+
state.binary = binary
|
|
42
|
+
return state
|
|
43
|
+
|
|
44
|
+
def to_extras(self) -> dict[str, str]:
|
|
45
|
+
return {
|
|
46
|
+
"caveman.intensity": self.intensity.value,
|
|
47
|
+
"caveman.backend": self.backend,
|
|
48
|
+
"caveman.binary": self.binary,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
__all__ = ["CavemanState"]
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Chunkers split long content into model-sized pieces."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Protocol
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SupportsLen(Protocol):
|
|
11
|
+
"""Minimal protocol for anything with a ``text`` attribute."""
|
|
12
|
+
|
|
13
|
+
text: str
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True)
|
|
17
|
+
class Chunk:
|
|
18
|
+
"""A piece of content along with its provenance."""
|
|
19
|
+
|
|
20
|
+
text: str
|
|
21
|
+
index: int
|
|
22
|
+
start: int
|
|
23
|
+
end: int
|
|
24
|
+
source_id: str | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Chunker:
|
|
28
|
+
"""Split text into overlapping windows of ``size`` characters.
|
|
29
|
+
|
|
30
|
+
This is intentionally the dumbest possible chunker; smarter chunkers
|
|
31
|
+
(e.g. AST-aware, sentence-aware) can be added by implementing the
|
|
32
|
+
same interface and registering them with the optimizer.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, *, size: int = 4000, overlap: int = 200) -> None:
|
|
36
|
+
if size <= 0:
|
|
37
|
+
raise ValueError("chunk size must be positive")
|
|
38
|
+
if overlap < 0 or overlap >= size:
|
|
39
|
+
raise ValueError("overlap must be in [0, size)")
|
|
40
|
+
self._size = size
|
|
41
|
+
self._overlap = overlap
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def size(self) -> int:
|
|
45
|
+
return self._size
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def overlap(self) -> int:
|
|
49
|
+
return self._overlap
|
|
50
|
+
|
|
51
|
+
def split(self, text: str, *, source_id: str | None = None) -> list[Chunk]:
|
|
52
|
+
"""Split ``text`` into chunks of up to ``self.size`` characters."""
|
|
53
|
+
if not text:
|
|
54
|
+
return []
|
|
55
|
+
chunks: list[Chunk] = []
|
|
56
|
+
step = self._size - self._overlap
|
|
57
|
+
idx = 0
|
|
58
|
+
pos = 0
|
|
59
|
+
n = len(text)
|
|
60
|
+
while pos < n:
|
|
61
|
+
end = min(pos + self._size, n)
|
|
62
|
+
chunks.append(
|
|
63
|
+
Chunk(
|
|
64
|
+
text=text[pos:end],
|
|
65
|
+
index=idx,
|
|
66
|
+
start=pos,
|
|
67
|
+
end=end,
|
|
68
|
+
source_id=source_id,
|
|
69
|
+
)
|
|
70
|
+
)
|
|
71
|
+
idx += 1
|
|
72
|
+
if end == n:
|
|
73
|
+
break
|
|
74
|
+
pos += step
|
|
75
|
+
return chunks
|
|
76
|
+
|
|
77
|
+
def split_many(
|
|
78
|
+
self,
|
|
79
|
+
items: Iterable[tuple[str, str | None]],
|
|
80
|
+
) -> list[Chunk]:
|
|
81
|
+
"""Split several texts; ``items`` yields ``(text, source_id)`` pairs."""
|
|
82
|
+
result: list[Chunk] = []
|
|
83
|
+
for text, source_id in items:
|
|
84
|
+
result.extend(self.split(text, source_id=source_id))
|
|
85
|
+
return result
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""High-level context orchestrator: chunk → rank → (optionally) summarize."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
from forgecli.core.service import Service
|
|
8
|
+
from forgecli.optimizer.chunker import Chunk, Chunker
|
|
9
|
+
from forgecli.optimizer.ranker import Ranker
|
|
10
|
+
from forgecli.optimizer.summarizer import Summarizer
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class OptimizedContext:
|
|
15
|
+
"""The output of :meth:`ContextOptimizer.optimize`."""
|
|
16
|
+
|
|
17
|
+
chunks: list[Chunk] = field(default_factory=list)
|
|
18
|
+
summary: str = ""
|
|
19
|
+
total_chars: int = 0
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ContextOptimizer(Service):
|
|
23
|
+
"""Orchestrates chunking, ranking, and summarization."""
|
|
24
|
+
|
|
25
|
+
name = "optimizer"
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
*,
|
|
30
|
+
chunker: Chunker | None = None,
|
|
31
|
+
ranker: Ranker | None = None,
|
|
32
|
+
summarizer: Summarizer | None = None,
|
|
33
|
+
max_context_tokens: int = 200_000,
|
|
34
|
+
) -> None:
|
|
35
|
+
super().__init__()
|
|
36
|
+
self._chunker = chunker or Chunker()
|
|
37
|
+
self._ranker = ranker or Ranker()
|
|
38
|
+
self._summarizer = summarizer
|
|
39
|
+
self._max_tokens = max_context_tokens
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def chunker(self) -> Chunker:
|
|
43
|
+
return self._chunker
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def ranker(self) -> Ranker:
|
|
47
|
+
return self._ranker
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def summarizer(self) -> Summarizer | None:
|
|
51
|
+
return self._summarizer
|
|
52
|
+
|
|
53
|
+
def optimize(
|
|
54
|
+
self,
|
|
55
|
+
text: str,
|
|
56
|
+
*,
|
|
57
|
+
query: str = "",
|
|
58
|
+
source_id: str | None = None,
|
|
59
|
+
) -> OptimizedContext:
|
|
60
|
+
"""Chunk ``text`` and rank it against ``query`` (no AI call)."""
|
|
61
|
+
chunks = self._chunker.split(text, source_id=source_id)
|
|
62
|
+
if query:
|
|
63
|
+
ranked = self._ranker.rank(query, chunks)
|
|
64
|
+
chunks = [chunk for chunk, _score in ranked]
|
|
65
|
+
return OptimizedContext(
|
|
66
|
+
chunks=chunks,
|
|
67
|
+
total_chars=sum(len(c.text) for c in chunks),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
async def optimize_and_summarize(
|
|
71
|
+
self,
|
|
72
|
+
text: str,
|
|
73
|
+
*,
|
|
74
|
+
query: str = "",
|
|
75
|
+
source_id: str | None = None,
|
|
76
|
+
) -> OptimizedContext:
|
|
77
|
+
"""Like :meth:`optimize` but produces a summary when a summarizer is set."""
|
|
78
|
+
ctx = self.optimize(text, query=query, source_id=source_id)
|
|
79
|
+
if self._summarizer is not None and ctx.chunks:
|
|
80
|
+
ctx.summary = await self._summarizer.summarize(ctx.chunks)
|
|
81
|
+
return ctx
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Ponytail prompt-optimizer integration.
|
|
2
|
+
|
|
3
|
+
ForgeCLI integrates the [Ponytail](https://ponytail.dev/) ruleset
|
|
4
|
+
behind a small :class:`PromptOptimizer` interface and applies it
|
|
5
|
+
transparently to every chat call. Two implementations are shipped:
|
|
6
|
+
|
|
7
|
+
* :class:`PonytailRulesetOptimizer` — a self-contained Python
|
|
8
|
+
implementation of the Ponytail "ladder" (lite / full / ultra).
|
|
9
|
+
Always available, no external dependencies.
|
|
10
|
+
* :class:`PonytailCLIOptimizer` — an optional adapter that shells out
|
|
11
|
+
to an external ``ponytail`` binary if one is on ``PATH``.
|
|
12
|
+
|
|
13
|
+
The :class:`CompositeOptimizer` picks between them at runtime.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from abc import ABC, abstractmethod
|
|
19
|
+
from collections.abc import Sequence
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from enum import Enum
|
|
22
|
+
|
|
23
|
+
from forgecli.providers.base import ChatMessage, ChatRequest
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Intensity(str, Enum):
|
|
27
|
+
"""How aggressively to optimize prompts.
|
|
28
|
+
|
|
29
|
+
The semantics match the Ponytail commands exactly:
|
|
30
|
+
|
|
31
|
+
* ``off`` - no rewriting; pass prompts through unchanged.
|
|
32
|
+
* ``lite`` - default; appends a one-line hint naming the lazier
|
|
33
|
+
alternative and lets the model decide.
|
|
34
|
+
* ``full`` - applies the full "ladder" ruleset and instructs the
|
|
35
|
+
model to ship the shortest diff.
|
|
36
|
+
* ``ultra`` - aggressive YAGNI; instructs the model to challenge
|
|
37
|
+
the rest of the requirement in the same breath.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
OFF = "off"
|
|
41
|
+
LITE = "lite"
|
|
42
|
+
FULL = "full"
|
|
43
|
+
ULTRA = "ultra"
|
|
44
|
+
|
|
45
|
+
@classmethod
|
|
46
|
+
def parse(cls, value: str | Intensity | None) -> Intensity:
|
|
47
|
+
"""Parse a string into an :class:`Intensity`, falling back to LITE."""
|
|
48
|
+
if value is None or value == "":
|
|
49
|
+
return cls.LITE
|
|
50
|
+
if isinstance(value, cls):
|
|
51
|
+
return value
|
|
52
|
+
try:
|
|
53
|
+
return cls(value.lower())
|
|
54
|
+
except ValueError as exc:
|
|
55
|
+
raise ValueError(
|
|
56
|
+
f"Unknown intensity {value!r}; expected one of "
|
|
57
|
+
f"{', '.join(i.value for i in cls)}"
|
|
58
|
+
) from exc
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class OptimizedRequest:
|
|
63
|
+
"""The output of :meth:`PromptOptimizer.optimize_chat`."""
|
|
64
|
+
|
|
65
|
+
request: ChatRequest
|
|
66
|
+
notes: tuple[str, ...] = ()
|
|
67
|
+
intensity: Intensity = Intensity.LITE
|
|
68
|
+
source: str = "ruleset" # "ruleset" | "external" | "passthrough"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class PromptOptimizer(ABC):
|
|
72
|
+
"""Strategy interface for prompt optimization.
|
|
73
|
+
|
|
74
|
+
Implementations must be deterministic and side-effect free
|
|
75
|
+
(other than logging), so they can be invoked transparently before
|
|
76
|
+
every model call.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
name: str = "abstract"
|
|
80
|
+
|
|
81
|
+
@abstractmethod
|
|
82
|
+
async def optimize_chat(
|
|
83
|
+
self, request: ChatRequest
|
|
84
|
+
) -> OptimizedRequest:
|
|
85
|
+
"""Return an optimized copy of ``request``."""
|
|
86
|
+
|
|
87
|
+
async def is_available(self) -> bool:
|
|
88
|
+
"""Return whether this optimizer is available to use."""
|
|
89
|
+
return True
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class CompositeOptimizer(PromptOptimizer):
|
|
93
|
+
"""Pick the right optimizer for the configured :class:`Intensity`."""
|
|
94
|
+
|
|
95
|
+
name = "composite"
|
|
96
|
+
|
|
97
|
+
def __init__(
|
|
98
|
+
self,
|
|
99
|
+
*,
|
|
100
|
+
intensity: Intensity = Intensity.LITE,
|
|
101
|
+
ruleset: PromptOptimizer | None = None,
|
|
102
|
+
external: PromptOptimizer | None = None,
|
|
103
|
+
) -> None:
|
|
104
|
+
self._intensity = intensity
|
|
105
|
+
self._ruleset = ruleset
|
|
106
|
+
self._external = external
|
|
107
|
+
self._sync_ruleset_intensity()
|
|
108
|
+
|
|
109
|
+
def _sync_ruleset_intensity(self) -> None:
|
|
110
|
+
"""Propagate the composite's intensity to the ruleset, if compatible."""
|
|
111
|
+
ruleset = self._ruleset
|
|
112
|
+
if isinstance(ruleset, PonytailRulesetOptimizer):
|
|
113
|
+
ruleset.set_intensity(self._intensity)
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def intensity(self) -> Intensity:
|
|
117
|
+
return self._intensity
|
|
118
|
+
|
|
119
|
+
def set_intensity(self, intensity: Intensity | str) -> None:
|
|
120
|
+
self._intensity = Intensity.parse(intensity)
|
|
121
|
+
# Keep the ruleset in sync so its output reflects the new level
|
|
122
|
+
# when we fall back to it.
|
|
123
|
+
if isinstance(self._ruleset, PonytailRulesetOptimizer):
|
|
124
|
+
self._ruleset.set_intensity(self._intensity)
|
|
125
|
+
|
|
126
|
+
async def optimize_chat(self, request: ChatRequest) -> OptimizedRequest:
|
|
127
|
+
if self._intensity is Intensity.OFF:
|
|
128
|
+
return OptimizedRequest(
|
|
129
|
+
request=request,
|
|
130
|
+
notes=("optimizer off",),
|
|
131
|
+
intensity=Intensity.OFF,
|
|
132
|
+
source="passthrough",
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
if self._external is not None and await self._external.is_available():
|
|
136
|
+
result = await self._external.optimize_chat(request)
|
|
137
|
+
return OptimizedRequest(
|
|
138
|
+
request=result.request,
|
|
139
|
+
notes=result.notes,
|
|
140
|
+
intensity=self._intensity,
|
|
141
|
+
source="external",
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
if self._ruleset is None:
|
|
145
|
+
return OptimizedRequest(
|
|
146
|
+
request=request,
|
|
147
|
+
notes=("no ruleset registered",),
|
|
148
|
+
intensity=self._intensity,
|
|
149
|
+
source="passthrough",
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
result = await self._ruleset.optimize_chat(request)
|
|
153
|
+
return OptimizedRequest(
|
|
154
|
+
request=result.request,
|
|
155
|
+
notes=result.notes,
|
|
156
|
+
intensity=self._intensity,
|
|
157
|
+
source="ruleset",
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _ensure_user_message(messages: Sequence[ChatMessage]) -> bool:
|
|
162
|
+
return any(m.role.value == "user" for m in messages)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _clone_request(request: ChatRequest, messages: Sequence[ChatMessage]) -> ChatRequest:
|
|
166
|
+
"""Return a copy of ``request`` with ``messages`` replaced."""
|
|
167
|
+
return request.model_copy(update={"messages": list(messages)})
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
__all__ = [
|
|
171
|
+
"CompositeOptimizer",
|
|
172
|
+
"Intensity",
|
|
173
|
+
"OptimizedProvider",
|
|
174
|
+
"OptimizedRequest",
|
|
175
|
+
"PonytailCLIOptimizer",
|
|
176
|
+
"PonytailRulesetOptimizer",
|
|
177
|
+
"PromptOptimizer",
|
|
178
|
+
]
|
|
179
|
+
|
|
180
|
+
# Re-export the two concrete implementations and the decorator.
|
|
181
|
+
from forgecli.optimizer.ponytail.cli import PonytailCLIOptimizer # noqa: E402
|
|
182
|
+
from forgecli.optimizer.ponytail.decorator import OptimizedProvider # noqa: E402
|
|
183
|
+
from forgecli.optimizer.ponytail.ruleset import PonytailRulesetOptimizer # noqa: E402
|