gap-finder 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.
Files changed (46) hide show
  1. gap_finder/__init__.py +31 -0
  2. gap_finder/_auth.py +18 -0
  3. gap_finder/embedding_model/__init__.py +40 -0
  4. gap_finder/embedding_model/client.py +24 -0
  5. gap_finder/embedding_model/embedding_model.py +37 -0
  6. gap_finder/embedding_model/factory.py +34 -0
  7. gap_finder/embedding_model/local_embedding_model.py +92 -0
  8. gap_finder/embedding_model/openai_embedding_model.py +43 -0
  9. gap_finder/io_helper.py +71 -0
  10. gap_finder/language_model/__init__.py +39 -0
  11. gap_finder/language_model/client.py +43 -0
  12. gap_finder/language_model/factory.py +40 -0
  13. gap_finder/language_model/language_model.py +31 -0
  14. gap_finder/language_model/local_language_model.py +69 -0
  15. gap_finder/language_model/openai_language_model.py +45 -0
  16. gap_finder/main.py +932 -0
  17. gap_finder/model_types.py +67 -0
  18. gap_finder/persistence.py +449 -0
  19. gap_finder/prompts/__init__.py +3 -0
  20. gap_finder/prompts/loader.py +44 -0
  21. gap_finder/prompts/templates/hyde.txt.j2 +13 -0
  22. gap_finder/prompts/templates/question_generation/examples_bad.txt.j2 +3 -0
  23. gap_finder/prompts/templates/question_generation/examples_good.txt.j2 +3 -0
  24. gap_finder/prompts/templates/question_generation/main.txt.j2 +34 -0
  25. gap_finder/prompts/templates/question_generation/rules.txt.j2 +10 -0
  26. gap_finder/prompts/templates/summarize.txt.j2 +10 -0
  27. gap_finder/prompts/templates/topic_explanation.txt.j2 +21 -0
  28. gap_finder/prompts/templates/topic_relevance.txt.j2 +23 -0
  29. gap_finder/py.typed +0 -0
  30. gap_finder/question_generation.py +160 -0
  31. gap_finder/question_ranking.py +72 -0
  32. gap_finder/report.py +120 -0
  33. gap_finder/singleton/constants.py +47 -0
  34. gap_finder/topic_explanation.py +164 -0
  35. gap_finder/topic_generation.py +155 -0
  36. gap_finder/topic_ranking.py +109 -0
  37. gap_finder/topic_relevance.py +128 -0
  38. gap_finder/vector_store/__init__.py +18 -0
  39. gap_finder/vector_store/faiss_vector_store.py +74 -0
  40. gap_finder/vector_store/numpy_vector_store.py +88 -0
  41. gap_finder/vector_store/pdf_chunker.py +41 -0
  42. gap_finder/vector_store/vector_store.py +69 -0
  43. gap_finder-0.1.0.dist-info/METADATA +240 -0
  44. gap_finder-0.1.0.dist-info/RECORD +46 -0
  45. gap_finder-0.1.0.dist-info/WHEEL +4 -0
  46. gap_finder-0.1.0.dist-info/licenses/LICENSE +21 -0
gap_finder/__init__.py ADDED
@@ -0,0 +1,31 @@
1
+ from typing import TYPE_CHECKING
2
+
3
+ from gap_finder._auth import MissingAPIKeyError
4
+ from gap_finder.model_types import (
5
+ EmbeddingModelInput,
6
+ LanguageModelInput,
7
+ SentenceTransformerLike,
8
+ TextGenerationPipelineLike,
9
+ TopicEmbeddingModelInput,
10
+ )
11
+
12
+ if TYPE_CHECKING:
13
+ from gap_finder.main import GapFinder
14
+
15
+ __all__ = [
16
+ "EmbeddingModelInput",
17
+ "GapFinder",
18
+ "LanguageModelInput",
19
+ "MissingAPIKeyError",
20
+ "SentenceTransformerLike",
21
+ "TextGenerationPipelineLike",
22
+ "TopicEmbeddingModelInput",
23
+ ]
24
+
25
+
26
+ def __getattr__(name: str):
27
+ if name == "GapFinder":
28
+ from gap_finder.main import GapFinder
29
+
30
+ return GapFinder
31
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
gap_finder/_auth.py ADDED
@@ -0,0 +1,18 @@
1
+ import os
2
+
3
+
4
+ class MissingAPIKeyError(RuntimeError):
5
+ """Raised when no API key was provided or configured."""
6
+
7
+
8
+ def resolve_api_key(
9
+ explicit: str | None = None,
10
+ env_var: str = "GAP_FINDER_API_KEY",
11
+ ) -> str:
12
+ key = explicit or os.getenv(env_var)
13
+ if not key:
14
+ raise MissingAPIKeyError(
15
+ f"No API key found. Pass api_key=... or set the environment "
16
+ f"variable {env_var}."
17
+ )
18
+ return key
@@ -0,0 +1,40 @@
1
+ from importlib import import_module
2
+ from typing import TYPE_CHECKING
3
+
4
+ from gap_finder.embedding_model.client import OpenAIEmbeddingClient
5
+
6
+ if TYPE_CHECKING:
7
+ from gap_finder.embedding_model.embedding_model import EmbeddingModel
8
+ from gap_finder.embedding_model.factory import create_embedding_model
9
+ from gap_finder.embedding_model.local_embedding_model import LocalEmbeddingModel
10
+ from gap_finder.embedding_model.openai_embedding_model import OpenAIEmbeddingModel
11
+
12
+ __all__ = [
13
+ "EmbeddingModel",
14
+ "LocalEmbeddingModel",
15
+ "OpenAIEmbeddingClient",
16
+ "OpenAIEmbeddingModel",
17
+ "create_embedding_model",
18
+ ]
19
+
20
+
21
+ def __getattr__(name: str):
22
+ if name == "EmbeddingModel":
23
+ from gap_finder.embedding_model.embedding_model import EmbeddingModel
24
+
25
+ return EmbeddingModel
26
+ if name == "create_embedding_model":
27
+ from gap_finder.embedding_model.factory import create_embedding_model
28
+
29
+ return create_embedding_model
30
+ if name == "LocalEmbeddingModel":
31
+ from gap_finder.embedding_model.local_embedding_model import LocalEmbeddingModel
32
+
33
+ return LocalEmbeddingModel
34
+ if name == "OpenAIEmbeddingModel":
35
+ from gap_finder.embedding_model.openai_embedding_model import OpenAIEmbeddingModel
36
+
37
+ return OpenAIEmbeddingModel
38
+ if name == "factory":
39
+ return import_module("gap_finder.embedding_model.factory")
40
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,24 @@
1
+ from openai import OpenAI
2
+
3
+ from gap_finder._auth import resolve_api_key
4
+
5
+
6
+ class OpenAIEmbeddingClient:
7
+ """An OpenAI client paired with the embedding model it should call."""
8
+
9
+ def __init__(
10
+ self,
11
+ model_name: str,
12
+ base_url: str | None = None,
13
+ api_key: str | None = None,
14
+ client: OpenAI | None = None,
15
+ ) -> None:
16
+ self.model_name = model_name
17
+ self.client = (
18
+ client
19
+ if client is not None
20
+ else OpenAI(
21
+ base_url=base_url,
22
+ api_key=resolve_api_key(api_key),
23
+ )
24
+ )
@@ -0,0 +1,37 @@
1
+ from abc import ABC, abstractmethod
2
+
3
+ import numpy as np
4
+
5
+
6
+ class EmbeddingModel(ABC):
7
+ """Common interface for local and API-backed text embedding models."""
8
+
9
+ @abstractmethod
10
+ def encode(
11
+ self,
12
+ texts: list[str],
13
+ show_progress_bar: bool = False,
14
+ batch_size: int = 32,
15
+ ) -> np.ndarray:
16
+ """Encode text into an embedding matrix."""
17
+ raise NotImplementedError
18
+
19
+ def embed(self, documents: list[str], verbose: bool = False) -> np.ndarray:
20
+ """Embed documents using BERTopic-compatible arguments."""
21
+ return self.encode(
22
+ documents,
23
+ show_progress_bar=verbose,
24
+ batch_size=32,
25
+ )
26
+
27
+ def activate(self) -> None:
28
+ """Make internally owned resources ready for use."""
29
+ return
30
+
31
+ def deactivate(self) -> None:
32
+ """Optionally offload internally owned resources between steps."""
33
+ return
34
+
35
+ def cleanup(self) -> None:
36
+ """Release internally owned resources after the pipeline finishes."""
37
+ return
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ from gap_finder.embedding_model.client import OpenAIEmbeddingClient
4
+ from gap_finder.embedding_model.embedding_model import EmbeddingModel
5
+ from gap_finder.model_types import EmbeddingModelInput
6
+
7
+
8
+ def create_embedding_model(
9
+ embedding_model: EmbeddingModelInput,
10
+ ) -> EmbeddingModel:
11
+ """Create an embedding wrapper from a model name or configured client."""
12
+ if isinstance(embedding_model, EmbeddingModel):
13
+ return embedding_model
14
+
15
+ if isinstance(embedding_model, OpenAIEmbeddingClient):
16
+ from gap_finder.embedding_model.openai_embedding_model import OpenAIEmbeddingModel
17
+
18
+ return OpenAIEmbeddingModel(client=embedding_model)
19
+ if isinstance(embedding_model, str):
20
+ from gap_finder.embedding_model.local_embedding_model import LocalEmbeddingModel
21
+
22
+ return LocalEmbeddingModel(client=embedding_model)
23
+
24
+ from sentence_transformers import SentenceTransformer
25
+
26
+ if isinstance(embedding_model, SentenceTransformer):
27
+ from gap_finder.embedding_model.local_embedding_model import LocalEmbeddingModel
28
+
29
+ return LocalEmbeddingModel(client=embedding_model)
30
+ raise TypeError(
31
+ "embedding_model must be an EmbeddingModel, model name, "
32
+ "SentenceTransformer, or "
33
+ f"OpenAIEmbeddingClient; got {type(embedding_model).__name__}."
34
+ )
@@ -0,0 +1,92 @@
1
+ import gc
2
+ from typing import Any
3
+
4
+ import numpy as np
5
+ from sentence_transformers import SentenceTransformer
6
+
7
+ from gap_finder.embedding_model.embedding_model import EmbeddingModel
8
+
9
+
10
+ class LocalEmbeddingModel(EmbeddingModel):
11
+ """Embedding model backed by a local SentenceTransformer."""
12
+
13
+ def __init__(
14
+ self,
15
+ client: str | SentenceTransformer,
16
+ ) -> None:
17
+ super().__init__()
18
+ self._owns_client = isinstance(client, str)
19
+ self.model_name = (
20
+ client
21
+ if isinstance(client, str)
22
+ else getattr(client, "model_name_or_path", None)
23
+ )
24
+ self.client: SentenceTransformer | None = (
25
+ None if isinstance(client, str) else client
26
+ )
27
+ self._target_device: Any = None
28
+ self._active = not self._owns_client
29
+ if self.client is not None:
30
+ self._target_device = getattr(self.client, "device", None)
31
+
32
+ def activate(self) -> None:
33
+ """Load or reactivate an internally owned SentenceTransformer."""
34
+ if not self._owns_client or self._active:
35
+ return
36
+ if self.client is None:
37
+ if not isinstance(self.model_name, str):
38
+ raise ValueError("Local embedding model has no model name.")
39
+ self.client = SentenceTransformer(self.model_name)
40
+ self._target_device = getattr(self.client, "device", None)
41
+ elif self._target_device is not None and hasattr(self.client, "to"):
42
+ self.client.to(self._target_device)
43
+ self._active = True
44
+
45
+ def _require_client(self) -> SentenceTransformer:
46
+ self.activate()
47
+ if self.client is None:
48
+ raise RuntimeError("Local embedding model could not be activated.")
49
+ return self.client
50
+
51
+ def encode(
52
+ self,
53
+ texts: list[str],
54
+ show_progress_bar: bool = False,
55
+ batch_size: int = 32,
56
+ ) -> np.ndarray:
57
+ if batch_size < 1:
58
+ raise ValueError("batch_size must be at least 1.")
59
+
60
+ embeddings = self._require_client().encode(
61
+ texts,
62
+ show_progress_bar=show_progress_bar,
63
+ batch_size=batch_size,
64
+ )
65
+ return np.asarray(embeddings, dtype=np.float32)
66
+
67
+ def deactivate(self) -> None:
68
+ """Offload an internally owned model while retaining it for reuse."""
69
+ if not self._owns_client or self.client is None or not self._active:
70
+ return
71
+
72
+ try:
73
+ import torch
74
+ except ImportError:
75
+ gc.collect()
76
+ return
77
+
78
+ if hasattr(self.client, "to"):
79
+ self.client.to(torch.device("cpu"))
80
+ self._active = False
81
+ gc.collect()
82
+ if torch.cuda.is_available():
83
+ torch.cuda.empty_cache()
84
+
85
+ def cleanup(self) -> None:
86
+ """Release an internally owned model after the pipeline finishes."""
87
+ if not self._owns_client or self.client is None:
88
+ return
89
+ self.deactivate()
90
+ self.client = None
91
+ self._active = False
92
+ gc.collect()
@@ -0,0 +1,43 @@
1
+ import numpy as np
2
+ from tqdm import tqdm
3
+
4
+ from gap_finder.embedding_model.client import OpenAIEmbeddingClient
5
+ from gap_finder.embedding_model.embedding_model import EmbeddingModel
6
+
7
+
8
+ class OpenAIEmbeddingModel(EmbeddingModel):
9
+ """Embedding model backed by an OpenAI-compatible embeddings API."""
10
+
11
+ def __init__(
12
+ self,
13
+ client: OpenAIEmbeddingClient,
14
+ ) -> None:
15
+ super().__init__()
16
+ self.model_name = client.model_name
17
+ self.client = client.client
18
+
19
+ def encode(
20
+ self,
21
+ texts: list[str],
22
+ show_progress_bar: bool = False,
23
+ batch_size: int = 32,
24
+ ) -> np.ndarray:
25
+ if batch_size < 1:
26
+ raise ValueError("batch_size must be at least 1.")
27
+ if not texts:
28
+ return np.empty((0, 0), dtype=np.float32)
29
+
30
+ embeddings: list[list[float]] = []
31
+ batch_starts = range(0, len(texts), batch_size)
32
+ for start in tqdm(batch_starts, disable=not show_progress_bar):
33
+ batch = texts[start:start + batch_size]
34
+ response = self.client.embeddings.create(
35
+ model=self.model_name,
36
+ input=batch,
37
+ )
38
+ response_data = list(response.data)
39
+ if all(hasattr(item, "index") for item in response_data):
40
+ response_data.sort(key=lambda item: item.index)
41
+ embeddings.extend(item.embedding for item in response_data)
42
+
43
+ return np.asarray(embeddings, dtype=np.float32)
@@ -0,0 +1,71 @@
1
+ """I/O helpers for loading and saving all pipeline artifacts."""
2
+ import logging
3
+ from datetime import datetime, timezone
4
+ from pathlib import Path
5
+ from uuid import uuid4
6
+
7
+ import pandas as pd
8
+
9
+ from gap_finder.singleton import constants
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ def _save_dataframe(df: pd.DataFrame, path: Path):
15
+ path.parent.mkdir(parents=True, exist_ok=True)
16
+ df.to_json(path, orient='records', force_ascii=False, indent=2)
17
+ logger.info(f"Saved {len(df)} entries to {path}")
18
+
19
+
20
+ def _save_text(text: str, path: Path):
21
+ path.parent.mkdir(parents=True, exist_ok=True)
22
+ path.write_text(text, encoding='utf-8')
23
+ logger.info(f"Saved report to {path}")
24
+
25
+
26
+ def bertopic_model_path(base_path: str, id: str) -> Path:
27
+ return Path(base_path) / Path(id) / constants.DataFileNames.BERTOPIC_MODEL
28
+
29
+
30
+ class FileHandler:
31
+ """Save the artifacts produced by one GapFinder run."""
32
+
33
+ def __init__(self, base_path: str | Path):
34
+ timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%fZ")
35
+ self.run_path = Path(base_path) / f"{timestamp}_{uuid4().hex[:8]}"
36
+ self.run_path.mkdir(parents=True, exist_ok=False)
37
+ logger.info(f"Saving intermediate results to {self.run_path}")
38
+
39
+ def _path(self, filename: str) -> Path:
40
+ return self.run_path / filename
41
+
42
+ def save_questions(self, questions: pd.DataFrame):
43
+ _save_dataframe(
44
+ questions, self._path(constants.DataFileNames.QUESTIONS))
45
+
46
+ def save_questions_with_topics(self, questions: pd.DataFrame):
47
+ _save_dataframe(
48
+ questions,
49
+ self._path(constants.DataFileNames.QUESTIONS_WITH_TOPICS),
50
+ )
51
+
52
+ def save_questions_rankings(self, questions: pd.DataFrame):
53
+ _save_dataframe(
54
+ questions, self._path(constants.DataFileNames.QUESTIONS_RANKINGS))
55
+
56
+ def save_topic_rankings(self, topics: pd.DataFrame):
57
+ _save_dataframe(
58
+ topics, self._path(constants.DataFileNames.TOPICS_RANKINGS_JSON))
59
+
60
+ def save_topic_explanations(self, topics: pd.DataFrame):
61
+ _save_dataframe(
62
+ topics, self._path(constants.DataFileNames.TOPIC_EXPLANATIONS_JSON))
63
+
64
+ def save_topic_relevance_scores(self, topics: pd.DataFrame):
65
+ _save_dataframe(
66
+ topics,
67
+ self._path(constants.DataFileNames.TOPIC_RELEVANCE_SCORES_JSON),
68
+ )
69
+
70
+ def save_report(self, report: str):
71
+ _save_text(report, self._path(constants.DataFileNames.REPORT_MD))
@@ -0,0 +1,39 @@
1
+ from importlib import import_module
2
+ from typing import TYPE_CHECKING
3
+
4
+ from gap_finder._auth import MissingAPIKeyError
5
+ from gap_finder.language_model.client import ClientError, OpenAIClient
6
+ from gap_finder.language_model.language_model import LanguageModel
7
+
8
+ if TYPE_CHECKING:
9
+ from gap_finder.language_model.factory import create_language_model
10
+ from gap_finder.language_model.local_language_model import LocalLanguageModel
11
+ from gap_finder.language_model.openai_language_model import OpenAILanguageModel
12
+
13
+ __all__ = [
14
+ "ClientError",
15
+ "LanguageModel",
16
+ "LocalLanguageModel",
17
+ "MissingAPIKeyError",
18
+ "OpenAIClient",
19
+ "OpenAILanguageModel",
20
+ "create_language_model",
21
+ ]
22
+
23
+
24
+ def __getattr__(name: str):
25
+ if name == "create_language_model":
26
+ from gap_finder.language_model.factory import create_language_model
27
+
28
+ return create_language_model
29
+ if name == "LocalLanguageModel":
30
+ from gap_finder.language_model.local_language_model import LocalLanguageModel
31
+
32
+ return LocalLanguageModel
33
+ if name == "OpenAILanguageModel":
34
+ from gap_finder.language_model.openai_language_model import OpenAILanguageModel
35
+
36
+ return OpenAILanguageModel
37
+ if name == "factory":
38
+ return import_module("gap_finder.language_model.factory")
39
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,43 @@
1
+ from openai import OpenAI
2
+
3
+ from gap_finder._auth import resolve_api_key
4
+
5
+
6
+ class ClientError(Exception):
7
+ """Error raised when the language-model client returns an invalid result."""
8
+
9
+ def __init__(self, *args, **context):
10
+ super().__init__(*args)
11
+ self.context = context
12
+
13
+
14
+ class OpenAIClient:
15
+ """Configured OpenAI-compatible chat-completions client."""
16
+
17
+ def __init__(
18
+ self,
19
+ model_name: str,
20
+ base_url: str | None = None,
21
+ api_key: str | None = None,
22
+ ) -> None:
23
+ self.model_name = model_name
24
+ self.client = OpenAI(
25
+ base_url=base_url,
26
+ api_key=resolve_api_key(api_key),
27
+ )
28
+
29
+ def invoke(self, prompt: str, system_prompt: str | None = None) -> str | None:
30
+ """Send a chat request and return its response text."""
31
+ try:
32
+ messages = []
33
+ if system_prompt is not None:
34
+ messages.append({"role": "system", "content": system_prompt})
35
+ messages.append({"role": "user", "content": prompt})
36
+
37
+ response = self.client.chat.completions.create(
38
+ model=self.model_name,
39
+ messages=messages,
40
+ )
41
+ return response.choices[0].message.content
42
+ except Exception as error:
43
+ raise ClientError(*error.args) from error
@@ -0,0 +1,40 @@
1
+ from __future__ import annotations
2
+
3
+ from gap_finder.language_model import client as language_model_client
4
+ from gap_finder.language_model.language_model import LanguageModel
5
+ from gap_finder.model_types import LanguageModelInput
6
+
7
+
8
+ def create_language_model(
9
+ language_model: LanguageModelInput,
10
+ base_url: str | None = None,
11
+ api_key: str | None = None,
12
+ ) -> LanguageModel:
13
+ """Create a language-model wrapper from a model name or client."""
14
+ if isinstance(language_model, LanguageModel):
15
+ return language_model
16
+
17
+ if isinstance(language_model, str):
18
+ from gap_finder.language_model.openai_language_model import OpenAILanguageModel
19
+
20
+ client = language_model_client.OpenAIClient(
21
+ model_name=language_model,
22
+ base_url=base_url,
23
+ api_key=api_key,
24
+ )
25
+ return OpenAILanguageModel(client=client)
26
+ if isinstance(language_model, language_model_client.OpenAIClient):
27
+ from gap_finder.language_model.openai_language_model import OpenAILanguageModel
28
+
29
+ return OpenAILanguageModel(client=language_model)
30
+
31
+ from transformers.pipelines.text_generation import TextGenerationPipeline
32
+
33
+ if isinstance(language_model, TextGenerationPipeline):
34
+ from gap_finder.language_model.local_language_model import LocalLanguageModel
35
+
36
+ return LocalLanguageModel(client=language_model)
37
+ raise TypeError(
38
+ "language_model must be a LanguageModel, model name, OpenAIClient, or "
39
+ f"TextGenerationPipeline; got {type(language_model).__name__}."
40
+ )
@@ -0,0 +1,31 @@
1
+ from abc import ABC, abstractmethod
2
+
3
+
4
+ class LanguageModel(ABC):
5
+ """Abstract interface for prompt-based language models."""
6
+
7
+ @abstractmethod
8
+ def invoke(self, prompt: str, system_prompt: str | None = None) -> str:
9
+ """Send a prompt and an optional system prompt to the model."""
10
+ raise NotImplementedError
11
+
12
+ @abstractmethod
13
+ def invoke_batch(
14
+ self,
15
+ prompts: list[str],
16
+ system_prompt: str | None = None,
17
+ ) -> list[str]:
18
+ """Send multiple prompts and an optional system prompt to the model."""
19
+ raise NotImplementedError
20
+
21
+ def activate(self) -> None:
22
+ """Make internally owned resources ready for use."""
23
+ return
24
+
25
+ def deactivate(self) -> None:
26
+ """Optionally offload internally owned resources between steps."""
27
+ return
28
+
29
+ def cleanup(self) -> None:
30
+ """Release internally owned resources after the pipeline finishes."""
31
+ return
@@ -0,0 +1,69 @@
1
+ from typing import Any
2
+
3
+ from transformers.pipelines.text_generation import TextGenerationPipeline
4
+
5
+ from gap_finder.language_model.language_model import LanguageModel
6
+
7
+
8
+ class LocalLanguageModel(LanguageModel):
9
+ """Language model backed by a Transformers text-generation pipeline."""
10
+
11
+ def __init__(
12
+ self,
13
+ client: TextGenerationPipeline,
14
+ ) -> None:
15
+ if getattr(client, "task", None) != "text-generation":
16
+ raise ValueError(
17
+ "language_model pipeline must have task 'text-generation'."
18
+ )
19
+ self.client = client
20
+
21
+ def invoke(self, prompt: str, system_prompt: str | None = None) -> str:
22
+ text = self._format_prompt(prompt, system_prompt)
23
+ return self._extract_text(self._generate(text))
24
+
25
+ def invoke_batch(
26
+ self,
27
+ prompts: list[str],
28
+ system_prompt: str | None = None,
29
+ ) -> list[str]:
30
+ if not prompts:
31
+ return []
32
+ texts = [self._format_prompt(prompt, system_prompt) for prompt in prompts]
33
+ results = self._generate(texts)
34
+ return [self._extract_text(result) for result in results]
35
+
36
+ def _format_prompt(self, prompt: str, system_prompt: str | None = None) -> str:
37
+ messages = []
38
+ if system_prompt is not None:
39
+ messages.append({"role": "system", "content": system_prompt})
40
+ messages.append({"role": "user", "content": prompt})
41
+ formatted_prompt = self.client.tokenizer.apply_chat_template(
42
+ messages,
43
+ tokenize=False,
44
+ add_generation_prompt=True,
45
+ )
46
+ if not isinstance(formatted_prompt, str):
47
+ raise TypeError(
48
+ "Tokenizer chat template must return text when tokenize=False."
49
+ )
50
+ return formatted_prompt
51
+
52
+ def _generate(self, text: str | list[str]) -> Any:
53
+ return self.client(
54
+ text,
55
+ do_sample=False,
56
+ return_full_text=False,
57
+ )
58
+
59
+ @staticmethod
60
+ def _extract_text(result: Any) -> str:
61
+ while isinstance(result, list):
62
+ if not result:
63
+ raise ValueError("Language model returned no generations.")
64
+ result = result[0]
65
+ if not isinstance(result, dict) or not isinstance(
66
+ result.get("generated_text"), str
67
+ ):
68
+ raise ValueError("Language model returned an invalid generation.")
69
+ return result["generated_text"]
@@ -0,0 +1,45 @@
1
+ from gap_finder.language_model.client import ClientError, OpenAIClient
2
+ from gap_finder.language_model.language_model import LanguageModel
3
+
4
+
5
+ class OpenAILanguageModel(LanguageModel):
6
+ """Language model backed by a configured OpenAI-compatible client."""
7
+
8
+ def __init__(self, client: OpenAIClient) -> None:
9
+ self.client = client
10
+
11
+ def invoke(self, prompt: str, system_prompt: str | None = None) -> str:
12
+ response = self.client.invoke(prompt=prompt, system_prompt=system_prompt)
13
+ return self._require_response(response, prompt, system_prompt)
14
+
15
+ def invoke_batch(
16
+ self,
17
+ prompts: list[str],
18
+ system_prompt: str | None = None,
19
+ ) -> list[str]:
20
+ return [
21
+ self._require_response(
22
+ self.client.invoke(prompt=prompt, system_prompt=system_prompt),
23
+ prompt,
24
+ system_prompt,
25
+ index=index,
26
+ )
27
+ for index, prompt in enumerate(prompts)
28
+ ]
29
+
30
+ def _require_response(
31
+ self,
32
+ response: str | None,
33
+ prompt: str,
34
+ system_prompt: str | None,
35
+ **context,
36
+ ) -> str:
37
+ if response is None:
38
+ raise ClientError(
39
+ "OpenAIClient returned None response.",
40
+ prompt=prompt,
41
+ system_prompt=system_prompt,
42
+ client=self.client,
43
+ **context,
44
+ )
45
+ return response