egeo 2.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.
- egeo/__init__.py +35 -0
- egeo/__main__.py +7 -0
- egeo/agents.py +322 -0
- egeo/cli.py +228 -0
- egeo/decide.py +434 -0
- egeo/loop.py +534 -0
- egeo/pipeline.py +287 -0
- egeo/runtimes.py +241 -0
- egeo/substrate_lint.py +337 -0
- egeo/workspace.py +572 -0
- egeo-2.0.0.dist-info/METADATA +662 -0
- egeo-2.0.0.dist-info/RECORD +18 -0
- egeo-2.0.0.dist-info/WHEEL +5 -0
- egeo-2.0.0.dist-info/entry_points.txt +2 -0
- egeo-2.0.0.dist-info/licenses/LICENSE +21 -0
- egeo-2.0.0.dist-info/top_level.txt +3 -0
- geo_eval.py +503 -0
- llm_client.py +235 -0
egeo/__init__.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""E-GEO runtime adapter layer and standalone ``egeo`` CLI.
|
|
2
|
+
|
|
3
|
+
This package decouples the four GEO agents (Analyzer, Ranker, Rewriter, Indexer)
|
|
4
|
+
from any single host runtime. The agent *contract* is defined once in
|
|
5
|
+
:mod:`egeo.agents`; concrete runtimes plug in implementations via
|
|
6
|
+
:mod:`egeo.runtimes`. The :mod:`egeo.cli` module exposes a runtime-agnostic
|
|
7
|
+
``egeo`` command (``optimize``, ``evaluate``, ``optimize-prompts``, ``runtimes``).
|
|
8
|
+
|
|
9
|
+
The Ranker and Rewriter agents intentionally *reuse* the existing
|
|
10
|
+
``geo_eval``/``llm_client`` modules at the repository root rather than
|
|
11
|
+
duplicating their logic. To make those root-level modules importable regardless
|
|
12
|
+
of the current working directory (e.g. ``python -m egeo`` from any folder), this
|
|
13
|
+
module bootstraps the repository root onto ``sys.path``.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
__version__ = "2.0.0"
|
|
21
|
+
|
|
22
|
+
# Repository root = parent of this package directory. Adding it to sys.path lets
|
|
23
|
+
# ``import geo_eval`` / ``import llm_client`` succeed even when the CLI is run
|
|
24
|
+
# from outside the repo root or installed in editable mode.
|
|
25
|
+
_REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
26
|
+
if str(_REPO_ROOT) not in sys.path:
|
|
27
|
+
sys.path.insert(0, str(_REPO_ROOT))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def repo_root() -> Path:
|
|
31
|
+
"""Return the repository root that ships ``geo_eval``/``prompts``/``geo-output``."""
|
|
32
|
+
return _REPO_ROOT
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
__all__ = ["__version__", "repo_root"]
|
egeo/__main__.py
ADDED
egeo/agents.py
ADDED
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""Runtime-agnostic abstractions for the four GEO agents.
|
|
2
|
+
|
|
3
|
+
The four agents mirror the Claude Code definitions in ``.claude/agents/`` but are
|
|
4
|
+
expressed here as plain Python objects so *any* runtime can construct and run
|
|
5
|
+
them:
|
|
6
|
+
|
|
7
|
+
- :class:`Analyzer` — deterministic, offline GEO-signal scorer (no LLM).
|
|
8
|
+
- :class:`Ranker` — thin wrapper over ``geo_eval._rank_candidates``.
|
|
9
|
+
- :class:`Rewriter` — thin wrapper over ``geo_eval._rewrite_description``.
|
|
10
|
+
- :class:`Indexer` — deterministic JSON-LD template filler (no LLM).
|
|
11
|
+
|
|
12
|
+
The Ranker and Rewriter deliberately delegate to ``geo_eval`` so there is a
|
|
13
|
+
single source of truth for ranking/rewriting logic and prompts. Analyzer and
|
|
14
|
+
Indexer are deterministic and offline, so the full pipeline runs without an API
|
|
15
|
+
key (and therefore under ``GEO_EVAL_MOCK=1`` in CI).
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import re
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any, Dict, List, Optional, Sequence
|
|
24
|
+
|
|
25
|
+
# Reuse the harness rather than re-implementing it. The package __init__ has
|
|
26
|
+
# already placed the repo root on sys.path so these imports resolve.
|
|
27
|
+
import geo_eval
|
|
28
|
+
from geo_eval import Candidate
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"GEO_FEATURES",
|
|
32
|
+
"AnalysisResult",
|
|
33
|
+
"RankResult",
|
|
34
|
+
"RewriteResult",
|
|
35
|
+
"SchemaResult",
|
|
36
|
+
"Analyzer",
|
|
37
|
+
"Ranker",
|
|
38
|
+
"Rewriter",
|
|
39
|
+
"Indexer",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
# The 10 universal GEO features (see openspec/project.md and AGENTS.md).
|
|
43
|
+
GEO_FEATURES: List[str] = [
|
|
44
|
+
"ranking_emphasis",
|
|
45
|
+
"user_intent",
|
|
46
|
+
"competitive_diff",
|
|
47
|
+
"social_proof",
|
|
48
|
+
"narrative",
|
|
49
|
+
"authority",
|
|
50
|
+
"usps",
|
|
51
|
+
"urgency",
|
|
52
|
+
"scannable",
|
|
53
|
+
"factual",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
# Human-readable remediation copy used when a feature scores low.
|
|
57
|
+
_RECOMMENDATIONS: Dict[str, str] = {
|
|
58
|
+
"ranking_emphasis": "Frame the content as a top/leading choice (e.g. 'best for ...').",
|
|
59
|
+
"user_intent": "Open by directly answering the user's likely question or use case.",
|
|
60
|
+
"competitive_diff": "Call out concrete advantages over typical alternatives (no competitor names needed).",
|
|
61
|
+
"social_proof": "Add trust signals: customer counts, ratings, or testimonials (never fabricated).",
|
|
62
|
+
"narrative": "Tighten the flow into a persuasive, benefit-led story.",
|
|
63
|
+
"authority": "Add expert, confident phrasing and verifiable credentials or specifics.",
|
|
64
|
+
"usps": "Make the unique selling points explicit and scannable as bullets.",
|
|
65
|
+
"urgency": "Add a genuine urgency or scarcity signal where appropriate.",
|
|
66
|
+
"scannable": "Add headings, bullet lists, and short paragraphs for easy parsing.",
|
|
67
|
+
"factual": "Keep concrete, verifiable facts (numbers, units) and avoid vague hype.",
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class AnalysisResult:
|
|
73
|
+
"""Output of :class:`Analyzer` — GEO-signal scores and gap analysis."""
|
|
74
|
+
|
|
75
|
+
title: str
|
|
76
|
+
content_length: int
|
|
77
|
+
scores: Dict[str, int]
|
|
78
|
+
total_score: int
|
|
79
|
+
gaps: List[Dict[str, str]] = field(default_factory=list)
|
|
80
|
+
strengths: List[str] = field(default_factory=list)
|
|
81
|
+
priority_actions: List[str] = field(default_factory=list)
|
|
82
|
+
|
|
83
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
84
|
+
return {
|
|
85
|
+
"title": self.title,
|
|
86
|
+
"content_length": self.content_length,
|
|
87
|
+
"scores": self.scores,
|
|
88
|
+
"total_score": self.total_score,
|
|
89
|
+
"gaps": self.gaps,
|
|
90
|
+
"strengths": self.strengths,
|
|
91
|
+
"priority_actions": self.priority_actions,
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class RankResult:
|
|
97
|
+
"""Output of :class:`Ranker` — an ordering of candidate ids."""
|
|
98
|
+
|
|
99
|
+
query: str
|
|
100
|
+
ordered_ids: List[str]
|
|
101
|
+
|
|
102
|
+
def position_of(self, candidate_id: str) -> int:
|
|
103
|
+
"""1-based position of ``candidate_id`` in the ordering."""
|
|
104
|
+
return self.ordered_ids.index(candidate_id) + 1
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass
|
|
108
|
+
class RewriteResult:
|
|
109
|
+
"""Output of :class:`Rewriter` — the optimized description."""
|
|
110
|
+
|
|
111
|
+
title: str
|
|
112
|
+
original_description: str
|
|
113
|
+
rewritten_description: str
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass
|
|
117
|
+
class SchemaResult:
|
|
118
|
+
"""Output of :class:`Indexer` — a JSON-LD document."""
|
|
119
|
+
|
|
120
|
+
schema_type: str
|
|
121
|
+
json_ld: Dict[str, Any]
|
|
122
|
+
|
|
123
|
+
def to_json(self, *, indent: int = 2) -> str:
|
|
124
|
+
return json.dumps(self.json_ld, indent=indent, ensure_ascii=False)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# --------------------------------------------------------------------------- #
|
|
128
|
+
# Analyzer (deterministic, offline)
|
|
129
|
+
# --------------------------------------------------------------------------- #
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _strip_frontmatter(text: str) -> str:
|
|
133
|
+
"""Remove a leading YAML/TOML frontmatter block if present."""
|
|
134
|
+
m = re.match(r"^---\s*\n.*?\n---\s*(?:\n|$)", text, re.DOTALL)
|
|
135
|
+
if m:
|
|
136
|
+
return text[m.end():]
|
|
137
|
+
m = re.match(r"^\+\+\+\s*\n.*?\n\+\+\+\s*(?:\n|$)", text, re.DOTALL)
|
|
138
|
+
if m:
|
|
139
|
+
return text[m.end():]
|
|
140
|
+
return text
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _clamp(value: int, low: int = 0, high: int = 10) -> int:
|
|
144
|
+
return max(low, min(high, value))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class Analyzer:
|
|
148
|
+
"""Score content against the 10 GEO features with a transparent heuristic.
|
|
149
|
+
|
|
150
|
+
This is an *offline proxy* for the Claude ``geo-analyzer`` agent: it never
|
|
151
|
+
calls an LLM, so it is deterministic and runs with no API key. It looks for
|
|
152
|
+
the same signals the analyzer prompt describes (superlatives, intent, social
|
|
153
|
+
proof, structure, etc.).
|
|
154
|
+
"""
|
|
155
|
+
|
|
156
|
+
_RANKING_WORDS = ("best", "top", "#1", "number one", "leading", "premier", "ultimate", "trusted", "award")
|
|
157
|
+
_INTENT_WORDS = ("best for", "ideal for", "designed for", "perfect for", "how to", "what is", "use case", "for businesses", "for teams")
|
|
158
|
+
_DIFF_WORDS = ("unlike", "compared to", "vs ", "versus", "alternative", "unique", "differentiat", "better than", "outperform")
|
|
159
|
+
_PROOF_WORDS = ("customer", "users", "rated", "rating", "review", "testimonial", "trusted by", "case study", "%", "stars")
|
|
160
|
+
_AUTHORITY_WORDS = ("expert", "certified", "proven", "industry", "research", "years", "award-winning", "official", "data shows")
|
|
161
|
+
_URGENCY_WORDS = ("now", "today", "limited", "offer", "hurry", "deadline", "while supplies", "act fast", "don't miss", "ends ")
|
|
162
|
+
_NARRATIVE_WORDS = ("because", "so that", "imagine", "whether", "that's why", "which means", "helps you", "you can")
|
|
163
|
+
|
|
164
|
+
def analyze(self, content: str, *, title: Optional[str] = None) -> AnalysisResult:
|
|
165
|
+
body = _strip_frontmatter(content)
|
|
166
|
+
lower = body.lower()
|
|
167
|
+
lines = [ln for ln in body.splitlines()]
|
|
168
|
+
non_empty = [ln for ln in lines if ln.strip()]
|
|
169
|
+
words = re.findall(r"[A-Za-z0-9#%']+", lower)
|
|
170
|
+
word_count = len(words)
|
|
171
|
+
|
|
172
|
+
if not title:
|
|
173
|
+
heading = next((ln for ln in lines if ln.strip().startswith("#")), "")
|
|
174
|
+
title = heading.lstrip("#").strip() or (non_empty[0].strip() if non_empty else "Untitled")
|
|
175
|
+
|
|
176
|
+
def keyword_score(keywords: Sequence[str], cap: int = 3) -> int:
|
|
177
|
+
hits = sum(lower.count(k) for k in keywords)
|
|
178
|
+
return _clamp(min(hits, cap) * (10 // cap) + (2 if hits else 0))
|
|
179
|
+
|
|
180
|
+
bullets = sum(1 for ln in lines if ln.strip().startswith(("-", "*", "•")) or re.match(r"^\s*\d+\.", ln))
|
|
181
|
+
headings = sum(1 for ln in lines if ln.strip().startswith("#"))
|
|
182
|
+
numbers = len(re.findall(r"\b\d[\d,\.]*\b", body))
|
|
183
|
+
|
|
184
|
+
scores: Dict[str, int] = {
|
|
185
|
+
"ranking_emphasis": keyword_score(self._RANKING_WORDS),
|
|
186
|
+
"user_intent": keyword_score(self._INTENT_WORDS),
|
|
187
|
+
"competitive_diff": keyword_score(self._DIFF_WORDS),
|
|
188
|
+
"social_proof": _clamp(keyword_score(self._PROOF_WORDS) + (2 if numbers >= 2 else 0)),
|
|
189
|
+
"narrative": _clamp(keyword_score(self._NARRATIVE_WORDS) + (2 if word_count > 80 else 0)),
|
|
190
|
+
"authority": keyword_score(self._AUTHORITY_WORDS),
|
|
191
|
+
"usps": _clamp((6 if bullets >= 3 else bullets * 2) + (2 if "feature" in lower or "benefit" in lower else 0)),
|
|
192
|
+
"urgency": keyword_score(self._URGENCY_WORDS),
|
|
193
|
+
"scannable": _clamp((4 if headings >= 1 else 0) + (4 if bullets >= 2 else bullets * 2) + (2 if len(non_empty) >= 4 else 0)),
|
|
194
|
+
"factual": _clamp(3 + min(numbers, 5) + (2 if numbers >= 3 else 0)),
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
total = sum(scores.values())
|
|
198
|
+
gaps = [
|
|
199
|
+
{
|
|
200
|
+
"feature": feat,
|
|
201
|
+
"current": f"Low signal (score {scores[feat]}/10)",
|
|
202
|
+
"recommendation": _RECOMMENDATIONS[feat],
|
|
203
|
+
}
|
|
204
|
+
for feat in GEO_FEATURES
|
|
205
|
+
if scores[feat] < 5
|
|
206
|
+
]
|
|
207
|
+
strengths = [feat for feat in GEO_FEATURES if scores[feat] >= 7]
|
|
208
|
+
priority = sorted(GEO_FEATURES, key=lambda f: scores[f])[:3]
|
|
209
|
+
priority_actions = [_RECOMMENDATIONS[f] for f in priority]
|
|
210
|
+
|
|
211
|
+
return AnalysisResult(
|
|
212
|
+
title=title,
|
|
213
|
+
content_length=len(body),
|
|
214
|
+
scores=scores,
|
|
215
|
+
total_score=total,
|
|
216
|
+
gaps=gaps,
|
|
217
|
+
strengths=strengths,
|
|
218
|
+
priority_actions=priority_actions,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# --------------------------------------------------------------------------- #
|
|
223
|
+
# Ranker / Rewriter (reuse geo_eval; the only LLM-backed agents)
|
|
224
|
+
# --------------------------------------------------------------------------- #
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class Ranker:
|
|
228
|
+
"""Simulate AI-engine ranking by delegating to ``geo_eval._rank_candidates``."""
|
|
229
|
+
|
|
230
|
+
def __init__(self, *, client: Any, model: str, prompts_dir: Path, temperature: float = 0.0):
|
|
231
|
+
self._client = client
|
|
232
|
+
self._model = model
|
|
233
|
+
self._system = geo_eval._read_text(prompts_dir / "ranker_system.txt")
|
|
234
|
+
self._user = geo_eval._read_text(prompts_dir / "ranker_user.txt")
|
|
235
|
+
self._temperature = temperature
|
|
236
|
+
|
|
237
|
+
def rank(self, query: str, candidates: Sequence[Candidate]) -> RankResult:
|
|
238
|
+
ordered = geo_eval._rank_candidates(
|
|
239
|
+
client=self._client,
|
|
240
|
+
model=self._model,
|
|
241
|
+
system_prompt=self._system,
|
|
242
|
+
user_template=self._user,
|
|
243
|
+
query=query,
|
|
244
|
+
candidates=candidates,
|
|
245
|
+
temperature=self._temperature,
|
|
246
|
+
)
|
|
247
|
+
return RankResult(query=query, ordered_ids=list(ordered))
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class Rewriter:
|
|
251
|
+
"""Optimize a description by delegating to ``geo_eval._rewrite_description``."""
|
|
252
|
+
|
|
253
|
+
def __init__(self, *, client: Any, model: str, prompts_dir: Path, temperature: float = 0.0):
|
|
254
|
+
self._client = client
|
|
255
|
+
self._model = model
|
|
256
|
+
self._system = geo_eval._read_text(prompts_dir / "rewriter_system.txt")
|
|
257
|
+
self._user = geo_eval._read_text(prompts_dir / "rewriter_user.txt")
|
|
258
|
+
self._temperature = temperature
|
|
259
|
+
|
|
260
|
+
def rewrite(self, title: str, description: str) -> RewriteResult:
|
|
261
|
+
rewritten = geo_eval._rewrite_description(
|
|
262
|
+
client=self._client,
|
|
263
|
+
model=self._model,
|
|
264
|
+
system_prompt=self._system,
|
|
265
|
+
user_template=self._user,
|
|
266
|
+
title=title,
|
|
267
|
+
description=description,
|
|
268
|
+
temperature=self._temperature,
|
|
269
|
+
)
|
|
270
|
+
return RewriteResult(
|
|
271
|
+
title=title,
|
|
272
|
+
original_description=description,
|
|
273
|
+
rewritten_description=rewritten,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
# --------------------------------------------------------------------------- #
|
|
278
|
+
# Indexer (deterministic, offline)
|
|
279
|
+
# --------------------------------------------------------------------------- #
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
class Indexer:
|
|
283
|
+
"""Generate JSON-LD schema by filling a template shipped in ``geo-output/schema``.
|
|
284
|
+
|
|
285
|
+
This mirrors the Claude ``geo-indexer`` agent's job but is deterministic and
|
|
286
|
+
offline. Known ``name``/``description`` fields are filled; remaining
|
|
287
|
+
``[FILL: ...]`` placeholders are left intact so users complete them. The
|
|
288
|
+
output still passes ``scripts/validate_jsonld.py``.
|
|
289
|
+
"""
|
|
290
|
+
|
|
291
|
+
# Map a logical content type to a shipped template file.
|
|
292
|
+
_TEMPLATE_BY_TYPE = {
|
|
293
|
+
"Organization": "Organization.json",
|
|
294
|
+
"Product": "Product.json",
|
|
295
|
+
"Service": "Service.json",
|
|
296
|
+
"Article": "Article.json",
|
|
297
|
+
"FAQPage": "FAQPage.json",
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
def __init__(self, *, schema_dir: Path):
|
|
301
|
+
self._schema_dir = schema_dir
|
|
302
|
+
|
|
303
|
+
def available_types(self) -> List[str]:
|
|
304
|
+
return [t for t, f in self._TEMPLATE_BY_TYPE.items() if (self._schema_dir / f).exists()]
|
|
305
|
+
|
|
306
|
+
def generate_schema(self, *, schema_type: str, name: str, description: str) -> SchemaResult:
|
|
307
|
+
filename = self._TEMPLATE_BY_TYPE.get(schema_type)
|
|
308
|
+
if not filename:
|
|
309
|
+
raise ValueError(
|
|
310
|
+
f"Unknown schema type '{schema_type}'. Known: {sorted(self._TEMPLATE_BY_TYPE)}"
|
|
311
|
+
)
|
|
312
|
+
template_path = self._schema_dir / filename
|
|
313
|
+
doc: Dict[str, Any] = json.loads(template_path.read_text(encoding="utf-8"))
|
|
314
|
+
|
|
315
|
+
# Fill the two fields we can confidently populate from the pipeline.
|
|
316
|
+
name_key = "headline" if schema_type == "Article" else "name"
|
|
317
|
+
if name_key in doc:
|
|
318
|
+
doc[name_key] = name
|
|
319
|
+
if "description" in doc:
|
|
320
|
+
doc["description"] = description
|
|
321
|
+
|
|
322
|
+
return SchemaResult(schema_type=schema_type, json_ld=doc)
|
egeo/cli.py
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""The standalone ``egeo`` command-line interface.
|
|
2
|
+
|
|
3
|
+
Runtime-agnostic entry point for the GEO pipeline. Subcommands:
|
|
4
|
+
|
|
5
|
+
- ``optimize <file>`` analyze → rank → rewrite → index a local file.
|
|
6
|
+
- ``evaluate`` thin wrapper over ``geo_eval.evaluate``.
|
|
7
|
+
- ``optimize-prompts`` thin wrapper over ``geo_eval.optimize`` (meta-optimizer).
|
|
8
|
+
- ``runtimes`` list available runtimes and their status.
|
|
9
|
+
- ``loop`` loop mode: ``run``, ``collect``, ``doctor`` (see :mod:`egeo.loop`).
|
|
10
|
+
|
|
11
|
+
Everything honors ``GEO_EVAL_MOCK=1`` (via ``llm_client.get_client``), so the
|
|
12
|
+
CLI runs offline in CI without an API key.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import List, Optional
|
|
22
|
+
|
|
23
|
+
from . import __version__, repo_root
|
|
24
|
+
|
|
25
|
+
_DEFAULT_PROMPTS = str(repo_root() / "prompts")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _add_evaluate_parser(sub: argparse._SubParsersAction) -> None:
|
|
29
|
+
p = sub.add_parser("evaluate", help="Evaluate prompt quality on a dataset (wraps geo_eval.evaluate).")
|
|
30
|
+
p.add_argument("--dataset", required=True)
|
|
31
|
+
p.add_argument("--prompts", default=_DEFAULT_PROMPTS)
|
|
32
|
+
p.add_argument("--ranker-model", default=os.environ.get("RANKER_MODEL", "gpt-4o"))
|
|
33
|
+
p.add_argument("--rewriter-model", default=os.environ.get("REWRITER_MODEL", "gpt-4o"))
|
|
34
|
+
p.add_argument("--temperature", type=float, default=0.0)
|
|
35
|
+
p.add_argument("--seed", type=int, default=7)
|
|
36
|
+
p.add_argument("--limit", type=int, default=None)
|
|
37
|
+
p.add_argument("--verbose", action="store_true")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _add_optimize_prompts_parser(sub: argparse._SubParsersAction) -> None:
|
|
41
|
+
p = sub.add_parser(
|
|
42
|
+
"optimize-prompts",
|
|
43
|
+
help="Meta-optimize the rewriter prompt (wraps geo_eval.optimize; non-destructive by default).",
|
|
44
|
+
)
|
|
45
|
+
p.add_argument("--train", required=True)
|
|
46
|
+
p.add_argument("--val", required=True)
|
|
47
|
+
p.add_argument("--prompts", default=_DEFAULT_PROMPTS)
|
|
48
|
+
p.add_argument("--ranker-model", default=os.environ.get("RANKER_MODEL", "gpt-4o"))
|
|
49
|
+
p.add_argument("--rewriter-model", default=os.environ.get("REWRITER_MODEL", "gpt-4o"))
|
|
50
|
+
p.add_argument("--meta-model", default=os.environ.get("META_MODEL", "gpt-4o"))
|
|
51
|
+
p.add_argument("--temperature", type=float, default=0.0)
|
|
52
|
+
p.add_argument("--seed", type=int, default=7)
|
|
53
|
+
p.add_argument("--iters", type=int, default=5)
|
|
54
|
+
p.add_argument(
|
|
55
|
+
"--apply",
|
|
56
|
+
action="store_true",
|
|
57
|
+
help="Overwrite the working rewriter prompt in place (default: write *.candidate.txt).",
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _add_optimize_parser(sub: argparse._SubParsersAction) -> None:
|
|
62
|
+
p = sub.add_parser("optimize", help="Run the GEO pipeline on a local content file.")
|
|
63
|
+
p.add_argument("input", help="Path to a local content file (Markdown/text).")
|
|
64
|
+
p.add_argument("--out-dir", default="geo-output", help="Output directory (default: geo-output).")
|
|
65
|
+
p.add_argument("--query", default=None, help="Search query to rank against (default: derived from the title).")
|
|
66
|
+
p.add_argument(
|
|
67
|
+
"--schema-type",
|
|
68
|
+
default="Article",
|
|
69
|
+
choices=["Organization", "Product", "Service", "Article", "FAQPage"],
|
|
70
|
+
help="JSON-LD schema template to emit (default: Article).",
|
|
71
|
+
)
|
|
72
|
+
p.add_argument("--runtime", default="python", help="Runtime to use (default: python).")
|
|
73
|
+
p.add_argument("--ranker-model", default=os.environ.get("RANKER_MODEL", "gpt-4o"))
|
|
74
|
+
p.add_argument("--rewriter-model", default=os.environ.get("REWRITER_MODEL", "gpt-4o"))
|
|
75
|
+
p.add_argument("--temperature", type=float, default=0.0)
|
|
76
|
+
p.add_argument("--json", action="store_true", help="Print only the machine-readable JSON summary.")
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _cmd_loop(args: argparse.Namespace) -> int:
|
|
80
|
+
from . import loop
|
|
81
|
+
|
|
82
|
+
return loop.main(args)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _add_runtimes_parser(sub: argparse._SubParsersAction) -> None:
|
|
86
|
+
p = sub.add_parser("runtimes", help="List available runtimes and their status.")
|
|
87
|
+
p.add_argument("--json", action="store_true", help="Print machine-readable JSON.")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _cmd_evaluate(args: argparse.Namespace) -> int:
|
|
91
|
+
import geo_eval
|
|
92
|
+
|
|
93
|
+
summary = geo_eval.evaluate(
|
|
94
|
+
dataset_path=Path(args.dataset),
|
|
95
|
+
prompts_dir=Path(args.prompts),
|
|
96
|
+
ranker_model=args.ranker_model,
|
|
97
|
+
rewriter_model=args.rewriter_model,
|
|
98
|
+
temperature=args.temperature,
|
|
99
|
+
seed=args.seed,
|
|
100
|
+
limit=args.limit,
|
|
101
|
+
verbose=args.verbose,
|
|
102
|
+
)
|
|
103
|
+
print(json.dumps(summary, indent=2, sort_keys=True))
|
|
104
|
+
return 0
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _cmd_optimize_prompts(args: argparse.Namespace) -> int:
|
|
108
|
+
import geo_eval
|
|
109
|
+
|
|
110
|
+
res = geo_eval.optimize(
|
|
111
|
+
train_path=Path(args.train),
|
|
112
|
+
val_path=Path(args.val),
|
|
113
|
+
prompts_dir=Path(args.prompts),
|
|
114
|
+
ranker_model=args.ranker_model,
|
|
115
|
+
rewriter_model=args.rewriter_model,
|
|
116
|
+
meta_model=args.meta_model,
|
|
117
|
+
temperature=args.temperature,
|
|
118
|
+
seed=args.seed,
|
|
119
|
+
iters=args.iters,
|
|
120
|
+
apply=args.apply,
|
|
121
|
+
)
|
|
122
|
+
print(json.dumps(res, indent=2, sort_keys=True))
|
|
123
|
+
return 0
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _cmd_optimize(args: argparse.Namespace) -> int:
|
|
127
|
+
from .pipeline import optimize_content
|
|
128
|
+
from .runtimes import get_runtime
|
|
129
|
+
|
|
130
|
+
input_path = Path(args.input)
|
|
131
|
+
if not input_path.is_file():
|
|
132
|
+
print(f"ERROR: input file not found: {input_path}", file=sys.stderr)
|
|
133
|
+
return 1
|
|
134
|
+
|
|
135
|
+
content = input_path.read_text(encoding="utf-8")
|
|
136
|
+
runtime = get_runtime(
|
|
137
|
+
args.runtime,
|
|
138
|
+
ranker_model=args.ranker_model,
|
|
139
|
+
rewriter_model=args.rewriter_model,
|
|
140
|
+
temperature=args.temperature,
|
|
141
|
+
)
|
|
142
|
+
if not runtime.executes_in_process:
|
|
143
|
+
print(
|
|
144
|
+
f"ERROR: runtime '{runtime.name}' does not execute in-process. "
|
|
145
|
+
f"Use --runtime python, or run the pipeline inside Claude Code.",
|
|
146
|
+
file=sys.stderr,
|
|
147
|
+
)
|
|
148
|
+
return 2
|
|
149
|
+
|
|
150
|
+
result = optimize_content(
|
|
151
|
+
runtime=runtime,
|
|
152
|
+
content=content,
|
|
153
|
+
source=str(input_path),
|
|
154
|
+
output_dir=Path(args.out_dir),
|
|
155
|
+
query=args.query,
|
|
156
|
+
schema_type=args.schema_type,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
if args.json:
|
|
160
|
+
print(json.dumps(result.summary(), indent=2, sort_keys=True))
|
|
161
|
+
return 0
|
|
162
|
+
|
|
163
|
+
delta = result.rank_improvement
|
|
164
|
+
delta_str = f"+{delta}" if delta > 0 else str(delta)
|
|
165
|
+
print(f"✓ Optimized: {result.title}")
|
|
166
|
+
print(f" Runtime: {result.runtime}")
|
|
167
|
+
print(f" GEO score: {result.analysis.total_score}/100")
|
|
168
|
+
print(f" Rank: #{result.rank_before} → #{result.rank_after} ({delta_str})")
|
|
169
|
+
print(f" Output dir: {result.output_dir}")
|
|
170
|
+
for path in result.written_files:
|
|
171
|
+
print(f" - {path}")
|
|
172
|
+
return 0
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _cmd_runtimes(args: argparse.Namespace) -> int:
|
|
176
|
+
from .runtimes import runtime_status
|
|
177
|
+
|
|
178
|
+
infos = runtime_status()
|
|
179
|
+
if args.json:
|
|
180
|
+
print(json.dumps([info.__dict__ for info in infos], indent=2, sort_keys=True))
|
|
181
|
+
return 0
|
|
182
|
+
|
|
183
|
+
print("Supported runtimes:\n")
|
|
184
|
+
for info in infos:
|
|
185
|
+
status = "available" if info.available else "unavailable"
|
|
186
|
+
mode = "in-process" if info.executes_in_process else "host-executed"
|
|
187
|
+
aliases = f" (aliases: {', '.join(info.aliases)})" if info.aliases else ""
|
|
188
|
+
print(f"• {info.name}{aliases}")
|
|
189
|
+
print(f" status: {status} | mode: {mode}")
|
|
190
|
+
print(f" {info.description}")
|
|
191
|
+
return 0
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
195
|
+
parser = argparse.ArgumentParser(
|
|
196
|
+
prog="egeo",
|
|
197
|
+
description="E-GEO: runtime-agnostic Generative Engine Optimization CLI.",
|
|
198
|
+
)
|
|
199
|
+
parser.add_argument("--version", action="version", version=f"egeo {__version__}")
|
|
200
|
+
sub = parser.add_subparsers(dest="cmd", required=True)
|
|
201
|
+
_add_optimize_parser(sub)
|
|
202
|
+
_add_evaluate_parser(sub)
|
|
203
|
+
_add_optimize_prompts_parser(sub)
|
|
204
|
+
_add_runtimes_parser(sub)
|
|
205
|
+
from . import loop
|
|
206
|
+
|
|
207
|
+
loop.add_parser(sub)
|
|
208
|
+
return parser
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
_DISPATCH = {
|
|
212
|
+
"optimize": _cmd_optimize,
|
|
213
|
+
"evaluate": _cmd_evaluate,
|
|
214
|
+
"optimize-prompts": _cmd_optimize_prompts,
|
|
215
|
+
"runtimes": _cmd_runtimes,
|
|
216
|
+
"loop": _cmd_loop,
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
221
|
+
parser = build_parser()
|
|
222
|
+
args = parser.parse_args(argv)
|
|
223
|
+
handler = _DISPATCH[args.cmd]
|
|
224
|
+
return handler(args)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
if __name__ == "__main__":
|
|
228
|
+
raise SystemExit(main())
|