sieve-context 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.
sieve/__init__.py ADDED
@@ -0,0 +1,29 @@
1
+ """Sieve: Context compression middleware for RAG."""
2
+
3
+ from sieve.models import (
4
+ Document,
5
+ Chunk,
6
+ ScoredChunk,
7
+ SieveResult,
8
+ FilterMetrics,
9
+ Savings,
10
+ Benchmark,
11
+ )
12
+ from sieve.sieve import Sieve, filter_context
13
+ from sieve.scorer import Scorer, get_default_scorer
14
+
15
+ __version__ = "0.1.0"
16
+
17
+ __all__ = [
18
+ "Sieve",
19
+ "filter_context",
20
+ "Document",
21
+ "Chunk",
22
+ "ScoredChunk",
23
+ "SieveResult",
24
+ "FilterMetrics",
25
+ "Savings",
26
+ "Benchmark",
27
+ "Scorer",
28
+ "get_default_scorer",
29
+ ]
sieve/benchmark.py ADDED
@@ -0,0 +1,132 @@
1
+ import time
2
+ import numpy as np
3
+ from sentence_transformers import SentenceTransformer
4
+ from sieve.config import Config
5
+ from sieve.models import Benchmark
6
+ from sieve.vector_store import VectorStore
7
+ from sieve.scorer import Scorer
8
+ from sieve.sieve import Sieve
9
+ from sieve.llm import LLM, count_tokens
10
+ from sieve.metrics import calculate_savings
11
+
12
+ class Evaluator:
13
+ """Evaluates whether two LLM answers are factually equivalent."""
14
+ def judge_equivalence(self, question: str, standard_answer: str, sieve_answer: str, llm: LLM) -> dict:
15
+ prompt = f"""
16
+ You are an impartial judge evaluating two AI systems.
17
+ Question: {question}
18
+ Standard Answer: {standard_answer}
19
+ Sieve Answer: {sieve_answer}
20
+
21
+ Do these two answers contain the exact same factual information in response to the question?
22
+ If Sieve's answer is missing information that Standard provided, it is not equivalent.
23
+
24
+ Output your response exactly in this format on two lines:
25
+ SCORE: <number between 0 and 100>
26
+ VERDICT: <one short sentence explaining the score>
27
+ """
28
+
29
+ try:
30
+ res = llm.answer(question, prompt)
31
+ lines = res.answer.strip().split('\n')
32
+ score = 100
33
+ verdict = "Could not parse judge response."
34
+
35
+ for line in lines:
36
+ if line.startswith("SCORE:"):
37
+ try:
38
+ score = int(line.replace("SCORE:", "").strip())
39
+ except ValueError:
40
+ pass
41
+ elif line.startswith("VERDICT:"):
42
+ verdict = line.replace("VERDICT:", "").strip()
43
+
44
+ return {"score": score, "verdict": verdict}
45
+ except Exception as e:
46
+ return {"score": 0, "verdict": f"Judge failed: {str(e)}"}
47
+
48
+ class Runner:
49
+ """Runs end-to-end benchmarking for Sieve ON vs OFF."""
50
+ def __init__(self, config: Config, vector_store: VectorStore, scorer: Scorer, llm: LLM):
51
+ self.config = config
52
+ self.vector_store = vector_store
53
+ self.scorer = scorer
54
+ self.llm = llm
55
+
56
+ self.sieve = Sieve(
57
+ threshold=config.sieve.threshold,
58
+ max_tokens=config.sieve.max_tokens,
59
+ context_window=config.sieve.context_window
60
+ )
61
+ self.evaluator = Evaluator()
62
+
63
+ def run_standard(self, question: str) -> Benchmark:
64
+ """Run the query using naive RAG (sending all retrieved chunks)."""
65
+ t0 = time.perf_counter()
66
+
67
+ all_chunks = self.vector_store.chunks
68
+ original_context = "\n\n".join([c.text for c in all_chunks])
69
+ original_tokens = count_tokens(original_context)
70
+
71
+ retrieval_latency = time.perf_counter() - t0
72
+
73
+ # LLM Call
74
+ std_llm_res = self.llm.answer(question, original_context)
75
+ std_metrics = calculate_savings(std_llm_res.input_tokens, std_llm_res.output_tokens, self.config)
76
+
77
+ std_total_latency = retrieval_latency + std_llm_res.latency
78
+
79
+ return Benchmark(
80
+ question=question,
81
+ standard_retrieval_tokens=original_tokens,
82
+ standard_llm_input_tokens=std_llm_res.input_tokens,
83
+ standard_cost=std_metrics.estimated_cost,
84
+ standard_latency_retrieval=retrieval_latency,
85
+ standard_latency_total=std_total_latency,
86
+ standard_answer=std_llm_res.answer,
87
+ # Fill empty for sieve fields
88
+ sieve_retrieval_tokens=0, sieve_output_tokens=0, sieve_tokens_removed=0, sieve_reduction_pct=0,
89
+ sieve_cost=0, sieve_latency_retrieval=0, sieve_latency_sieve=0, sieve_latency_total=0, sieve_answer="",
90
+ cost_reduction_pct=0, latency_improvement_pct=0, semantic_similarity=None
91
+ )
92
+
93
+ def run_sieve(self, question: str, progress_callback=None) -> Benchmark:
94
+ """Run the query using Sieve to compress the context first."""
95
+ t0 = time.perf_counter()
96
+
97
+ all_chunks = self.vector_store.chunks
98
+ original_context = "\n\n".join([c.text for c in all_chunks])
99
+ original_tokens = count_tokens(original_context)
100
+ retrieval_latency = time.perf_counter() - t0
101
+
102
+ if progress_callback:
103
+ progress_callback({"stage": "starting"})
104
+
105
+ # Score and compress
106
+ scored_chunks = self.scorer.score_chunks(question, all_chunks, progress_callback=progress_callback)
107
+ sieve_res = self.sieve.filter_context(scored_chunks, count_tokens)
108
+
109
+ if progress_callback:
110
+ progress_callback({"stage": "calling_llm"})
111
+
112
+ # LLM Call
113
+ sieve_llm_res = self.llm.answer(question, sieve_res.compressed_context)
114
+ sieve_metrics = calculate_savings(sieve_llm_res.input_tokens, sieve_llm_res.output_tokens, self.config)
115
+
116
+ sieve_total_latency = retrieval_latency + sieve_res.latency + sieve_llm_res.latency
117
+
118
+ return Benchmark(
119
+ question=question,
120
+ standard_retrieval_tokens=0, standard_llm_input_tokens=0, standard_cost=0,
121
+ standard_latency_retrieval=0, standard_latency_total=0, standard_answer="",
122
+ sieve_retrieval_tokens=original_tokens,
123
+ sieve_output_tokens=sieve_llm_res.input_tokens,
124
+ sieve_tokens_removed=sieve_res.removed_tokens,
125
+ sieve_reduction_pct=sieve_res.reduction_percentage,
126
+ sieve_cost=sieve_metrics.estimated_cost,
127
+ sieve_latency_retrieval=retrieval_latency,
128
+ sieve_latency_sieve=sieve_res.latency,
129
+ sieve_latency_total=sieve_total_latency,
130
+ sieve_answer=sieve_llm_res.answer,
131
+ cost_reduction_pct=0, latency_improvement_pct=0, semantic_similarity=None
132
+ )
sieve/calibrate.py ADDED
@@ -0,0 +1,140 @@
1
+ import json
2
+ import os
3
+ import time
4
+ from typing import List, Dict, Any, Optional
5
+ from pydantic import BaseModel, Field
6
+ from sieve.sieve import filter_context
7
+ from sieve.llm import LLM, MockLLM, GoogleLLM, count_tokens
8
+ from sieve.config import load_config
9
+
10
+ class BenchmarkCase(BaseModel):
11
+ question: str
12
+ context: str
13
+ expected_answer: str
14
+
15
+ class ThresholdResult(BaseModel):
16
+ threshold: Optional[float]
17
+ label: str
18
+ context_kept_pct: float
19
+ accuracy_pct: float
20
+ correct_count: int
21
+ total_count: int
22
+ retained_tokens_avg: float
23
+ latency_ms_avg: float
24
+
25
+ class CalibrationSummary(BaseModel):
26
+ results: List[ThresholdResult]
27
+ min_sufficient_threshold: Optional[float]
28
+ min_sufficient_context_pct: float
29
+ min_sufficient_accuracy_pct: float
30
+
31
+ def score_answer(expected: str, answer_or_context: str) -> bool:
32
+ """Check case-insensitive substring match of expected answer."""
33
+ return expected.strip().lower() in answer_or_context.lower()
34
+
35
+ def run_calibration(
36
+ benchmark_path: str,
37
+ thresholds: List[float] = [0.20, 0.35, 0.50, 0.65, 0.80],
38
+ llm: Optional[LLM] = None,
39
+ ) -> CalibrationSummary:
40
+ """Sweep thresholds across a benchmark and report accuracy and context reduction."""
41
+ with open(benchmark_path, "r", encoding="utf-8") as f:
42
+ data = json.load(f)
43
+
44
+ cases = [BenchmarkCase(**item) for item in data]
45
+ total_cases = len(cases)
46
+ if total_cases == 0:
47
+ raise ValueError("Benchmark dataset is empty.")
48
+
49
+ results: List[ThresholdResult] = []
50
+
51
+ # 1. Baseline (without Sieve)
52
+ baseline_correct = 0
53
+ baseline_tokens = []
54
+ baseline_latencies = []
55
+
56
+ for c in cases:
57
+ tokens = count_tokens(c.context)
58
+ baseline_tokens.append(tokens)
59
+ if llm is not None:
60
+ t0 = time.perf_counter()
61
+ resp = llm.answer(c.question, c.context)
62
+ elapsed = (time.perf_counter() - t0) * 1000.0
63
+ baseline_latencies.append(elapsed)
64
+ if score_answer(c.expected_answer, resp.answer):
65
+ baseline_correct += 1
66
+ else:
67
+ # Check presence in context
68
+ baseline_latencies.append(0.0)
69
+ if score_answer(c.expected_answer, c.context):
70
+ baseline_correct += 1
71
+
72
+ results.append(ThresholdResult(
73
+ threshold=None,
74
+ label="— (WITHOUT SIEVE)",
75
+ context_kept_pct=100.0,
76
+ accuracy_pct=round((baseline_correct / total_cases) * 100.0, 1),
77
+ correct_count=baseline_correct,
78
+ total_count=total_cases,
79
+ retained_tokens_avg=round(sum(baseline_tokens) / total_cases, 1),
80
+ latency_ms_avg=round(sum(baseline_latencies) / total_cases, 1),
81
+ ))
82
+
83
+ # 2. Threshold sweep
84
+ for th in sorted(thresholds):
85
+ correct = 0
86
+ kept_tokens_list = []
87
+ latencies = []
88
+
89
+ for c in cases:
90
+ res = filter_context(query=c.question, context=c.context, threshold=th)
91
+ kept_tokens = res.metrics.filtered_tokens
92
+ kept_tokens_list.append(kept_tokens)
93
+
94
+ if llm is not None:
95
+ t0 = time.perf_counter()
96
+ resp = llm.answer(c.question, res.filtered_context)
97
+ elapsed = (time.perf_counter() - t0) * 1000.0 + res.metrics.filtering_latency_ms
98
+ latencies.append(elapsed)
99
+ if score_answer(c.expected_answer, resp.answer):
100
+ correct += 1
101
+ else:
102
+ latencies.append(res.metrics.filtering_latency_ms)
103
+ if score_answer(c.expected_answer, res.filtered_context):
104
+ correct += 1
105
+
106
+ avg_kept_pct = (sum(kept_tokens_list) / sum(baseline_tokens)) * 100.0 if sum(baseline_tokens) > 0 else 0.0
107
+ acc_pct = (correct / total_cases) * 100.0
108
+
109
+ results.append(ThresholdResult(
110
+ threshold=th,
111
+ label=f"{th:.2f}",
112
+ context_kept_pct=round(avg_kept_pct, 1),
113
+ accuracy_pct=round(acc_pct, 1),
114
+ correct_count=correct,
115
+ total_count=total_cases,
116
+ retained_tokens_avg=round(sum(kept_tokens_list) / total_cases, 1),
117
+ latency_ms_avg=round(sum(latencies) / total_cases, 1),
118
+ ))
119
+
120
+ # Determine minimum sufficient context:
121
+ # Filter among thresholds that maintain accuracy >= baseline accuracy (or max accuracy observed)
122
+ max_acc = max(r.accuracy_pct for r in results if r.threshold is not None)
123
+ qualifying = [r for r in results if r.threshold is not None and r.accuracy_pct >= max_acc]
124
+ if qualifying:
125
+ # Pick the one with lowest context kept
126
+ best = min(qualifying, key=lambda x: x.context_kept_pct)
127
+ best_th = best.threshold
128
+ best_context_pct = best.context_kept_pct
129
+ best_acc = best.accuracy_pct
130
+ else:
131
+ best_th = None
132
+ best_context_pct = 100.0
133
+ best_acc = 100.0
134
+
135
+ return CalibrationSummary(
136
+ results=results,
137
+ min_sufficient_threshold=best_th,
138
+ min_sufficient_context_pct=best_context_pct,
139
+ min_sufficient_accuracy_pct=best_acc,
140
+ )
sieve/cli.py ADDED
@@ -0,0 +1,248 @@
1
+ import os
2
+ import glob
3
+ from typing import Optional
4
+ import typer
5
+ from rich.prompt import Prompt
6
+ from rich.panel import Panel
7
+ from rich.table import Table
8
+
9
+ from sieve.config import load_config
10
+ from sieve.ui import (
11
+ console, print_header, run_sieve, run_standard,
12
+ render_mode_dashboard, render_stats_table, print_env_savings,
13
+ to_safe_ascii, SAFE_BOX, STANDARD_COLOR, SIEVE_COLOR, SIEVE_ACCENT
14
+ )
15
+ from sieve.documents import parse_document, process_documents
16
+ from sieve.vector_store import VectorStore
17
+ from sieve.scorer import Scorer
18
+ from sieve.llm import MockLLM, GoogleLLM
19
+ from sieve.benchmark import Runner
20
+ from sieve.sieve import filter_context
21
+ from sieve.calibrate import run_calibration
22
+
23
+ app = typer.Typer(help="Sieve: Intelligent context compression for RAG")
24
+
25
+ def setup(offline: bool = False):
26
+ config = load_config()
27
+ vector_store = VectorStore(config.workspace.index_dir, config.models.embedding)
28
+ scorer = Scorer(config.models.reranker)
29
+
30
+ if offline or not config.llm.api_key:
31
+ llm = MockLLM()
32
+ else:
33
+ llm = GoogleLLM(api_key=config.llm.api_key, model_name=config.llm.model)
34
+
35
+ return config, vector_store, scorer, llm
36
+
37
+ @app.command()
38
+ def index():
39
+ """Ingest and index documents in the workspace."""
40
+ config, vector_store, scorer, llm = setup(offline=True)
41
+ doc_dir = config.workspace.documents_dir
42
+
43
+ if not os.path.exists(doc_dir):
44
+ console.print(f"[warning]Documents directory not found at {doc_dir}. Created it. Please add files and run index again.[/warning]")
45
+ os.makedirs(doc_dir)
46
+ return
47
+
48
+ files = []
49
+ for ext in ["*.pdf", "*.txt", "*.md", "*.html"]:
50
+ files.extend(glob.glob(os.path.join(doc_dir, ext)))
51
+
52
+ if not files:
53
+ console.print(f"[warning]No supported documents found in {doc_dir}. Please add some PDFs or text files.[/warning]")
54
+ return
55
+
56
+ console.print(f"[info]Found {len(files)} documents. Indexing...[/info]")
57
+
58
+ all_chunks = []
59
+ for f in files:
60
+ coarse = parse_document(f)
61
+ fine = process_documents(coarse)
62
+ all_chunks.extend(fine)
63
+
64
+ vector_store.add_chunks(all_chunks)
65
+ console.print(f"[success]Successfully indexed {len(all_chunks)} chunks into the vector store![/success]")
66
+
67
+ @app.command()
68
+ def ask(question: str, offline: bool = typer.Option(False, "--offline", help="Run in offline mode")):
69
+ """Ask a question using Sieve RAG."""
70
+ config, vector_store, scorer, llm = setup(offline)
71
+ runner = Runner(config, vector_store, scorer, llm)
72
+
73
+ console.rule(
74
+ f"[bold {SIEVE_COLOR}]SIEVE :: SIEVE MODE[/bold {SIEVE_COLOR}] [dim]- cross-encoder filtered context[/dim]",
75
+ style=SIEVE_COLOR,
76
+ characters="-",
77
+ )
78
+ console.print(f"\n[bold]Query:[/bold] {to_safe_ascii(question)}\n")
79
+
80
+ res = run_sieve(runner, question)
81
+
82
+ console.print(Panel(to_safe_ascii(res.sieve_answer), title="Sieve's answer", border_style=SIEVE_COLOR, box=SAFE_BOX))
83
+ console.print()
84
+ console.print(render_mode_dashboard("sieve", res, SIEVE_COLOR))
85
+ print_env_savings("sieve", res, res.sieve_retrieval_tokens)
86
+ console.print()
87
+ console.rule(style=SIEVE_COLOR, characters="-")
88
+
89
+ @app.command()
90
+ def benchmark(offline: bool = typer.Option(False, "--offline", help="Run in offline mode")):
91
+ """Run a side-by-side benchmark (Standard vs Sieve)."""
92
+ print_header()
93
+
94
+ config, vector_store, scorer, llm = setup(offline)
95
+ if not vector_store.chunks:
96
+ console.print("[warning]No indexed documents. Please run 'sieve index' first.[/warning]")
97
+ return
98
+
99
+ question = Prompt.ask("\n[bold]Enter your question[/bold]")
100
+
101
+ runner = Runner(config, vector_store, scorer, llm)
102
+
103
+ console.rule(f"[bold {STANDARD_COLOR}]STANDARD RAG[/bold {STANDARD_COLOR}]", style=STANDARD_COLOR, characters="-")
104
+ std_res = run_standard(runner, question)
105
+ console.print(Panel(to_safe_ascii(std_res.standard_answer), title="Standard's answer", border_style=STANDARD_COLOR, box=SAFE_BOX))
106
+
107
+ console.print()
108
+ console.rule(f"[bold {SIEVE_COLOR}]SIEVE RAG[/bold {SIEVE_COLOR}]", style=SIEVE_COLOR, characters="-")
109
+ sieve_res = run_sieve(runner, question)
110
+ console.print(Panel(to_safe_ascii(sieve_res.sieve_answer), title="Sieve's answer", border_style=SIEVE_COLOR, box=SAFE_BOX))
111
+
112
+ console.print()
113
+
114
+ # Merge results to pass into render_stats_table
115
+ sieve_res.standard_llm_input_tokens = std_res.standard_llm_input_tokens
116
+ sieve_res.standard_cost = std_res.standard_cost
117
+ sieve_res.standard_latency_total = std_res.standard_latency_total
118
+
119
+ console.print(render_stats_table(sieve_res))
120
+
121
+ console.print()
122
+ with console.status(f"[bold {SIEVE_ACCENT}]Judging factual agreement...[/bold {SIEVE_ACCENT}]"):
123
+ comparison = runner.evaluator.judge_equivalence(question, std_res.standard_answer, sieve_res.sieve_answer, llm)
124
+
125
+ console.print(
126
+ Panel(
127
+ f"[bold]{comparison['score']}% agreement[/bold]\n{to_safe_ascii(comparison['verdict'])}",
128
+ title="Accuracy - LLM-as-judge",
129
+ border_style=SIEVE_ACCENT,
130
+ box=SAFE_BOX,
131
+ )
132
+ )
133
+
134
+ console.print()
135
+ console.rule(style=SIEVE_COLOR, characters="-")
136
+
137
+ @app.command()
138
+ def calibrate(
139
+ benchmark_file: str = typer.Option("examples/benchmark.json", "--benchmark", "-b", help="Path to benchmark JSON"),
140
+ thresholds: str = typer.Option("0.20,0.35,0.50,0.65,0.80", "--thresholds", "-t", help="Comma-separated threshold list"),
141
+ offline: bool = typer.Option(True, "--offline", help="Run in offline mode without calling LLM API"),
142
+ ):
143
+ """Calibrate optimal filtering threshold across a benchmark dataset."""
144
+ th_list = [float(x.strip()) for x in thresholds.split(",") if x.strip()]
145
+ config, _, _, llm = setup(offline=offline)
146
+
147
+ console.print(f"[info]Running Sieve Calibrate on '{benchmark_file}' with thresholds {th_list}...[/info]")
148
+ active_llm = None if offline else llm
149
+
150
+ summary = run_calibration(benchmark_file, thresholds=th_list, llm=active_llm)
151
+
152
+ table = Table(title="SIEVE CALIBRATION RESULTS", box=SAFE_BOX)
153
+ table.add_column("Threshold", style="bold")
154
+ table.add_column("Context Kept", justify="right")
155
+ table.add_column("Accuracy", justify="right")
156
+ table.add_column("Correct", justify="right")
157
+ table.add_column("Avg Latency", justify="right")
158
+
159
+ for r in summary.results:
160
+ table.add_row(
161
+ r.label,
162
+ f"{r.context_kept_pct}%",
163
+ f"{r.accuracy_pct}%",
164
+ f"{r.correct_count}/{r.total_count}",
165
+ f"{r.latency_ms_avg:.1f}ms",
166
+ )
167
+ console.print(table)
168
+
169
+ console.print(
170
+ f"\n[bold {SIEVE_COLOR}]Minimum sufficient context:[/bold {SIEVE_COLOR}] "
171
+ f"Threshold {summary.min_sufficient_threshold if summary.min_sufficient_threshold is not None else 'N/A'}, "
172
+ f"{summary.min_sufficient_context_pct}% of context retained, "
173
+ f"{summary.min_sufficient_accuracy_pct}% accuracy."
174
+ )
175
+
176
+ @app.command()
177
+ def filter(
178
+ query: str = typer.Argument(..., help="Query to filter context against"),
179
+ context_file: Optional[str] = typer.Option(None, "--file", "-f", help="Path to context text file"),
180
+ context_text: Optional[str] = typer.Option(None, "--text", help="Raw context text to filter"),
181
+ threshold: float = typer.Option(0.50, "--threshold", "-t", help="Relevance threshold"),
182
+ max_tokens: Optional[int] = typer.Option(None, "--max-tokens", help="Maximum tokens in filtered context"),
183
+ ):
184
+ """Filter retrieved context directly for a given query."""
185
+ raw_context = ""
186
+ if context_file:
187
+ if not os.path.exists(context_file):
188
+ console.print(f"[red]Error: file '{context_file}' does not exist.[/red]")
189
+ raise typer.Exit(code=1)
190
+ with open(context_file, "r", encoding="utf-8") as f:
191
+ raw_context = f.read()
192
+ elif context_text:
193
+ raw_context = context_text
194
+ else:
195
+ # Default to demo context if available
196
+ demo_path = "examples/context.txt"
197
+ if os.path.exists(demo_path):
198
+ with open(demo_path, "r", encoding="utf-8") as f:
199
+ raw_context = f.read()
200
+ else:
201
+ console.print("[red]Error: Please provide --file or --text.[/red]")
202
+ raise typer.Exit(code=1)
203
+
204
+ result = filter_context(query=query, context=raw_context, threshold=threshold, max_tokens=max_tokens)
205
+
206
+ console.print(Panel(result.filtered_context, title="Filtered Context", border_style=SIEVE_COLOR, box=SAFE_BOX))
207
+ console.print(
208
+ f"\n[bold]Metrics:[/bold] Original: {result.metrics.original_tokens} tokens | "
209
+ f"Filtered: {result.metrics.filtered_tokens} tokens | "
210
+ f"Reduction: {result.metrics.reduction_percent}% | "
211
+ f"Latency: {result.metrics.filtering_latency_ms} ms"
212
+ )
213
+
214
+ @app.command()
215
+ def doctor():
216
+ """Check system status."""
217
+ print_header()
218
+ config = load_config()
219
+ console.print("[bold]SIEVE DOCTOR[/bold]")
220
+
221
+ doc_dir = config.workspace.documents_dir
222
+ docs_exist = os.path.exists(doc_dir) and len(os.listdir(doc_dir)) > 0
223
+ console.print(f"[{'success' if docs_exist else 'warning'}] Workspace documents: {'Found' if docs_exist else 'Empty'}")
224
+
225
+ idx_dir = config.workspace.index_dir
226
+ idx_exist = os.path.exists(idx_dir) and len(os.listdir(idx_dir)) > 0
227
+ console.print(f"[{'success' if idx_exist else 'warning'}] Vector index: {'Found' if idx_exist else 'Empty'}")
228
+
229
+ key_exists = bool(config.llm.api_key)
230
+ console.print(f"[{'success' if key_exists else 'warning'}] LLM API Key: {'Configured' if key_exists else 'Not found (will use offline mode)'}")
231
+
232
+ @app.command()
233
+ def interactive():
234
+ """Start interactive session."""
235
+ print_header()
236
+ while True:
237
+ cmd = Prompt.ask("\n[bold]sieve>[/bold] ")
238
+ if cmd.lower() in ['exit', 'quit']:
239
+ break
240
+ elif cmd.lower().startswith('ask '):
241
+ ask(cmd[4:], offline=False)
242
+ elif cmd.lower() == 'benchmark':
243
+ benchmark(offline=False)
244
+ else:
245
+ console.print("Commands: ask <question>, benchmark, calibrate, exit")
246
+
247
+ if __name__ == "__main__":
248
+ app()
sieve/config.py ADDED
@@ -0,0 +1,69 @@
1
+ import os
2
+ import yaml
3
+ from pydantic import BaseModel
4
+ from dotenv import load_dotenv
5
+
6
+ class WorkspaceConfig(BaseModel):
7
+ documents_dir: str = "./workspace/documents"
8
+ index_dir: str = "./workspace/indexes"
9
+ benchmarks_dir: str = "./workspace/benchmarks"
10
+
11
+ class RetrievalConfig(BaseModel):
12
+ top_k: int = 10
13
+
14
+ class SieveConfig(BaseModel):
15
+ threshold: float = 0.60
16
+ max_tokens: int = 2000
17
+ context_window: int = 1
18
+
19
+ class ModelsConfig(BaseModel):
20
+ embedding: str = "all-MiniLM-L6-v2"
21
+ reranker: str = "cross-encoder/ms-marco-MiniLM-L-6-v2"
22
+
23
+ class PricingConfig(BaseModel):
24
+ input_per_1m: float = 0.075
25
+ output_per_1m: float = 0.300
26
+
27
+ class LLMConfig(BaseModel):
28
+ provider: str = "google"
29
+ model: str = "gemini-3.5-flash-lite"
30
+ pricing: PricingConfig = PricingConfig()
31
+ api_key: str = ""
32
+
33
+ class SustainabilityConfig(BaseModel):
34
+ energy_per_million_tokens_kwh: float = 0.003
35
+ carbon_intensity_g_per_kwh: float = 450.0
36
+
37
+ class Config(BaseModel):
38
+ workspace: WorkspaceConfig = WorkspaceConfig()
39
+ retrieval: RetrievalConfig = RetrievalConfig()
40
+ sieve: SieveConfig = SieveConfig()
41
+ models: ModelsConfig = ModelsConfig()
42
+ llm: LLMConfig = LLMConfig()
43
+ sustainability: SustainabilityConfig = SustainabilityConfig()
44
+
45
+ def load_config(config_path: str = "sieve.yaml") -> Config:
46
+ load_dotenv()
47
+
48
+ config_data = {}
49
+ if os.path.exists(config_path):
50
+ with open(config_path, "r", encoding="utf-8") as f:
51
+ config_data = yaml.safe_load(f) or {}
52
+
53
+ config = Config(**config_data)
54
+
55
+ # Overrides from environment
56
+ if os.getenv("SIEVE_LLM_PROVIDER"):
57
+ config.llm.provider = os.getenv("SIEVE_LLM_PROVIDER")
58
+
59
+ if os.getenv("SIEVE_GOOGLE_MODEL"):
60
+ config.llm.model = os.getenv("SIEVE_GOOGLE_MODEL")
61
+
62
+ if os.getenv("SIEVE_GOOGLE_API_KEY"):
63
+ config.llm.api_key = os.getenv("SIEVE_GOOGLE_API_KEY")
64
+
65
+ return config
66
+
67
+ # Global config instance for simple access if needed
68
+ # Typically better to pass it around, but a default instance is handy
69
+ default_config = load_config()