"""Abstract LLM client interface.""" from __future__ import annotations import os import re from abc import ABC, abstractmethod from pathlib import Path import yaml def load_config(config_path: str = "config.yaml") -> dict: with open(config_path) as f: return yaml.safe_load(f) def load_prompt_template(template_path: str) -> str: return Path(template_path).read_text() # Strip HTML comments (maintainer-only notes) from knowledge files before # passing to LLM. Authors put checklists / cross-file-update warnings in # blocks at the top of each knowledge/*.md; those are meta # about how to edit the file, not content the LLM should read. _HTML_COMMENT_RE = re.compile(r"", re.DOTALL) def load_expert_knowledge(knowledge_path: str) -> str: path = Path(knowledge_path) if not path.exists(): return "" return _HTML_COMMENT_RE.sub("", path.read_text()).lstrip() class LLMClient(ABC): """Abstract base class for LLM backends.""" @abstractmethod def call( self, prompt_template: str, variables: dict, expert_knowledge: str = "", seed: int | None = None, ) -> str: """Call the LLM with a prompt template, variables, and optional expert knowledge. Args: prompt_template: The prompt template string with {placeholders}. variables: Dictionary of variables to fill into the template. expert_knowledge: Expert knowledge text to inject via {expert_knowledge} placeholder. seed: Optional integer seed for sampling reproducibility. Local backends must honor this when temperature > 0; cloud backends (claude / claude-cli) currently raise NotImplementedError when seed is not None. Returns: The LLM's response text. """ def call_with_config( self, prompt_key: str, knowledge_key: str, variables: dict, config: dict | None = None, seed: int | None = None, ) -> str: """Convenience method: load prompt template and knowledge from config paths, then call. Args: prompt_key: Key in config['prompts'] for the prompt template file path. knowledge_key: Key in config['knowledge'] for the expert knowledge file path. variables: Dictionary of variables to fill into the template. config: Config dict. If None, loads from config.yaml. seed: Optional seed forwarded to the underlying call() (see LLMClient.call). """ if config is None: config = load_config() template = load_prompt_template(config["prompts"][prompt_key]) knowledge = load_expert_knowledge(config["knowledge"][knowledge_key]) return self.call(template, variables, knowledge, seed=seed)