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.
- gap_finder/__init__.py +31 -0
- gap_finder/_auth.py +18 -0
- gap_finder/embedding_model/__init__.py +40 -0
- gap_finder/embedding_model/client.py +24 -0
- gap_finder/embedding_model/embedding_model.py +37 -0
- gap_finder/embedding_model/factory.py +34 -0
- gap_finder/embedding_model/local_embedding_model.py +92 -0
- gap_finder/embedding_model/openai_embedding_model.py +43 -0
- gap_finder/io_helper.py +71 -0
- gap_finder/language_model/__init__.py +39 -0
- gap_finder/language_model/client.py +43 -0
- gap_finder/language_model/factory.py +40 -0
- gap_finder/language_model/language_model.py +31 -0
- gap_finder/language_model/local_language_model.py +69 -0
- gap_finder/language_model/openai_language_model.py +45 -0
- gap_finder/main.py +932 -0
- gap_finder/model_types.py +67 -0
- gap_finder/persistence.py +449 -0
- gap_finder/prompts/__init__.py +3 -0
- gap_finder/prompts/loader.py +44 -0
- gap_finder/prompts/templates/hyde.txt.j2 +13 -0
- gap_finder/prompts/templates/question_generation/examples_bad.txt.j2 +3 -0
- gap_finder/prompts/templates/question_generation/examples_good.txt.j2 +3 -0
- gap_finder/prompts/templates/question_generation/main.txt.j2 +34 -0
- gap_finder/prompts/templates/question_generation/rules.txt.j2 +10 -0
- gap_finder/prompts/templates/summarize.txt.j2 +10 -0
- gap_finder/prompts/templates/topic_explanation.txt.j2 +21 -0
- gap_finder/prompts/templates/topic_relevance.txt.j2 +23 -0
- gap_finder/py.typed +0 -0
- gap_finder/question_generation.py +160 -0
- gap_finder/question_ranking.py +72 -0
- gap_finder/report.py +120 -0
- gap_finder/singleton/constants.py +47 -0
- gap_finder/topic_explanation.py +164 -0
- gap_finder/topic_generation.py +155 -0
- gap_finder/topic_ranking.py +109 -0
- gap_finder/topic_relevance.py +128 -0
- gap_finder/vector_store/__init__.py +18 -0
- gap_finder/vector_store/faiss_vector_store.py +74 -0
- gap_finder/vector_store/numpy_vector_store.py +88 -0
- gap_finder/vector_store/pdf_chunker.py +41 -0
- gap_finder/vector_store/vector_store.py +69 -0
- gap_finder-0.1.0.dist-info/METADATA +240 -0
- gap_finder-0.1.0.dist-info/RECORD +46 -0
- gap_finder-0.1.0.dist-info/WHEEL +4 -0
- 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)
|
gap_finder/io_helper.py
ADDED
|
@@ -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
|