simonbb 0.0.8__tar.gz → 0.0.9__tar.gz
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.
- {simonbb-0.0.8 → simonbb-0.0.9}/PKG-INFO +12 -13
- {simonbb-0.0.8 → simonbb-0.0.9}/pyproject.toml +12 -13
- simonbb-0.0.9/src/simonbb/cis438/rag.py +455 -0
- simonbb-0.0.8/src/simonbb/cis438/rag.py +0 -258
- {simonbb-0.0.8 → simonbb-0.0.9}/LICENSE +0 -0
- {simonbb-0.0.8 → simonbb-0.0.9}/README.md +0 -0
- {simonbb-0.0.8 → simonbb-0.0.9}/src/simonbb/__init__.py +0 -0
- {simonbb-0.0.8 → simonbb-0.0.9}/src/simonbb/cis433/__init__.py +0 -0
- {simonbb-0.0.8 → simonbb-0.0.9}/src/simonbb/cis433/actions.py +0 -0
- {simonbb-0.0.8 → simonbb-0.0.9}/src/simonbb/cis433/agent.py +0 -0
- {simonbb-0.0.8 → simonbb-0.0.9}/src/simonbb/cis433/bert.py +0 -0
- {simonbb-0.0.8 → simonbb-0.0.9}/src/simonbb/cis433/dqn.py +0 -0
- {simonbb-0.0.8 → simonbb-0.0.9}/src/simonbb/cis433/experience.py +0 -0
- {simonbb-0.0.8 → simonbb-0.0.9}/src/simonbb/cis433/gpt.py +0 -0
- {simonbb-0.0.8 → simonbb-0.0.9}/src/simonbb/cis433/ignite.py +0 -0
- {simonbb-0.0.8 → simonbb-0.0.9}/src/simonbb/cis433/math500.py +0 -0
- {simonbb-0.0.8 → simonbb-0.0.9}/src/simonbb/cis433/qwen3.py +0 -0
- {simonbb-0.0.8 → simonbb-0.0.9}/src/simonbb/cis433/transformer.py +0 -0
- {simonbb-0.0.8 → simonbb-0.0.9}/src/simonbb/cis433/utils.py +0 -0
- {simonbb-0.0.8 → simonbb-0.0.9}/src/simonbb/cis433/wrappers.py +0 -0
- {simonbb-0.0.8 → simonbb-0.0.9}/src/simonbb/cis438/__init__.py +0 -0
- {simonbb-0.0.8 → simonbb-0.0.9}/src/simonbb/utils.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: simonbb
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.9
|
|
4
4
|
Summary: A small package to facilitate teaching AI courses at business schools
|
|
5
5
|
Project-URL: Homepage, https://simonbb.com
|
|
6
6
|
Author-email: Huaxia Rui <huaxia.rui@simon.rochester.edu>
|
|
@@ -9,18 +9,17 @@ License-File: LICENSE
|
|
|
9
9
|
Classifier: Operating System :: OS Independent
|
|
10
10
|
Classifier: Programming Language :: Python :: 3
|
|
11
11
|
Requires-Python: >=3.9
|
|
12
|
-
Requires-Dist: gymnasium
|
|
13
|
-
Requires-Dist: matplotlib
|
|
14
|
-
Requires-Dist: numpy
|
|
15
|
-
Requires-Dist: opencv-python
|
|
16
|
-
Requires-Dist: pytorch-ignite
|
|
17
|
-
Requires-Dist: pyyaml
|
|
18
|
-
Requires-Dist: ray
|
|
19
|
-
Requires-Dist:
|
|
20
|
-
Requires-Dist:
|
|
21
|
-
Requires-Dist: torchvision
|
|
22
|
-
Requires-Dist:
|
|
23
|
-
Requires-Dist: transformers~=4.57.3
|
|
12
|
+
Requires-Dist: gymnasium>=1.3.0
|
|
13
|
+
Requires-Dist: matplotlib>=3.10.8
|
|
14
|
+
Requires-Dist: numpy>=2.4.0
|
|
15
|
+
Requires-Dist: opencv-python>=5.0.0.93
|
|
16
|
+
Requires-Dist: pytorch-ignite>=0.5.4
|
|
17
|
+
Requires-Dist: pyyaml>=6.0.3
|
|
18
|
+
Requires-Dist: ray>=2.56.0
|
|
19
|
+
Requires-Dist: stable-baselines3>=2.9.0
|
|
20
|
+
Requires-Dist: torch>=2.9.1
|
|
21
|
+
Requires-Dist: torchvision>=0.24.1
|
|
22
|
+
Requires-Dist: transformers>=4.57.3
|
|
24
23
|
Description-Content-Type: text/markdown
|
|
25
24
|
|
|
26
25
|
# Background
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "simonbb"
|
|
7
|
-
version = "0.0.
|
|
7
|
+
version = "0.0.9"
|
|
8
8
|
authors = [
|
|
9
9
|
{ name="Huaxia Rui", email="huaxia.rui@simon.rochester.edu" },
|
|
10
10
|
]
|
|
@@ -18,18 +18,17 @@ classifiers = [
|
|
|
18
18
|
license = "MIT"
|
|
19
19
|
license-files = ["LICEN[CS]E*"]
|
|
20
20
|
dependencies = [
|
|
21
|
-
"matplotlib
|
|
22
|
-
"torch
|
|
23
|
-
"torchvision
|
|
24
|
-
"numpy
|
|
25
|
-
"transformers
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
"
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
"pyyaml~=6.0.3"
|
|
21
|
+
"matplotlib >= 3.10.8",
|
|
22
|
+
"torch >= 2.9.1",
|
|
23
|
+
"torchvision >=0.24.1",
|
|
24
|
+
"numpy >=2.4.0",
|
|
25
|
+
"transformers >= 4.57.3",
|
|
26
|
+
"gymnasium >= 1.3.0",
|
|
27
|
+
"pytorch-ignite >= 0.5.4",
|
|
28
|
+
"stable_baselines3 >= 2.9.0",
|
|
29
|
+
"ray >= 2.56.0",
|
|
30
|
+
"opencv-python >= 5.0.0.93",
|
|
31
|
+
"pyyaml >= 6.0.3"
|
|
33
32
|
]
|
|
34
33
|
[project.urls]
|
|
35
34
|
Homepage = "https://simonbb.com"
|
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
from typing import Annotated, TypedDict, get_args, Any, Protocol, Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from functools import partial
|
|
12
|
+
|
|
13
|
+
from langchain_core.documents import Document
|
|
14
|
+
from langchain_core.messages import HumanMessage, SystemMessage
|
|
15
|
+
from langchain_core.prompts import ChatPromptTemplate
|
|
16
|
+
from langchain_core.runnables import Runnable, RunnablePassthrough
|
|
17
|
+
from langchain_core.output_parsers import StrOutputParser
|
|
18
|
+
|
|
19
|
+
from langgraph.graph import END, START, StateGraph
|
|
20
|
+
from langgraph.graph.message import add_messages
|
|
21
|
+
|
|
22
|
+
from langchain_chroma import Chroma
|
|
23
|
+
from langchain_community.document_loaders import PyPDFLoader, UnstructuredHTMLLoader
|
|
24
|
+
|
|
25
|
+
from langchain_core.callbacks import CallbackManagerForRetrieverRun
|
|
26
|
+
|
|
27
|
+
import hashlib
|
|
28
|
+
import chromadb
|
|
29
|
+
from langchain_core.embeddings import Embeddings
|
|
30
|
+
from langchain_core.language_models import BaseChatModel
|
|
31
|
+
from langchain_core.retrievers import BaseRetriever
|
|
32
|
+
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
|
33
|
+
|
|
34
|
+
#%%
|
|
35
|
+
|
|
36
|
+
def load_documents(path: Path):
|
|
37
|
+
"""Load documents from a file or directory."""
|
|
38
|
+
|
|
39
|
+
valid_extensions = {'.pdf', '.html', '.ejs'}
|
|
40
|
+
|
|
41
|
+
docs = []
|
|
42
|
+
success_cnt = 0
|
|
43
|
+
page_cnt = 0
|
|
44
|
+
|
|
45
|
+
if path.exists():
|
|
46
|
+
if path.is_dir():
|
|
47
|
+
for file in [ item for item in path.rglob('*') if item.is_file() and item.suffix in valid_extensions ]:
|
|
48
|
+
print(file.name)
|
|
49
|
+
if file.suffix == '.pdf':
|
|
50
|
+
documents = PyPDFLoader(file).load()
|
|
51
|
+
elif file.suffix in {'.html', '.ejs'}:
|
|
52
|
+
documents = UnstructuredHTMLLoader(file).load()
|
|
53
|
+
else:
|
|
54
|
+
continue
|
|
55
|
+
success_cnt += 1
|
|
56
|
+
page_cnt += len(documents)
|
|
57
|
+
docs.extend(documents)
|
|
58
|
+
else:
|
|
59
|
+
print(path.name)
|
|
60
|
+
if path.suffix == '.pdf':
|
|
61
|
+
documents = PyPDFLoader(path).load()
|
|
62
|
+
elif path.suffix in {'.html', '.ejs'}:
|
|
63
|
+
documents = UnstructuredHTMLLoader(path).load()
|
|
64
|
+
else:
|
|
65
|
+
print(f"Unsupported file type: {path}")
|
|
66
|
+
return None, 0, 0
|
|
67
|
+
success_cnt = 1
|
|
68
|
+
page_cnt = len(documents)
|
|
69
|
+
docs.extend(documents)
|
|
70
|
+
else:
|
|
71
|
+
print(f"Error: '{path}' is not a valid file or directory")
|
|
72
|
+
return None, 0, 0
|
|
73
|
+
|
|
74
|
+
if success_cnt == 0:
|
|
75
|
+
print("No valid documents found!")
|
|
76
|
+
return None, 0, 0
|
|
77
|
+
|
|
78
|
+
return docs, success_cnt, page_cnt
|
|
79
|
+
|
|
80
|
+
#%% MultiQueryRerankRetriever
|
|
81
|
+
|
|
82
|
+
class MultiQueryRerankRetriever(BaseRetriever):
|
|
83
|
+
"""Chroma retriever with optional multi-query retrieval and reranking."""
|
|
84
|
+
knowledge_base: str
|
|
85
|
+
embeddings: Embeddings = Field(exclude=True)
|
|
86
|
+
query_llm: BaseChatModel | None = Field(default=None, exclude=True)
|
|
87
|
+
reranker: Any = Field(default=None, exclude=True)
|
|
88
|
+
k: int = Field(default=3, ge=1)
|
|
89
|
+
top_n: int = Field(default=5, ge=1)
|
|
90
|
+
vstore_path: str = Field( default_factory=lambda: f"{os.path.expanduser('~')}/vstore" )
|
|
91
|
+
_base_retriever: BaseRetriever = PrivateAttr()
|
|
92
|
+
|
|
93
|
+
def model_post_init(self, __context: Any) -> None:
|
|
94
|
+
"""Initialize the underlying Chroma retriever."""
|
|
95
|
+
store = Chroma( persist_directory=( f"{self.vstore_path}/{self.knowledge_base}/BAAI_db/chroma_vector_store"), embedding_function=self.embeddings )
|
|
96
|
+
self._base_retriever = store.as_retriever( search_kwargs={"k": self.k} )
|
|
97
|
+
if self.query_llm is not None and self.reranker is None:
|
|
98
|
+
raise ValueError( "A reranker is required when query_llm is provided." )
|
|
99
|
+
# run_manager manages callbacks, tracing, and monitoring during a retriever's execution, allowing integrations with LangSmith
|
|
100
|
+
def _generate_queries( self, query: str, run_manager: CallbackManagerForRetrieverRun ) -> list[str]:
|
|
101
|
+
"""Generate five alternative formulations of the query."""
|
|
102
|
+
prompt = ChatPromptTemplate.from_template( "You are an AI language model assistant. Generate 5 different versions of the following question. Return exactly 5 alternative questions, one per line. Do not include explanations or numbering. Original question: {question}" )
|
|
103
|
+
chain = prompt | self.query_llm | StrOutputParser()
|
|
104
|
+
result = chain.invoke( {"question": query}, config={"callbacks": run_manager.get_child()} )
|
|
105
|
+
return [q.strip() for q in result.splitlines() if q.strip()]
|
|
106
|
+
|
|
107
|
+
@staticmethod
|
|
108
|
+
def _unique_union( document_lists: list[list[Document]] ) -> list[Document]:
|
|
109
|
+
"""Flatten document lists and remove duplicates."""
|
|
110
|
+
unique_docs = {
|
|
111
|
+
doc.page_content: doc
|
|
112
|
+
for docs in document_lists
|
|
113
|
+
for doc in docs
|
|
114
|
+
}
|
|
115
|
+
return list(unique_docs.values())
|
|
116
|
+
|
|
117
|
+
def _rerank( self, query: str, docs: list[Document] ) -> list[Document]:
|
|
118
|
+
"""Rerank retrieved documents using a cross-encoder."""
|
|
119
|
+
if not docs:
|
|
120
|
+
return []
|
|
121
|
+
pairs = [(query, doc.page_content) for doc in docs]
|
|
122
|
+
scores = self.reranker.predict(pairs)
|
|
123
|
+
scored_docs = sorted(
|
|
124
|
+
zip(scores, docs),
|
|
125
|
+
key=lambda x: float(x[0]),
|
|
126
|
+
reverse=True,
|
|
127
|
+
)
|
|
128
|
+
return [doc for _, doc in scored_docs[:self.top_n]]
|
|
129
|
+
|
|
130
|
+
def _get_relevant_documents( self, query: str, *, run_manager: CallbackManagerForRetrieverRun ) -> list[Document]:
|
|
131
|
+
"""Retrieve documents using simple or multi-query retrieval."""
|
|
132
|
+
# Simple retrieval
|
|
133
|
+
if self.query_llm is None:
|
|
134
|
+
return self._base_retriever.invoke( query, config={"callbacks": run_manager.get_child()} )
|
|
135
|
+
# Generate alternative queries
|
|
136
|
+
alt_queries = self._generate_queries(query, run_manager)
|
|
137
|
+
all_queries = list( dict.fromkeys([query, *alt_queries]) ) # standard, most efficient way to remove duplicates from a list (or any iterable) while preserving the original insertion order
|
|
138
|
+
# Retrieve documents for all queries
|
|
139
|
+
doc_lists = self._base_retriever.batch(
|
|
140
|
+
all_queries,
|
|
141
|
+
config={"callbacks": run_manager.get_child()},
|
|
142
|
+
)
|
|
143
|
+
# Deduplicate and rerank
|
|
144
|
+
unique_docs = self._unique_union(doc_lists)
|
|
145
|
+
return self._rerank(query, unique_docs)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
#%% Hypothetical Question Retriever
|
|
149
|
+
|
|
150
|
+
def _stable_id(source_id: str, index: int, text: str) -> str:
|
|
151
|
+
digest = hashlib.sha256(f"{source_id}\0{index}\0{text}".encode()).hexdigest()
|
|
152
|
+
return f"chunk-{digest[:32]}"
|
|
153
|
+
|
|
154
|
+
def _clean_questions(questions: Sequence[str]) -> list[str]:
|
|
155
|
+
clean: list[str] = []
|
|
156
|
+
seen: set[str] = set()
|
|
157
|
+
for value in questions:
|
|
158
|
+
question = " ".join(str(value).split()).strip()
|
|
159
|
+
key = question.casefold()
|
|
160
|
+
if question and key not in seen:
|
|
161
|
+
seen.add(key)
|
|
162
|
+
clean.append(question)
|
|
163
|
+
return clean
|
|
164
|
+
|
|
165
|
+
def _chroma_metadata(metadata: dict[str, Any]) -> dict[str, Any]:
|
|
166
|
+
"""Keep Chroma-compatible scalar metadata and drop None/compound values."""
|
|
167
|
+
return {
|
|
168
|
+
str(key): value for key, value in metadata.items()
|
|
169
|
+
if value is not None and isinstance(value, (str, int, float, bool))
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
class QuestionGenerator(Protocol):
|
|
173
|
+
"""Anything capable of generating search questions for a chunk."""
|
|
174
|
+
def generate(self, document: Document) -> list[str]: ...
|
|
175
|
+
|
|
176
|
+
class _QuestionList(BaseModel):
|
|
177
|
+
questions: list[str] = Field(description="Distinct questions answered by the text")
|
|
178
|
+
|
|
179
|
+
class HypotheticalQuestionGenerator:
|
|
180
|
+
"""LLM-backed question generator using provider-supported structured output."""
|
|
181
|
+
|
|
182
|
+
def __init__(self, model: BaseChatModel, *, questions_per_chunk: int = 4) -> None:
|
|
183
|
+
if questions_per_chunk < 1:
|
|
184
|
+
raise ValueError("questions_per_chunk must be positive")
|
|
185
|
+
self.questions_per_chunk = questions_per_chunk
|
|
186
|
+
prompt = ChatPromptTemplate.from_messages(
|
|
187
|
+
[
|
|
188
|
+
( "system", "Generate exactly {count} distinct, natural questions that can be answered from the supplied text. Do not answer them and do not use outside knowledge."),
|
|
189
|
+
("human", "Text:\n{text}"),
|
|
190
|
+
]
|
|
191
|
+
)
|
|
192
|
+
self._chain = prompt | model.with_structured_output(_QuestionList)
|
|
193
|
+
|
|
194
|
+
def generate(self, document: Document) -> list[str]:
|
|
195
|
+
result = self._chain.invoke( {"count": self.questions_per_chunk, "text": document.page_content} )
|
|
196
|
+
questions = result.questions if isinstance(result, _QuestionList) else result["questions"]
|
|
197
|
+
return _clean_questions(questions)[: self.questions_per_chunk]
|
|
198
|
+
|
|
199
|
+
@dataclass(frozen=True)
|
|
200
|
+
class IngestionResult:
|
|
201
|
+
chunks: int
|
|
202
|
+
hypothetical_questions: int
|
|
203
|
+
chunk_ids: tuple[str, ...]
|
|
204
|
+
|
|
205
|
+
class HypotheticalQuestionRetriever(BaseRetriever):
|
|
206
|
+
"""Search question vectors, then resolve their chunk IDs from the chunk collection."""
|
|
207
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
208
|
+
question_store: Chroma
|
|
209
|
+
chunk_collection: Any
|
|
210
|
+
generator: Any
|
|
211
|
+
question_k: int = 12
|
|
212
|
+
chunk_k: int = 4
|
|
213
|
+
metadata_filter: dict[str, Any] | None = None
|
|
214
|
+
|
|
215
|
+
def __init__(self, *, persist_directory: str | Path, embeddings: Embeddings, generator: QuestionGenerator, question_collection: str = "hypothetical_questions", chunk_collection: str = "original_chunks", question_k: int = 12, chunk_k: int = 4, metadata_filter: dict[str, Any] | None = None, **kwargs: Any) -> None:
|
|
216
|
+
client = chromadb.PersistentClient(path=str(persist_directory))
|
|
217
|
+
question_store = Chroma( client=client, collection_name=question_collection, embedding_function=embeddings )
|
|
218
|
+
chunks = client.get_or_create_collection( name=chunk_collection, embedding_function=None )
|
|
219
|
+
super().__init__(
|
|
220
|
+
question_store=question_store,
|
|
221
|
+
chunk_collection=chunks,
|
|
222
|
+
generator=generator,
|
|
223
|
+
question_k=question_k,
|
|
224
|
+
chunk_k=chunk_k,
|
|
225
|
+
metadata_filter=metadata_filter,
|
|
226
|
+
**kwargs,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
def ingest( self, documents: Sequence[Document], *, source_id: str, split: bool = True, chunk_size: int = 1000, chunk_overlap: int = 150, replace_source: bool = True ) -> IngestionResult:
|
|
230
|
+
if not source_id: # source_id is a unique identifier for the source document or documents being ingested
|
|
231
|
+
raise ValueError("source_id must not be empty")
|
|
232
|
+
splitter = RecursiveCharacterTextSplitter( chunk_size=chunk_size, chunk_overlap=chunk_overlap, add_start_index=True )
|
|
233
|
+
input_docs = splitter.split_documents( list(documents) ) if split else list(documents)
|
|
234
|
+
|
|
235
|
+
if replace_source:
|
|
236
|
+
self.question_store.delete(where={"source_id": source_id})
|
|
237
|
+
self.chunk_collection.delete(where={"source_id": source_id})
|
|
238
|
+
|
|
239
|
+
chunk_ids: list[str] = []
|
|
240
|
+
question_docs: list[Document] = []
|
|
241
|
+
question_ids: list[str] = []
|
|
242
|
+
chunk_metadatas: list[dict[str, Any]] = []
|
|
243
|
+
chunk_texts: list[str] = []
|
|
244
|
+
|
|
245
|
+
for index, doc in enumerate(input_docs):
|
|
246
|
+
chunk_id = _stable_id(source_id, index, doc.page_content)
|
|
247
|
+
metadata = _chroma_metadata( {**doc.metadata, "source_id": source_id, "chunk_id": chunk_id} )
|
|
248
|
+
chunk_ids.append( chunk_id )
|
|
249
|
+
chunk_metadatas.append( metadata )
|
|
250
|
+
chunk_texts.append( doc.page_content )
|
|
251
|
+
|
|
252
|
+
questions = _clean_questions( self.generator.generate(doc) )
|
|
253
|
+
for question_index, question in enumerate(questions):
|
|
254
|
+
question_ids.append( f"{chunk_id}:q:{question_index}" )
|
|
255
|
+
question_docs.append(
|
|
256
|
+
Document( page_content=question,
|
|
257
|
+
metadata={"chunk_id": chunk_id, "source_id": source_id, "question_index": question_index },
|
|
258
|
+
)
|
|
259
|
+
)
|
|
260
|
+
if question_docs: # LangChain's Chroma.add_documents() triggers embedding
|
|
261
|
+
self.question_store.add_documents( question_docs, ids=question_ids )
|
|
262
|
+
|
|
263
|
+
if chunk_ids: # Chroma's underlying collection API stores text and metadata without embeddings.
|
|
264
|
+
self.chunk_collection.upsert( ids=chunk_ids, documents=chunk_texts, metadatas=chunk_metadatas,
|
|
265
|
+
embeddings=[[0.0] for _ in chunk_ids] ) # placeholder vectors since Chroma records require vectors
|
|
266
|
+
|
|
267
|
+
return IngestionResult(len(chunk_ids), len(question_docs), tuple(chunk_ids))
|
|
268
|
+
|
|
269
|
+
# override _get_relevant_documents() method from BaseRetriever
|
|
270
|
+
def _get_relevant_documents(self, query: str, *, run_manager: Any = None) -> list[Document]:
|
|
271
|
+
matches = self.question_store.similarity_search( query, k=self.question_k, filter=self.metadata_filter )
|
|
272
|
+
chunk_ids: list[str] = []
|
|
273
|
+
seen: set[str] = set()
|
|
274
|
+
for m in matches:
|
|
275
|
+
chunk_id = str(m.metadata["chunk_id"])
|
|
276
|
+
if chunk_id not in seen:
|
|
277
|
+
seen.add(chunk_id)
|
|
278
|
+
chunk_ids.append(chunk_id)
|
|
279
|
+
if len(chunk_ids) == self.chunk_k:
|
|
280
|
+
break
|
|
281
|
+
if not chunk_ids:
|
|
282
|
+
return []
|
|
283
|
+
|
|
284
|
+
records = self.chunk_collection.get(ids=chunk_ids, include=["documents", "metadatas"])
|
|
285
|
+
by_id = {
|
|
286
|
+
record_id: Document(page_content=text, metadata=metadata or {})
|
|
287
|
+
for record_id, text, metadata in zip(
|
|
288
|
+
records["ids"], records["documents"], records["metadatas"], strict=True
|
|
289
|
+
)
|
|
290
|
+
}
|
|
291
|
+
return [by_id[chunk_id] for chunk_id in chunk_ids if chunk_id in by_id]
|
|
292
|
+
|
|
293
|
+
#%% RAG Chains
|
|
294
|
+
|
|
295
|
+
system_prompt = """You are a helpful assistant answering questions based on provided document snippets.
|
|
296
|
+
IMPORTANT INSTRUCTIONS:
|
|
297
|
+
1. Base your answer ONLY on the provided context snippets below.
|
|
298
|
+
2. Synthesize information from multiple snippets if needed.
|
|
299
|
+
3. If the snippets contain partial information, provide the best answer you can with what's available.
|
|
300
|
+
4. Only say "There wasn't enough information to answer the question" if the snippets truly contain NO relevant information.
|
|
301
|
+
5. Always cite your sources using the snippet numbers in parentheses, like (1), (2), etc.
|
|
302
|
+
|
|
303
|
+
The snippets are provided in this format:
|
|
304
|
+
Snippet 1: <text content>
|
|
305
|
+
Snippet 2: <text content>
|
|
306
|
+
...
|
|
307
|
+
|
|
308
|
+
{context}"""
|
|
309
|
+
|
|
310
|
+
def format_documents(documents: Sequence[Document]) -> str:
|
|
311
|
+
snippets = []
|
|
312
|
+
for i, doc in enumerate(documents, start=1):
|
|
313
|
+
snippets.append(f"Snippet {i}: {doc.page_content}")
|
|
314
|
+
context = "\n".join(snippets)
|
|
315
|
+
return context
|
|
316
|
+
|
|
317
|
+
def rag_basic(retriever: BaseRetriever, model: BaseChatModel) -> Runnable:
|
|
318
|
+
"""Return an LCEL chain accepting a question string and producing an answer string."""
|
|
319
|
+
prompt = ChatPromptTemplate.from_messages(
|
|
320
|
+
[
|
|
321
|
+
( "system", "Answer using only the context below. If the answer is absent, say you do not know.\n\nContext:\n{context}" ),
|
|
322
|
+
("human", "{query}"),
|
|
323
|
+
]
|
|
324
|
+
)
|
|
325
|
+
inputs = {
|
|
326
|
+
"context": retriever | format_documents,
|
|
327
|
+
"query": RunnablePassthrough(),
|
|
328
|
+
}
|
|
329
|
+
return inputs | prompt | model | StrOutputParser()
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def rag_main(retriever: BaseRetriever, model: BaseChatModel) -> Runnable:
|
|
333
|
+
"""Return an LCEL chain accepting a question string and producing an dic with 4 keys/values
|
|
334
|
+
-- 'sources': original list of Documents from retriever
|
|
335
|
+
-- 'query': original query
|
|
336
|
+
-- 'context': merged content from relevant documents
|
|
337
|
+
-- 'answer': final answer """
|
|
338
|
+
prompt = ChatPromptTemplate.from_messages([
|
|
339
|
+
("system", system_prompt),
|
|
340
|
+
("human", "{query}")
|
|
341
|
+
])
|
|
342
|
+
return (
|
|
343
|
+
{
|
|
344
|
+
"sources": retriever,
|
|
345
|
+
"query": RunnablePassthrough(),
|
|
346
|
+
}
|
|
347
|
+
| RunnablePassthrough.assign( context=lambda x: format_documents(x["sources"]) )
|
|
348
|
+
| RunnablePassthrough.assign( answer=prompt | model | StrOutputParser() )
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
build_rag = rag_main
|
|
352
|
+
|
|
353
|
+
#%% RAG with Routing
|
|
354
|
+
|
|
355
|
+
domain_prompt = """You are a helpful assistant answering questions based on provided document snippets relating to {domain}.
|
|
356
|
+
|
|
357
|
+
IMPORTANT INSTRUCTIONS:
|
|
358
|
+
1. Base your answer ONLY on the provided context snippets below.
|
|
359
|
+
2. Synthesize information from multiple snippets if needed.
|
|
360
|
+
3. If the snippets contain partial information, provide the best answer you can with what's available.
|
|
361
|
+
4. Only say "There wasn't enough information to answer the question" if the snippets truly contain NO relevant information.
|
|
362
|
+
5. Always cite your sources using the snippet numbers in parentheses, like (1), (2), etc.
|
|
363
|
+
|
|
364
|
+
The snippets are provided in this format:
|
|
365
|
+
Snippet 1: <text content>
|
|
366
|
+
Snippet 2: <text content>
|
|
367
|
+
...
|
|
368
|
+
|
|
369
|
+
{context}"""
|
|
370
|
+
|
|
371
|
+
def multiRagBuilder( domain_literals, router_prompt, llm, embeddings, reranker, query_llm=None, k=3):
|
|
372
|
+
"""
|
|
373
|
+
domain_literals: the last one is a placeholder if the router finds no domain matches the query
|
|
374
|
+
"""
|
|
375
|
+
domains = get_args(domain_literals)[:-1] # exclude the placeholder
|
|
376
|
+
nodomain = get_args(domain_literals)[-1]
|
|
377
|
+
# use the functional form of TypedDict instead of the class form to accommodate Python 3.14
|
|
378
|
+
State = TypedDict(
|
|
379
|
+
"State",
|
|
380
|
+
{
|
|
381
|
+
"messages": Annotated[list, add_messages],
|
|
382
|
+
"query": str,
|
|
383
|
+
"domain": domain_literals,
|
|
384
|
+
"documents": list[Document],
|
|
385
|
+
"answer": str,
|
|
386
|
+
}
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
class Input(TypedDict):
|
|
390
|
+
query: str
|
|
391
|
+
|
|
392
|
+
Output = TypedDict(
|
|
393
|
+
"Output",
|
|
394
|
+
{
|
|
395
|
+
"domain": domain_literals,
|
|
396
|
+
"documents": list[Document],
|
|
397
|
+
"answer": str
|
|
398
|
+
}
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
def router_node(state) -> State:
|
|
402
|
+
user_message = HumanMessage(state["query"])
|
|
403
|
+
messages = [ SystemMessage(router_prompt), *state["messages"], user_message ]
|
|
404
|
+
res = llm.invoke(messages)
|
|
405
|
+
return {
|
|
406
|
+
"domain": res.content.strip(),
|
|
407
|
+
"messages": [user_message, res], # update conversation history
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
def pick_retriever( state ):
|
|
411
|
+
for domain in domains:
|
|
412
|
+
if re.search( domain, state['domain'] ):
|
|
413
|
+
return domain
|
|
414
|
+
return 'generate_answer'
|
|
415
|
+
|
|
416
|
+
def generate_answer(state) -> State:
|
|
417
|
+
snippets = []
|
|
418
|
+
for i, doc in enumerate(state.get('documents', []), start=1):
|
|
419
|
+
snippets.append(f"Snippet {i}: {doc.page_content}")
|
|
420
|
+
context = "\n".join(snippets)
|
|
421
|
+
if state.get('domain', '')!=nodomain:
|
|
422
|
+
prompt = ChatPromptTemplate.from_messages( [ ("system", domain_prompt), ("human", "{query}") ])
|
|
423
|
+
messages = prompt.format_messages( domain=state.get('domain', ''), context=context, query=state["query"] )
|
|
424
|
+
else:
|
|
425
|
+
prompt = ChatPromptTemplate.from_messages( [ ("system", 'You are a helpful assistant.'), ("human", "{query}") ])
|
|
426
|
+
messages = prompt.format_messages( query=state["query"] )
|
|
427
|
+
res = llm.invoke(messages)
|
|
428
|
+
return {
|
|
429
|
+
"answer": res.content,
|
|
430
|
+
"messages": res, # update conversation history
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
def retriever_node( domain, state):
|
|
434
|
+
query = re.sub(domain, 'this class', state["query"] ) # using the course code hurts RAG since the course code is rarely used in documents.
|
|
435
|
+
#print( f"domain={domain}; query={query}")
|
|
436
|
+
documents = retrievers[domain].invoke( query )
|
|
437
|
+
return { "documents": documents, 'query': query }
|
|
438
|
+
|
|
439
|
+
builder = StateGraph(State, input_schema=Input, output_schema=Output)
|
|
440
|
+
builder.add_node("router", router_node)
|
|
441
|
+
builder.add_edge(START, "router")
|
|
442
|
+
builder.add_conditional_edges("router", pick_retriever)
|
|
443
|
+
|
|
444
|
+
retrievers = {}
|
|
445
|
+
retriever_nodes = {}
|
|
446
|
+
for domain in domains:
|
|
447
|
+
retrievers[domain] = MultiQueryRerankRetriever(knowledge_base=domain, embeddings=embeddings, query_llm=query_llm, reranker=reranker, k=k)
|
|
448
|
+
retriever_nodes[domain] = partial( retriever_node, domain ) # the 1st argument is the partial function. the rest are frozen arguments passed positionally to fill the leftmost parameters of the original function.
|
|
449
|
+
builder.add_node(domain, retriever_nodes[domain])
|
|
450
|
+
builder.add_edge(domain, "generate_answer")
|
|
451
|
+
|
|
452
|
+
builder.add_node("generate_answer", generate_answer)
|
|
453
|
+
builder.add_edge("generate_answer", END)
|
|
454
|
+
|
|
455
|
+
return builder
|
|
@@ -1,258 +0,0 @@
|
|
|
1
|
-
# module dependencies: torch torchvision langchain_core langchain langgraph langchain_community langchain_chroma langchain_huggingface sentence_transformers
|
|
2
|
-
# additional packages for class projects: langchain_ollama langchain_text_splitters unstructured streamlit_javascript
|
|
3
|
-
|
|
4
|
-
import os
|
|
5
|
-
import re
|
|
6
|
-
from typing import Annotated, Literal, TypedDict, get_args
|
|
7
|
-
from pathlib import Path
|
|
8
|
-
|
|
9
|
-
from functools import partial
|
|
10
|
-
|
|
11
|
-
import torch
|
|
12
|
-
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
|
13
|
-
|
|
14
|
-
from langchain_core.documents import Document
|
|
15
|
-
from langchain_core.messages import HumanMessage, SystemMessage
|
|
16
|
-
from langchain_core.prompts import ChatPromptTemplate
|
|
17
|
-
from langchain_core.runnables import RunnableLambda
|
|
18
|
-
|
|
19
|
-
from langgraph.graph import END, START, StateGraph
|
|
20
|
-
from langgraph.graph.message import add_messages
|
|
21
|
-
|
|
22
|
-
from langchain_chroma import Chroma
|
|
23
|
-
from langchain_huggingface import HuggingFaceEmbeddings
|
|
24
|
-
from langchain_community.document_loaders import PyPDFLoader, UnstructuredHTMLLoader
|
|
25
|
-
from sentence_transformers import CrossEncoder
|
|
26
|
-
|
|
27
|
-
embedding_model = HuggingFaceEmbeddings(
|
|
28
|
-
model_name="BAAI/bge-large-en-v1.5",
|
|
29
|
-
model_kwargs={'device': device},
|
|
30
|
-
encode_kwargs={'normalize_embeddings': False}
|
|
31
|
-
)
|
|
32
|
-
|
|
33
|
-
#%%
|
|
34
|
-
|
|
35
|
-
def load_documents(path: Path):
|
|
36
|
-
"""Load documents from a file or directory."""
|
|
37
|
-
|
|
38
|
-
valid_extensions = {'.pdf', '.html', '.ejs'}
|
|
39
|
-
|
|
40
|
-
docs = []
|
|
41
|
-
success_cnt = 0
|
|
42
|
-
page_cnt = 0
|
|
43
|
-
|
|
44
|
-
if path.exists():
|
|
45
|
-
if path.is_dir():
|
|
46
|
-
for file in [ item for item in path.rglob('*') if item.is_file() and item.suffix in valid_extensions ]:
|
|
47
|
-
print(file.name)
|
|
48
|
-
if file.suffix == '.pdf':
|
|
49
|
-
documents = PyPDFLoader(file).load()
|
|
50
|
-
elif file.suffix in {'.html', '.ejs'}:
|
|
51
|
-
documents = UnstructuredHTMLLoader(file).load()
|
|
52
|
-
else:
|
|
53
|
-
continue
|
|
54
|
-
success_cnt += 1
|
|
55
|
-
page_cnt += len(documents)
|
|
56
|
-
docs.extend(documents)
|
|
57
|
-
else:
|
|
58
|
-
print(path.name)
|
|
59
|
-
if path.suffix == '.pdf':
|
|
60
|
-
documents = PyPDFLoader(path).load()
|
|
61
|
-
elif path.suffix in {'.html', '.ejs'}:
|
|
62
|
-
documents = UnstructuredHTMLLoader(path).load()
|
|
63
|
-
else:
|
|
64
|
-
print(f"Unsupported file type: {path}")
|
|
65
|
-
return None, 0, 0
|
|
66
|
-
success_cnt = 1
|
|
67
|
-
page_cnt = len(documents)
|
|
68
|
-
docs.extend(documents)
|
|
69
|
-
else:
|
|
70
|
-
print(f"Error: '{path}' is not a valid file or directory")
|
|
71
|
-
return None, 0, 0
|
|
72
|
-
|
|
73
|
-
if success_cnt == 0:
|
|
74
|
-
print("No valid documents found!")
|
|
75
|
-
return None, 0, 0
|
|
76
|
-
|
|
77
|
-
return docs, success_cnt, page_cnt
|
|
78
|
-
|
|
79
|
-
reranker_model = CrossEncoder("BAAI/bge-reranker-large")
|
|
80
|
-
def _getRetriever( knowledge_base, query_llm=None, k=3, vstore_path = f'{os.getenv("HOME")}/vstore' ):
|
|
81
|
-
store = Chroma( persist_directory=f'{vstore_path}/{knowledge_base}/BAAI_db/chroma_vector_store', embedding_function=embedding_model )
|
|
82
|
-
base_retriever = store.as_retriever(search_kwargs={"k": k}) # a vector store becomes a retriever through the as_retriever() method
|
|
83
|
-
if query_llm==None:
|
|
84
|
-
return base_retriever
|
|
85
|
-
|
|
86
|
-
perspectives_prompt = ChatPromptTemplate.from_template(
|
|
87
|
-
"""You are an AI language model assistant. Your task is to generate 5 different versions of the given original question
|
|
88
|
-
These should be in 5 lines and provide ONLY these alternative questions separated by newlines, no explaination is needed.
|
|
89
|
-
Original question: {question}""")
|
|
90
|
-
def parse_queries_output(message):
|
|
91
|
-
#print( message.content )
|
|
92
|
-
return message.content.split('\n')
|
|
93
|
-
|
|
94
|
-
query_gen = perspectives_prompt | query_llm | parse_queries_output
|
|
95
|
-
|
|
96
|
-
def get_unique_union(document_lists): # Flatten list of lists, and deduplicate them
|
|
97
|
-
deduped_docs = { doc.page_content: doc for sublist in document_lists for doc in sublist}
|
|
98
|
-
unique_docs = list(deduped_docs.values()) # a flat list of unique docs
|
|
99
|
-
return unique_docs
|
|
100
|
-
|
|
101
|
-
def rerank_docs(query, docs, top_n=3):
|
|
102
|
-
"""Rerank documents using cross-encoder and return top_n."""
|
|
103
|
-
if not docs:
|
|
104
|
-
return []
|
|
105
|
-
pairs = [(query, doc.page_content) for doc in docs]
|
|
106
|
-
scores = reranker_model.predict(pairs)
|
|
107
|
-
scored_docs = sorted(zip(scores, docs), key=lambda x: float(x[0]), reverse=True)
|
|
108
|
-
return [doc for _, doc in scored_docs[:top_n]]
|
|
109
|
-
|
|
110
|
-
def retrieve_and_rerank(query):
|
|
111
|
-
# Generate alternative queries + include original
|
|
112
|
-
alt_queries = query_gen.invoke({"question": query})
|
|
113
|
-
all_queries = [query] + alt_queries
|
|
114
|
-
# Retrieve chunks for each query
|
|
115
|
-
doc_lists = base_retriever.batch(all_queries)
|
|
116
|
-
unique_docs = get_unique_union(doc_lists)
|
|
117
|
-
# Rerank with cross-encoder and return top 3
|
|
118
|
-
return rerank_docs(query, unique_docs, top_n=5)
|
|
119
|
-
|
|
120
|
-
return RunnableLambda(retrieve_and_rerank)
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
"""
|
|
124
|
-
RAG Chain
|
|
125
|
-
"""
|
|
126
|
-
|
|
127
|
-
def getRagModel(knowledge_base, llm, query_llm=None, k=3):
|
|
128
|
-
"""
|
|
129
|
-
Create and return a RAG model that returns a dictionary with 'result' and 'source_documents'.
|
|
130
|
-
"""
|
|
131
|
-
retriever = _getRetriever(knowledge_base, query_llm, k)
|
|
132
|
-
|
|
133
|
-
def _rag_chain(input_dict):
|
|
134
|
-
query = input_dict["query"]
|
|
135
|
-
docs = retriever.invoke(query)
|
|
136
|
-
snippets = []
|
|
137
|
-
for i, doc in enumerate(docs, start=1):
|
|
138
|
-
snippets.append(f"Snippet {i}: {doc.page_content}")
|
|
139
|
-
context = "\n".join(snippets)
|
|
140
|
-
system_prompt = """You are a helpful assistant answering questions based on provided document snippets.
|
|
141
|
-
IMPORTANT INSTRUCTIONS:
|
|
142
|
-
1. Base your answer ONLY on the provided context snippets below.
|
|
143
|
-
2. Synthesize information from multiple snippets if needed.
|
|
144
|
-
3. If the snippets contain partial information, provide the best answer you can with what's available.
|
|
145
|
-
4. Only say "There wasn't enough information to answer the question" if the snippets truly contain NO relevant information.
|
|
146
|
-
5. Always cite your sources using the snippet numbers in parentheses, like (1), (2), etc.
|
|
147
|
-
|
|
148
|
-
The snippets are provided in this format:
|
|
149
|
-
Snippet 1: <text content>
|
|
150
|
-
Snippet 2: <text content>
|
|
151
|
-
...
|
|
152
|
-
|
|
153
|
-
{context}"""
|
|
154
|
-
prompt = ChatPromptTemplate.from_messages( [ ("system", system_prompt), ("human", "{query}") ])
|
|
155
|
-
messages = prompt.format_messages( context=context, query=query )
|
|
156
|
-
answer = llm.invoke(messages).content
|
|
157
|
-
return {"answer": answer, "sources": docs}
|
|
158
|
-
|
|
159
|
-
return RunnableLambda(_rag_chain)
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
"""
|
|
163
|
-
multi-rag system with router
|
|
164
|
-
"""
|
|
165
|
-
|
|
166
|
-
domain_prompt = """You are a helpful assistant answering questions based on provided document snippets relating to {domain}.
|
|
167
|
-
|
|
168
|
-
IMPORTANT INSTRUCTIONS:
|
|
169
|
-
1. Base your answer ONLY on the provided context snippets below.
|
|
170
|
-
2. Synthesize information from multiple snippets if needed.
|
|
171
|
-
3. If the snippets contain partial information, provide the best answer you can with what's available.
|
|
172
|
-
4. Only say "There wasn't enough information to answer the question" if the snippets truly contain NO relevant information.
|
|
173
|
-
5. Always cite your sources using the snippet numbers in parentheses, like (1), (2), etc.
|
|
174
|
-
|
|
175
|
-
The snippets are provided in this format:
|
|
176
|
-
Snippet 1: <text content>
|
|
177
|
-
Snippet 2: <text content>
|
|
178
|
-
...
|
|
179
|
-
|
|
180
|
-
{context}"""
|
|
181
|
-
|
|
182
|
-
def multiRagBuilder( domain_literals, router_prompt, chat_router, chat, query_llm=None, k=3):
|
|
183
|
-
"""
|
|
184
|
-
domain_literals: the last one is a placeholder if the router finds no domain matches the query
|
|
185
|
-
"""
|
|
186
|
-
domains = get_args(domain_literals)[:-1] # exclude the placeholder
|
|
187
|
-
nodomain = get_args(domain_literals)[-1]
|
|
188
|
-
|
|
189
|
-
class State(TypedDict):
|
|
190
|
-
messages: Annotated[list, add_messages] # annotated with the reducer function to append new messages to the existing list, rather than overwriting
|
|
191
|
-
query: str # input
|
|
192
|
-
domain: domain_literals # output
|
|
193
|
-
documents: list[Document]
|
|
194
|
-
answer: str
|
|
195
|
-
|
|
196
|
-
class Input(TypedDict):
|
|
197
|
-
query: str
|
|
198
|
-
|
|
199
|
-
class Output(TypedDict):
|
|
200
|
-
domain: domain_literals
|
|
201
|
-
documents: list[Document]
|
|
202
|
-
answer: str
|
|
203
|
-
|
|
204
|
-
def router_node(state: State) -> State:
|
|
205
|
-
user_message = HumanMessage(state["query"])
|
|
206
|
-
messages = [ SystemMessage(router_prompt), *state["messages"], user_message ]
|
|
207
|
-
res = chat_router.invoke(messages)
|
|
208
|
-
return {
|
|
209
|
-
"domain": res.content.strip(),
|
|
210
|
-
"messages": [user_message, res], # update conversation history
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
def pick_retriever( state: State) -> Literal[*domains, "generate_answer"]:
|
|
214
|
-
for domain in domains:
|
|
215
|
-
if re.search( domain, state['domain'] ):
|
|
216
|
-
return domain
|
|
217
|
-
return 'generate_answer'
|
|
218
|
-
|
|
219
|
-
def generate_answer(state: State) -> State:
|
|
220
|
-
snippets = []
|
|
221
|
-
for i, doc in enumerate(state.get('documents', []), start=1):
|
|
222
|
-
snippets.append(f"Snippet {i}: {doc.page_content}")
|
|
223
|
-
context = "\n".join(snippets)
|
|
224
|
-
if state.get('domain', '')!=nodomain:
|
|
225
|
-
prompt = ChatPromptTemplate.from_messages( [ ("system", domain_prompt), ("human", "{query}") ])
|
|
226
|
-
messages = prompt.format_messages( domain=state.get('domain', ''), context=context, query=state["query"] )
|
|
227
|
-
else:
|
|
228
|
-
prompt = ChatPromptTemplate.from_messages( [ ("system", 'You are a helpful assistant.'), ("human", "{query}") ])
|
|
229
|
-
messages = prompt.format_messages( query=state["query"] )
|
|
230
|
-
res = chat.invoke(messages)
|
|
231
|
-
return {
|
|
232
|
-
"answer": res.content,
|
|
233
|
-
"messages": res, # update conversation history
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
def retriever_node( domain: domains, state: State):
|
|
237
|
-
query = re.sub(domain, 'this class', state["query"] ) # using the course code hurts RAG since the course code is rarely used in documents.
|
|
238
|
-
#print( f"domain={domain}; query={query}")
|
|
239
|
-
documents = retrievers[domain].invoke( query )
|
|
240
|
-
return { "documents": documents, 'query': query }
|
|
241
|
-
|
|
242
|
-
builder = StateGraph(State, input_schema=Input, output_schema=Output)
|
|
243
|
-
builder.add_node("router", router_node)
|
|
244
|
-
builder.add_edge(START, "router")
|
|
245
|
-
builder.add_conditional_edges("router", pick_retriever)
|
|
246
|
-
|
|
247
|
-
retrievers = {}
|
|
248
|
-
retriever_nodes = {}
|
|
249
|
-
for domain in domains:
|
|
250
|
-
retrievers[domain] = _getRetriever(domain, query_llm, k)
|
|
251
|
-
retriever_nodes[domain] = partial( retriever_node, domain ) # the 1st argument is the partial function. the rest are frozen arguments passed positionally to fill the leftmost parameters of the original function.
|
|
252
|
-
builder.add_node(domain, retriever_nodes[domain])
|
|
253
|
-
builder.add_edge(domain, "generate_answer")
|
|
254
|
-
|
|
255
|
-
builder.add_node("generate_answer", generate_answer)
|
|
256
|
-
builder.add_edge("generate_answer", END)
|
|
257
|
-
|
|
258
|
-
return builder
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|