groundy 0.3.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.
- groundy/__init__.py +32 -0
- groundy/backends/__init__.py +0 -0
- groundy/backends/embeddings.py +44 -0
- groundy/backends/fastembed.py +52 -0
- groundy/backends/llm_judge.py +45 -0
- groundy/cli.py +352 -0
- groundy/core.py +445 -0
- groundy/observability/__init__.py +99 -0
- groundy/observability/langfuse.py +101 -0
- groundy/prompts.py +37 -0
- groundy/py.typed +0 -0
- groundy-0.3.0.dist-info/METADATA +348 -0
- groundy-0.3.0.dist-info/RECORD +16 -0
- groundy-0.3.0.dist-info/WHEEL +4 -0
- groundy-0.3.0.dist-info/entry_points.txt +2 -0
- groundy-0.3.0.dist-info/licenses/LICENSE +21 -0
groundy/__init__.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
3
|
+
|
|
4
|
+
from loguru import logger
|
|
5
|
+
|
|
6
|
+
from groundy.core import Cache, GroundyChecker, GroundyResult, groundy
|
|
7
|
+
from groundy.observability import NoopTracer, Span, Tracer
|
|
8
|
+
|
|
9
|
+
# Silent in production by default. Turn debug logging on for dev environments by
|
|
10
|
+
# setting GROUNDY_DEBUG=1 (e.g. in your dev .env) — never set it in production.
|
|
11
|
+
_DEBUG = os.getenv("GROUNDY_DEBUG", "").strip().lower() in ("1", "true", "yes", "on")
|
|
12
|
+
if _DEBUG:
|
|
13
|
+
logger.enable("groundy")
|
|
14
|
+
else:
|
|
15
|
+
logger.disable("groundy")
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"groundy",
|
|
19
|
+
"GroundyChecker",
|
|
20
|
+
"GroundyResult",
|
|
21
|
+
"Cache",
|
|
22
|
+
"Tracer",
|
|
23
|
+
"Span",
|
|
24
|
+
"NoopTracer",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
# Single source of truth: the version declared in pyproject.toml (read from the
|
|
28
|
+
# installed package metadata), so this never drifts from the distribution.
|
|
29
|
+
try:
|
|
30
|
+
__version__ = version("groundy")
|
|
31
|
+
except PackageNotFoundError: # running from a source tree that isn't installed
|
|
32
|
+
__version__ = "0.0.0+unknown"
|
|
File without changes
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""
|
|
2
|
+
groundy.backends.embeddings
|
|
3
|
+
~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
4
|
+
Semantic similarity via sentence-transformers + cosine similarity.
|
|
5
|
+
Runs fully local, no API calls.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from functools import lru_cache
|
|
11
|
+
from typing import List
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@lru_cache(maxsize=1)
|
|
15
|
+
def _get_model():
|
|
16
|
+
"""Lazy-load the model once and cache it."""
|
|
17
|
+
from sentence_transformers import SentenceTransformer
|
|
18
|
+
|
|
19
|
+
# all-MiniLM-L6-v2: fast, small (80MB), good enough for consistency checking
|
|
20
|
+
# swap to 'all-mpnet-base-v2' for better quality at the cost of speed
|
|
21
|
+
return SentenceTransformer("all-MiniLM-L6-v2")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def cosine_similarity_batch(texts_a: List[str], texts_b: List[str]) -> List[float]:
|
|
25
|
+
"""
|
|
26
|
+
Compute cosine similarity for a list of text pairs.
|
|
27
|
+
|
|
28
|
+
Returns one float per pair. Cosine is in [-1, 1]; for related text it sits in
|
|
29
|
+
~[0, 1], but genuinely opposed answers can score negative — that's intentional
|
|
30
|
+
signal (it drags the consistency score down), so scores are NOT clamped.
|
|
31
|
+
"""
|
|
32
|
+
model = _get_model()
|
|
33
|
+
|
|
34
|
+
# The caller expands C(n,2) pairs into two aligned lists, so every distinct answer
|
|
35
|
+
# shows up n-1 times across them — encoding all of them is n(n-1) forward passes for
|
|
36
|
+
# only n_distinct unique strings. Embed each distinct string once; the pair scores are
|
|
37
|
+
# then just dot products of cached vectors (the encode is the cost, not the dot).
|
|
38
|
+
uniq = list(dict.fromkeys(texts_a + texts_b))
|
|
39
|
+
vectors = model.encode(uniq, normalize_embeddings=True)
|
|
40
|
+
vec = dict(zip(uniq, vectors))
|
|
41
|
+
|
|
42
|
+
# dot product of normalized vectors = cosine similarity (not clamped: opposed answers
|
|
43
|
+
# can score negative, which is intentional signal).
|
|
44
|
+
return [float((vec[a] * vec[b]).sum()) for a, b in zip(texts_a, texts_b)]
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""
|
|
2
|
+
groundy.backends.fastembed
|
|
3
|
+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
4
|
+
Semantic similarity via fastembed (ONNX Runtime) + cosine similarity.
|
|
5
|
+
|
|
6
|
+
Same model as the default ``embeddings`` backend — ``all-MiniLM-L6-v2`` — but run through
|
|
7
|
+
ONNX Runtime instead of torch/sentence-transformers, so the import is ~15x lighter (~0.7s
|
|
8
|
+
vs ~4.8s) and there's no torch in the process. Opt-in: ``backend="fastembed"`` (needs the
|
|
9
|
+
``fastembed`` extra). Embedding quality is identical; only the engine differs.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from functools import lru_cache
|
|
15
|
+
from typing import List
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
|
|
19
|
+
MODEL_NAME = "sentence-transformers/all-MiniLM-L6-v2"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@lru_cache(maxsize=1)
|
|
23
|
+
def _get_model():
|
|
24
|
+
"""Lazy-load the ONNX model once and cache it (first call downloads the model)."""
|
|
25
|
+
from fastembed import TextEmbedding
|
|
26
|
+
|
|
27
|
+
return TextEmbedding(model_name=MODEL_NAME)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def cosine_similarity_batch(texts_a: List[str], texts_b: List[str]) -> List[float]:
|
|
31
|
+
"""
|
|
32
|
+
Compute cosine similarity for a list of text pairs (fastembed/ONNX engine).
|
|
33
|
+
|
|
34
|
+
Mirrors the default ``embeddings`` backend: each distinct string is embedded once
|
|
35
|
+
(the caller hands in the expanded pair lists, so every answer repeats n-1 times), then
|
|
36
|
+
the pair scores are dot products of the cached, L2-normalised vectors. Cosine is NOT
|
|
37
|
+
clamped — opposed answers can score negative, which is intentional signal.
|
|
38
|
+
"""
|
|
39
|
+
model = _get_model()
|
|
40
|
+
|
|
41
|
+
uniq = list(dict.fromkeys(texts_a + texts_b))
|
|
42
|
+
if not uniq:
|
|
43
|
+
return []
|
|
44
|
+
# fastembed.embed yields one vector per input; normalise to unit length so dot = cosine
|
|
45
|
+
# (don't assume the engine normalises for us — divide explicitly, guarding zero norm).
|
|
46
|
+
vec = {}
|
|
47
|
+
for text, v in zip(uniq, model.embed(uniq)):
|
|
48
|
+
v = np.asarray(v, dtype=np.float64)
|
|
49
|
+
norm = np.linalg.norm(v)
|
|
50
|
+
vec[text] = v / norm if norm else v
|
|
51
|
+
|
|
52
|
+
return [float(np.dot(vec[a], vec[b])) for a, b in zip(texts_a, texts_b)]
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""
|
|
2
|
+
groundy.backends.llm_judge
|
|
3
|
+
~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
4
|
+
Semantic similarity via LLM-as-judge.
|
|
5
|
+
|
|
6
|
+
STUB — interface is defined and ready, implementation is TODO.
|
|
7
|
+
The signature matches embeddings.py so backends are interchangeable.
|
|
8
|
+
|
|
9
|
+
When implemented, this will ask Claude to rate semantic equivalence
|
|
10
|
+
between answer pairs on a 0-1 scale, which is more robust for
|
|
11
|
+
domain-specific or nuanced content but costs extra API calls.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import List
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def judge_similarity_batch(texts_a: List[str], texts_b: List[str]) -> List[float]:
|
|
20
|
+
"""
|
|
21
|
+
Rate semantic similarity between pairs of texts using an LLM as judge.
|
|
22
|
+
|
|
23
|
+
Parameters
|
|
24
|
+
----------
|
|
25
|
+
texts_a, texts_b : list of str
|
|
26
|
+
Parallel lists of texts to compare.
|
|
27
|
+
|
|
28
|
+
Returns
|
|
29
|
+
-------
|
|
30
|
+
list of float
|
|
31
|
+
Similarity scores in [0, 1].
|
|
32
|
+
|
|
33
|
+
TODO:
|
|
34
|
+
- Call Claude with a structured prompt asking for a 0.0-1.0 similarity score
|
|
35
|
+
- Use structured output / tool_use to get a clean float
|
|
36
|
+
- Batch pairs into a single prompt to reduce API calls
|
|
37
|
+
- Cache results for identical pairs
|
|
38
|
+
"""
|
|
39
|
+
# When implemented, format groundy.prompts.JUDGE_PROMPT per pair:
|
|
40
|
+
# JUDGE_PROMPT.format(text_a=a, text_b=b)
|
|
41
|
+
raise NotImplementedError(
|
|
42
|
+
"llm_judge backend is a stub. "
|
|
43
|
+
"Use backend='embeddings' for now, or implement this. "
|
|
44
|
+
"See the docstring for the spec."
|
|
45
|
+
)
|
groundy/cli.py
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
"""
|
|
2
|
+
groundy.cli
|
|
3
|
+
~~~~~~~~~~~
|
|
4
|
+
A tiny ``groundy`` CLI — paste a question, watch it think, see the verdict.
|
|
5
|
+
|
|
6
|
+
Zero extra deps: hand-rolled ANSI + a braille spinner, both TTY-aware (they degrade to
|
|
7
|
+
plain text when piped). It reformulates *and* answers on ``GROUNDY_MODEL`` via the
|
|
8
|
+
OpenAI-compatible client, so ``GROUNDY_API_KEY`` + ``GROUNDY_MODEL`` runs the whole thing.
|
|
9
|
+
|
|
10
|
+
The CLI owns its output: it silences groundy's debug logging (even if ``GROUNDY_DEBUG=1``)
|
|
11
|
+
so the pretty render stays clean — pass ``--debug`` to see the raw reformulation/answer log.
|
|
12
|
+
|
|
13
|
+
groundy "Who proved Fermat's Last Theorem?"
|
|
14
|
+
echo "your question" | groundy
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import itertools
|
|
21
|
+
import os
|
|
22
|
+
import sys
|
|
23
|
+
import threading
|
|
24
|
+
import time
|
|
25
|
+
import warnings
|
|
26
|
+
|
|
27
|
+
# Quiet the embedding model's first-load chatter — huggingface_hub's "unauthenticated
|
|
28
|
+
# requests" warning and its "Loading weights" progress bar — so they don't stomp on the
|
|
29
|
+
# spinner. Must be set before sentence-transformers/huggingface_hub import; setdefault keeps
|
|
30
|
+
# any value the user already exported. (This noise is third-party, not groundy's own log.)
|
|
31
|
+
os.environ.setdefault("HF_HUB_DISABLE_PROGRESS_BARS", "1")
|
|
32
|
+
os.environ.setdefault("HF_HUB_VERBOSITY", "error")
|
|
33
|
+
os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error")
|
|
34
|
+
# ...and swallow the warning huggingface_hub itself raises when some library (e.g. fastembed)
|
|
35
|
+
# calls enable_progress_bars() while our DISABLE env var wins — our own quieting triggers it.
|
|
36
|
+
warnings.filterwarnings("ignore", message="Cannot enable progress bars")
|
|
37
|
+
|
|
38
|
+
# ANSI colours, switched off when piped or when NO_COLOR is set (https://no-color.org).
|
|
39
|
+
_PLAIN = bool(os.getenv("NO_COLOR")) or not sys.stdout.isatty()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _paint(text: str, *codes: str) -> str:
|
|
43
|
+
return text if _PLAIN else "".join(codes) + text + "\033[0m"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
BOLD, DIM, GREEN, YELLOW, RED, CYAN, GREY = (
|
|
47
|
+
"\033[1m",
|
|
48
|
+
"\033[2m",
|
|
49
|
+
"\033[32m",
|
|
50
|
+
"\033[33m",
|
|
51
|
+
"\033[31m",
|
|
52
|
+
"\033[36m",
|
|
53
|
+
"\033[90m",
|
|
54
|
+
)
|
|
55
|
+
# 24-bit violet (#A855F7) for the histogram bars — fancier than flat ANSI magenta.
|
|
56
|
+
VIOLET = "\033[38;2;168;85;247m"
|
|
57
|
+
|
|
58
|
+
# One refusal string, shared by the matrix view and -q (kept in step with the library's).
|
|
59
|
+
REFUSAL = "I'm not confident enough to answer that reliably."
|
|
60
|
+
|
|
61
|
+
# --matrix view: a-z row/column labels and a 5-step shade ramp (faint = low similarity,
|
|
62
|
+
# solid = high), so the pairwise agreement structure reads as bright blocks.
|
|
63
|
+
ALPHABET = "abcdefghijklmnopqrstuvwxyz"
|
|
64
|
+
RAMP = "·░▒▓█"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class _Spinner:
|
|
68
|
+
"""A transient braille spinner. No-op when stdout isn't a TTY (e.g. piped)."""
|
|
69
|
+
|
|
70
|
+
FRAMES = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
|
|
71
|
+
|
|
72
|
+
def __init__(self, text: str):
|
|
73
|
+
self.text = text
|
|
74
|
+
self._stop = threading.Event()
|
|
75
|
+
self._thread: threading.Thread | None = None
|
|
76
|
+
|
|
77
|
+
def __enter__(self):
|
|
78
|
+
if not _PLAIN:
|
|
79
|
+
self._thread = threading.Thread(target=self._spin, daemon=True)
|
|
80
|
+
self._thread.start()
|
|
81
|
+
return self
|
|
82
|
+
|
|
83
|
+
def _spin(self):
|
|
84
|
+
for frame in itertools.cycle(self.FRAMES):
|
|
85
|
+
if self._stop.is_set():
|
|
86
|
+
break
|
|
87
|
+
sys.stdout.write(f"\r {_paint(frame, CYAN)} {_paint(self.text, DIM)}")
|
|
88
|
+
sys.stdout.flush()
|
|
89
|
+
time.sleep(0.08)
|
|
90
|
+
|
|
91
|
+
def __exit__(self, *_):
|
|
92
|
+
if self._thread is None: # plain/piped mode — nothing was ever drawn
|
|
93
|
+
return
|
|
94
|
+
self._stop.set()
|
|
95
|
+
self._thread.join()
|
|
96
|
+
sys.stdout.write("\r" + " " * (len(self.text) + 6) + "\r") # wipe the line
|
|
97
|
+
sys.stdout.flush()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _bar(share: float, width: int = 8) -> str:
|
|
101
|
+
"""A tiny block meter: ███████░"""
|
|
102
|
+
filled = round(max(0.0, min(1.0, share)) * width)
|
|
103
|
+
return "█" * filled + "░" * (width - filled)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _truncate(text: str, limit: int = 70) -> str:
|
|
107
|
+
text = " ".join(text.split())
|
|
108
|
+
return text if len(text) <= limit else text[: limit - 1] + "…"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _distinct(answers: list[str], agreement_scores: list[float]):
|
|
112
|
+
"""Pair each answer with its *agreement* — how well it agrees with the others, the very
|
|
113
|
+
signal groundy scores on. Exactly-identical answers (the one equivalence we can assert
|
|
114
|
+
without guessing) collapse into one row with a count. Returns ``[(agreement, count,
|
|
115
|
+
answer)]``, strongest agreement first: the consensus on top, the outliers at the bottom.
|
|
116
|
+
"""
|
|
117
|
+
rows: dict[str, list] = {} # normalised text -> [agreement, count, original]
|
|
118
|
+
for answer, fit in zip(answers, agreement_scores):
|
|
119
|
+
key = " ".join(answer.split()).lower()
|
|
120
|
+
if key in rows:
|
|
121
|
+
rows[key][1] += 1
|
|
122
|
+
else:
|
|
123
|
+
rows[key] = [fit, 1, answer]
|
|
124
|
+
return sorted(rows.values(), key=lambda r: r[0], reverse=True)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _matrix(n: int, similarity_scores: list[float]) -> list[list[float]]:
|
|
128
|
+
"""Rebuild the full symmetric pairwise matrix (diagonal 1.0) from the flat upper triangle
|
|
129
|
+
in the result — the raw substrate the consistency score is the average of."""
|
|
130
|
+
m = [[1.0] * n for _ in range(n)]
|
|
131
|
+
for (i, j), s in zip(itertools.combinations(range(n), 2), similarity_scores):
|
|
132
|
+
m[i][j] = m[j][i] = s
|
|
133
|
+
return m
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _cell(s: float) -> str:
|
|
137
|
+
"""A 2-char heat cell, shaded by similarity (clamped to [0, 1])."""
|
|
138
|
+
return RAMP[round(max(0.0, min(1.0, s)) * (len(RAMP) - 1))] * 2
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _render(r, matrix: bool = False) -> None:
|
|
142
|
+
"""Pretty-print a GroundyResult: verdict, answer, then the agreement scatter (or matrix)."""
|
|
143
|
+
ok = r.is_reliable
|
|
144
|
+
mark = _paint("✓ reliable", GREEN, BOLD) if ok else _paint("⚠ uncertain", YELLOW, BOLD)
|
|
145
|
+
score = _paint(f"consistency {r.consistency_score:.2f}", GREEN if ok else YELLOW)
|
|
146
|
+
timing = _paint(f"{r.latency_ms / 1000:.1f}s", GREY)
|
|
147
|
+
print(f"\n {mark} {score} {_paint('·', GREY)} {timing}\n")
|
|
148
|
+
|
|
149
|
+
if ok:
|
|
150
|
+
print(f" {_paint(r.best_answer, BOLD)}\n")
|
|
151
|
+
else:
|
|
152
|
+
print(f" {_paint(REFUSAL, YELLOW)}\n")
|
|
153
|
+
|
|
154
|
+
# The matrix — groundy's pairwise signal, shown two ways. Default: each distinct answer
|
|
155
|
+
# with a bar = how much it agrees with the rest (consensus tall, outliers short). With
|
|
156
|
+
# --matrix: the raw N×N heatmap, where mutually-agreeing answers light up as bright blocks
|
|
157
|
+
# and the eye finds the clusters — no threshold, nothing aggregated.
|
|
158
|
+
if matrix:
|
|
159
|
+
labels = [ALPHABET[i] for i in range(len(r.answers))]
|
|
160
|
+
m = _matrix(len(r.answers), r.similarity_scores)
|
|
161
|
+
# Cells touch (no gap) so a cluster reads as one solid block, not vertical stripes.
|
|
162
|
+
print(" " + "".join(f"{_paint(c, GREY)} " for c in labels))
|
|
163
|
+
for i, answer in enumerate(r.answers):
|
|
164
|
+
cells = "".join(_paint(_cell(m[i][j]), VIOLET) for j in range(len(r.answers)))
|
|
165
|
+
print(f" {_paint(labels[i], GREY)} {cells} {_truncate(answer, 46)}")
|
|
166
|
+
else:
|
|
167
|
+
for fit, count, answer in _distinct(r.answers, r.agreement_scores):
|
|
168
|
+
bar = _paint(_bar(fit), VIOLET)
|
|
169
|
+
tag = _paint(f" ×{count}", GREY) if count > 1 else ""
|
|
170
|
+
print(f" {bar} {_paint(f'{fit:.2f}', GREY)} {_truncate(answer)}{tag}")
|
|
171
|
+
print()
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def main(argv: list[str] | None = None) -> int:
|
|
175
|
+
parser = argparse.ArgumentParser(
|
|
176
|
+
prog="groundy",
|
|
177
|
+
description="Ask a question several ways; pass only if the model agrees with itself.",
|
|
178
|
+
epilog="Needs GROUNDY_API_KEY, GROUNDY_BASE_URL, GROUNDY_MODEL "
|
|
179
|
+
"(reformulates and answers on that model).",
|
|
180
|
+
)
|
|
181
|
+
parser.add_argument("query", nargs="?", help="the question (or pipe it via stdin)")
|
|
182
|
+
parser.add_argument("-n", type=int, default=5, help="answers compared (default: 5)")
|
|
183
|
+
parser.add_argument(
|
|
184
|
+
"-t", "--threshold", type=float, default=0.75, help="reliable cutoff (default: 0.75)"
|
|
185
|
+
)
|
|
186
|
+
parser.add_argument("--model", default=None, help="reformulation model (else GROUNDY_MODEL)")
|
|
187
|
+
parser.add_argument(
|
|
188
|
+
"--backend",
|
|
189
|
+
default="fastembed",
|
|
190
|
+
help="similarity backend: fastembed (default, lighter ONNX) or embeddings "
|
|
191
|
+
"(sentence-transformers bi-encoder); fastembed falls back to embeddings if not "
|
|
192
|
+
"installed",
|
|
193
|
+
)
|
|
194
|
+
parser.add_argument(
|
|
195
|
+
"-c",
|
|
196
|
+
"--concurrency",
|
|
197
|
+
type=int,
|
|
198
|
+
default=2,
|
|
199
|
+
help="verify answers to fetch in parallel (default: 2; 1 = sequential)",
|
|
200
|
+
)
|
|
201
|
+
parser.add_argument("-q", "--quiet", action="store_true", help="print only the answer")
|
|
202
|
+
parser.add_argument(
|
|
203
|
+
"--matrix", action="store_true", help="show the full N×N pairwise agreement heatmap"
|
|
204
|
+
)
|
|
205
|
+
parser.add_argument("--debug", action="store_true", help="show the raw reformulation log")
|
|
206
|
+
args = parser.parse_args(argv)
|
|
207
|
+
|
|
208
|
+
# Convenience: load a local .env if python-dotenv happens to be installed (it's a dev
|
|
209
|
+
# extra, never required). Silently skip it otherwise.
|
|
210
|
+
try:
|
|
211
|
+
from dotenv import load_dotenv
|
|
212
|
+
|
|
213
|
+
load_dotenv()
|
|
214
|
+
except ImportError:
|
|
215
|
+
pass
|
|
216
|
+
|
|
217
|
+
query = args.query
|
|
218
|
+
if query is None and not sys.stdin.isatty():
|
|
219
|
+
query = sys.stdin.read().strip()
|
|
220
|
+
if not query:
|
|
221
|
+
parser.error("no query given (pass it as an argument or pipe it via stdin)")
|
|
222
|
+
|
|
223
|
+
model = args.model or os.getenv("GROUNDY_MODEL")
|
|
224
|
+
if not model:
|
|
225
|
+
print(_paint("✗ set GROUNDY_MODEL (or pass --model) first.", RED), file=sys.stderr)
|
|
226
|
+
return 2
|
|
227
|
+
base_url = os.getenv("GROUNDY_BASE_URL")
|
|
228
|
+
if not base_url:
|
|
229
|
+
print(
|
|
230
|
+
_paint("✗ set GROUNDY_BASE_URL (your provider endpoint) first.", RED), file=sys.stderr
|
|
231
|
+
)
|
|
232
|
+
return 2
|
|
233
|
+
|
|
234
|
+
# fastembed is the default (lighter, no torch), but it's an optional extra — a plain
|
|
235
|
+
# `pip install groundy` won't have it. Fall back to the always-present embeddings backend
|
|
236
|
+
# so the CLI works out of the box; suggest the extra once, on stderr, never in -q output.
|
|
237
|
+
import importlib.util
|
|
238
|
+
|
|
239
|
+
if args.backend == "fastembed" and importlib.util.find_spec("fastembed") is None:
|
|
240
|
+
if not args.quiet:
|
|
241
|
+
print(
|
|
242
|
+
_paint(
|
|
243
|
+
"ℹ fastembed not installed — using embeddings "
|
|
244
|
+
"(pip install 'groundy[fastembed]' for the faster backend).",
|
|
245
|
+
GREY,
|
|
246
|
+
),
|
|
247
|
+
file=sys.stderr,
|
|
248
|
+
)
|
|
249
|
+
args.backend = "embeddings"
|
|
250
|
+
|
|
251
|
+
from loguru import logger
|
|
252
|
+
from openai import OpenAI
|
|
253
|
+
|
|
254
|
+
from groundy import GroundyChecker
|
|
255
|
+
|
|
256
|
+
# The CLI renders its own output, so groundy's debug log would just be noise — keep it
|
|
257
|
+
# off even if GROUNDY_DEBUG=1 is set in the env, unless --debug explicitly asks for it.
|
|
258
|
+
if args.debug:
|
|
259
|
+
logger.enable("groundy")
|
|
260
|
+
else:
|
|
261
|
+
logger.disable("groundy")
|
|
262
|
+
|
|
263
|
+
client = OpenAI(base_url=base_url, api_key=os.getenv("GROUNDY_API_KEY"))
|
|
264
|
+
|
|
265
|
+
# check() calls answer_fn once per "way" (n verify calls), then once more for the served
|
|
266
|
+
# answer. We tick the counter *after* each call returns and live-update the spinner, so a
|
|
267
|
+
# given "i/n" only appears once that answer is actually back — it advances with the
|
|
268
|
+
# answers, never ahead of them. After the last verify answer, check() scores them pairwise
|
|
269
|
+
# (a fast local batched op — slow only on the first run, while the embedding model loads),
|
|
270
|
+
# which is when we flip to "comparing answers…". (The spinner thread reads .text each frame.)
|
|
271
|
+
spinner = _Spinner("reformulating…")
|
|
272
|
+
answered = 0
|
|
273
|
+
answered_lock = threading.Lock() # verify calls may run concurrently (--concurrency)
|
|
274
|
+
|
|
275
|
+
# Background preload of the embedding model. Its cold start (~10s: torch +
|
|
276
|
+
# sentence-transformers import + weight load) is the single biggest chunk of a
|
|
277
|
+
# one-shot CLI run — bigger than all the LLM calls combined — yet it only happens
|
|
278
|
+
# because each `groundy` invocation is a fresh process. We start it now so it loads
|
|
279
|
+
# *underneath* the reformulation + verify LLM calls instead of stalling the scoring
|
|
280
|
+
# step. The thread is joined the instant the last verify answer is in (see answer_fn),
|
|
281
|
+
# i.e. just before check() scores — so the model is guaranteed resident by then and
|
|
282
|
+
# check()'s own _get_model() is a cache hit, never a second concurrent load. Best
|
|
283
|
+
# effort: a load error is swallowed here and re-raised by check()'s _get_model().
|
|
284
|
+
def _preload_model():
|
|
285
|
+
try:
|
|
286
|
+
import importlib
|
|
287
|
+
|
|
288
|
+
mod = importlib.import_module(f"groundy.backends.{args.backend}")
|
|
289
|
+
getattr(mod, "_get_model", lambda: None)() # not every backend has one (llm_judge)
|
|
290
|
+
except Exception: # noqa: BLE001 — warmup is best-effort; real errors surface in check()
|
|
291
|
+
pass
|
|
292
|
+
|
|
293
|
+
preloader = threading.Thread(target=_preload_model, daemon=True)
|
|
294
|
+
preloader.start()
|
|
295
|
+
|
|
296
|
+
def answer_fn(q: str) -> str:
|
|
297
|
+
nonlocal answered
|
|
298
|
+
# verify calls can be concurrent, so guard the shared counter. The served call is the
|
|
299
|
+
# one made after all n verify answers are in (answered == n by then).
|
|
300
|
+
with answered_lock:
|
|
301
|
+
is_served = answered >= args.n
|
|
302
|
+
if is_served:
|
|
303
|
+
spinner.text = "writing the answer…"
|
|
304
|
+
# temp 0: the CLI owns this answer call, so keep it deterministic — divergence
|
|
305
|
+
# across the n "ways" is then phrasing-driven, not sampling noise.
|
|
306
|
+
msg = client.chat.completions.create(
|
|
307
|
+
model=model,
|
|
308
|
+
max_tokens=512,
|
|
309
|
+
temperature=0.0,
|
|
310
|
+
messages=[{"role": "user", "content": q}],
|
|
311
|
+
)
|
|
312
|
+
with answered_lock:
|
|
313
|
+
answered += 1
|
|
314
|
+
done = answered
|
|
315
|
+
if done < args.n:
|
|
316
|
+
spinner.text = f"asking {done}/{args.n} ways…"
|
|
317
|
+
elif done == args.n: # last verify in — check() scores the answers pairwise next
|
|
318
|
+
# Make sure the preloaded embedding model is resident before that scoring call,
|
|
319
|
+
# so it's an lru_cache hit (no second concurrent load). Usually already done; if
|
|
320
|
+
# the load ran long this blocks for its tail, hidden under one spinner tick.
|
|
321
|
+
spinner.text = "comparing answers…"
|
|
322
|
+
preloader.join()
|
|
323
|
+
return msg.choices[0].message.content
|
|
324
|
+
|
|
325
|
+
checker = GroundyChecker(
|
|
326
|
+
n=args.n,
|
|
327
|
+
threshold=args.threshold,
|
|
328
|
+
model=model,
|
|
329
|
+
base_url=base_url,
|
|
330
|
+
backend=args.backend,
|
|
331
|
+
concurrency=args.concurrency,
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
if not args.quiet:
|
|
335
|
+
print(f"\n{_paint('🌱 groundy', GREEN, BOLD)}\n\n {_paint('?', CYAN)} {query}")
|
|
336
|
+
|
|
337
|
+
try:
|
|
338
|
+
with spinner:
|
|
339
|
+
result = checker.check(query, answer_fn)
|
|
340
|
+
except Exception as e: # noqa: BLE001 — surface any provider/parse error cleanly
|
|
341
|
+
print(_paint(f"✗ {type(e).__name__}: {e}", RED), file=sys.stderr)
|
|
342
|
+
return 1
|
|
343
|
+
|
|
344
|
+
if args.quiet:
|
|
345
|
+
print(result.best_answer if result.is_reliable else REFUSAL)
|
|
346
|
+
else:
|
|
347
|
+
_render(result, args.matrix)
|
|
348
|
+
return 0
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
if __name__ == "__main__":
|
|
352
|
+
raise SystemExit(main())
|