| """Thin LLM client used to generate realistic file content for synthetic tasks. |
| |
| Uses LiteLLM with the DeepSeek key from .mcp_env by default. If no key is set or |
| ``--no-llm`` is passed, callers fall back to a built-in offline content generator |
| so the pipeline still works without network access. |
| """ |
|
|
| import json |
| import os |
| import random |
| import re |
| from pathlib import Path |
| from typing import List, Optional |
|
|
| from dotenv import load_dotenv |
|
|
| |
| _REPO_ROOT = Path(__file__).resolve().parent.parent |
| load_dotenv(_REPO_ROOT / ".mcp_env") |
|
|
| _OFFLINE_WORDS = ( |
| "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda mu nu xi " |
| "omicron pi rho sigma tau upsilon phi chi psi omega quartz nimbus harbor " |
| "ledger compass orchard meadow lantern cobalt ember willow ".split() |
| ) |
|
|
|
|
| class LLMClient: |
| """Minimal completion wrapper around LiteLLM with an offline fallback.""" |
|
|
| def __init__( |
| self, |
| model: str = "openai/deepseek-v4-pro", |
| api_key_var: str = "DEEPSEEK_API_KEY", |
| enabled: bool = True, |
| seed: int = 0, |
| base_url: Optional[str] = None, |
| ): |
| self.model = model |
| self.api_key = os.getenv(api_key_var) |
| |
| self.base_url = base_url or os.getenv("DEEPSEEK_BASE_URL") |
| self.enabled = enabled and bool(self.api_key) |
| self._rng = random.Random(seed) |
|
|
| |
|
|
| def complete(self, prompt: str, temperature: float = 1.0, |
| max_tokens: int = 300) -> Optional[str]: |
| """One-shot completion. Returns the text, or None if disabled/failed. |
| |
| Used for paraphrasing task questions (never for computing answers).""" |
| if not self.enabled: |
| return None |
| try: |
| import litellm |
| litellm.suppress_debug_info = True |
| resp = litellm.completion( |
| model=self.model, api_key=self.api_key, base_url=self.base_url, |
| messages=[{"role": "user", "content": prompt}], |
| temperature=temperature, max_tokens=max_tokens, |
| ) |
| return (resp.choices[0].message.content or "").strip() |
| except Exception as e: |
| print(f"[llm] complete() failed, falling back ({e})") |
| return None |
|
|
| def gen_snippets(self, theme: str, n: int, max_words: int = 30) -> List[dict]: |
| """Return ``n`` dicts of {"filename", "content"} on a theme. |
| |
| Filenames are slugs without extension (the caller assigns extensions and |
| adjusts sizes). Falls back to offline lorem text on any failure. |
| """ |
| if self.enabled: |
| try: |
| return self._gen_snippets_llm(theme, n, max_words) |
| except Exception as e: |
| print(f"[llm] falling back to offline content ({e})") |
| return self._gen_snippets_offline(theme, n, max_words) |
|
|
| |
|
|
| def _gen_snippets_llm(self, theme: str, n: int, max_words: int) -> List[dict]: |
| import litellm |
|
|
| litellm.suppress_debug_info = True |
| prompt = ( |
| f"Generate {n} short, realistic text snippets for files that might appear " |
| f"on a desktop/project about: {theme}.\n" |
| f"Return ONLY a JSON array of objects with keys 'filename' (a short " |
| f"lowercase slug, no extension, e.g. 'meeting_notes', unique across the " |
| f"array) and 'content' (1-2 sentences, at most {max_words} words, plain " |
| f"text, no markdown headers). No commentary, no code fences." |
| ) |
| resp = litellm.completion( |
| model=self.model, |
| api_key=self.api_key, |
| base_url=self.base_url, |
| messages=[{"role": "user", "content": prompt}], |
| temperature=1.0, |
| max_tokens=2000, |
| ) |
| text = resp.choices[0].message.content |
| items = self._parse_json_array(text) |
| out: List[dict] = [] |
| seen = set() |
| for it in items: |
| name = _slug(str(it.get("filename", "")).strip()) |
| body = str(it.get("content", "")).strip() |
| if not name or not body: |
| continue |
| name = _dedupe(name, seen) |
| out.append({"filename": name, "content": body}) |
| |
| if len(out) < n: |
| out.extend(self._gen_snippets_offline(theme, n - len(out), max_words, seen)) |
| return out[:n] |
|
|
| @staticmethod |
| def _parse_json_array(text: str) -> List[dict]: |
| text = text.strip() |
| |
| text = re.sub(r"^```(?:json)?|```$", "", text, flags=re.MULTILINE).strip() |
| start, end = text.find("["), text.rfind("]") |
| if start != -1 and end != -1: |
| text = text[start : end + 1] |
| data = json.loads(text) |
| if not isinstance(data, list): |
| raise ValueError("expected a JSON array") |
| return data |
|
|
| |
|
|
| def _gen_snippets_offline( |
| self, theme: str, n: int, max_words: int, seen: Optional[set] = None |
| ) -> List[dict]: |
| seen = seen if seen is not None else set() |
| out = [] |
| for _ in range(n): |
| wc = self._rng.randint(6, max_words) |
| words = [self._rng.choice(_OFFLINE_WORDS) for _ in range(wc)] |
| name = _dedupe(_slug("_".join(words[:2]) or "note"), seen) |
| body = (theme + ": " + " ".join(words)).strip().capitalize() + "." |
| out.append({"filename": name, "content": body}) |
| return out |
|
|
|
|
| def _slug(s: str) -> str: |
| s = re.sub(r"[^a-zA-Z0-9]+", "_", s).strip("_").lower() |
| return s or "file" |
|
|
|
|
| def _dedupe(name: str, seen: set) -> str: |
| base, i = name, 1 |
| while name in seen: |
| i += 1 |
| name = f"{base}_{i}" |
| seen.add(name) |
| return name |
|
|