docuquery 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.
- docuquery/__init__.py +3 -0
- docuquery/chunker.py +87 -0
- docuquery/constants.py +67 -0
- docuquery/database.py +385 -0
- docuquery/ingest.py +52 -0
- docuquery/main.py +157 -0
- docuquery/rag.py +279 -0
- docuquery/tools.py +118 -0
- docuquery/utils.py +121 -0
- docuquery-0.1.0.dist-info/METADATA +392 -0
- docuquery-0.1.0.dist-info/RECORD +14 -0
- docuquery-0.1.0.dist-info/WHEEL +4 -0
- docuquery-0.1.0.dist-info/entry_points.txt +3 -0
- docuquery-0.1.0.dist-info/licenses/LICENSE +21 -0
docuquery/main.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import uuid
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@click.group()
|
|
9
|
+
@click.version_option(package_name="docuquery")
|
|
10
|
+
def cli() -> None:
|
|
11
|
+
from docuquery.utils import ensure_env_variables
|
|
12
|
+
|
|
13
|
+
os.environ.setdefault("HF_HUB_DISABLE_PROGRESS_BARS", "1")
|
|
14
|
+
os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error")
|
|
15
|
+
# PyTorch warns on macOS that a distributed-training feature is missing.
|
|
16
|
+
# DocuQuery does not use it, so hide the warning.
|
|
17
|
+
logging.getLogger("torch.distributed.elastic.multiprocessing.redirects").setLevel(
|
|
18
|
+
logging.ERROR
|
|
19
|
+
)
|
|
20
|
+
ensure_env_variables()
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@click.command(name="init")
|
|
25
|
+
def init() -> None:
|
|
26
|
+
"""Initializes the local environment: creates tables in the Postgres database."""
|
|
27
|
+
# Imported here: constants.py reads the environment on import, so it must
|
|
28
|
+
# run after ensure_env_variables() has loaded the config file.
|
|
29
|
+
from docuquery.database import Database
|
|
30
|
+
|
|
31
|
+
try:
|
|
32
|
+
click.echo("Initializing database...", color=True)
|
|
33
|
+
db = Database()
|
|
34
|
+
db.initialize_database()
|
|
35
|
+
click.secho("✔ Database initialized successfully.", fg="green")
|
|
36
|
+
except click.ClickException:
|
|
37
|
+
raise
|
|
38
|
+
except Exception as e:
|
|
39
|
+
raise click.ClickException(f"Could not initialize database: {e}") from e
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@click.command(name="ingest")
|
|
43
|
+
@click.argument(
|
|
44
|
+
"file_path",
|
|
45
|
+
type=click.Path(exists=True),
|
|
46
|
+
required=True,
|
|
47
|
+
help="The path to the document file to be ingested. You can drag and drop the file here.",
|
|
48
|
+
)
|
|
49
|
+
def ingest(file_path: str) -> None:
|
|
50
|
+
"""Ingest a document from the specified file path."""
|
|
51
|
+
from docuquery.ingest import DocumentIngestor
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
ingestor = DocumentIngestor()
|
|
55
|
+
ingestor.process_and_store_document(file_path)
|
|
56
|
+
except click.ClickException:
|
|
57
|
+
raise
|
|
58
|
+
except Exception as e:
|
|
59
|
+
raise click.ClickException(f"Could not ingest the document: {e}") from e
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@click.command(name="ask")
|
|
63
|
+
@click.argument(
|
|
64
|
+
"question", required=False, help="Start the conversation with this question."
|
|
65
|
+
)
|
|
66
|
+
@click.option(
|
|
67
|
+
"--session-id", required=False, help="Resume a saved conversation by session ID."
|
|
68
|
+
)
|
|
69
|
+
def ask(question: str, session_id: str) -> None:
|
|
70
|
+
"""Chat with your documents. Type 'exit' or press Ctrl-D to quit."""
|
|
71
|
+
from docuquery.rag import RAGBase
|
|
72
|
+
|
|
73
|
+
rag = RAGBase()
|
|
74
|
+
|
|
75
|
+
if session_id:
|
|
76
|
+
try:
|
|
77
|
+
uuid.UUID(session_id)
|
|
78
|
+
except ValueError:
|
|
79
|
+
raise click.ClickException(
|
|
80
|
+
"The session could not be loaded. Invalid session ID."
|
|
81
|
+
) from None
|
|
82
|
+
try:
|
|
83
|
+
session = rag.load_conversation(session_id)
|
|
84
|
+
click.echo(f'Resumed session: {session_id} - Title: "{session["title"]}"')
|
|
85
|
+
except click.ClickException:
|
|
86
|
+
raise
|
|
87
|
+
except ValueError:
|
|
88
|
+
raise click.ClickException(
|
|
89
|
+
"The session could not be loaded. Session does not exist."
|
|
90
|
+
) from None
|
|
91
|
+
|
|
92
|
+
title = question
|
|
93
|
+
try:
|
|
94
|
+
while True:
|
|
95
|
+
if not question:
|
|
96
|
+
question = click.prompt(
|
|
97
|
+
click.style("You", fg="cyan"), prompt_suffix=": "
|
|
98
|
+
).strip()
|
|
99
|
+
if question.lower() in ("exit", "quit"):
|
|
100
|
+
break
|
|
101
|
+
if question:
|
|
102
|
+
title = title or question
|
|
103
|
+
click.secho("Answer: ", nl=False, fg="green")
|
|
104
|
+
try:
|
|
105
|
+
for token in rag.query_rag_stream(question):
|
|
106
|
+
click.secho(token, nl=False)
|
|
107
|
+
# Save conversation
|
|
108
|
+
session_id = rag.save_conversation(title=title[:50])
|
|
109
|
+
except Exception as e:
|
|
110
|
+
click.secho(
|
|
111
|
+
f"❌ Error: Could not generate an answer: {e}",
|
|
112
|
+
err=True,
|
|
113
|
+
fg="red",
|
|
114
|
+
)
|
|
115
|
+
click.echo("\n")
|
|
116
|
+
question = None
|
|
117
|
+
except (KeyboardInterrupt, EOFError, click.Abort):
|
|
118
|
+
pass
|
|
119
|
+
click.echo("\nGoodbye!")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@click.command(name="sessions")
|
|
123
|
+
def list_sessions() -> None:
|
|
124
|
+
"""List all saved conversation sessions."""
|
|
125
|
+
from docuquery.rag import RAGBase
|
|
126
|
+
|
|
127
|
+
rag = RAGBase()
|
|
128
|
+
try:
|
|
129
|
+
sessions = rag.list_sessions()
|
|
130
|
+
if not sessions:
|
|
131
|
+
click.echo("No sessions found.")
|
|
132
|
+
for session in sessions:
|
|
133
|
+
click.echo(
|
|
134
|
+
f'Session ID: {session["id"]} - Title: "{session.get("title", "Untitled")}"'
|
|
135
|
+
)
|
|
136
|
+
except click.ClickException:
|
|
137
|
+
raise
|
|
138
|
+
except Exception as e:
|
|
139
|
+
raise click.ClickException(f"Could not list sessions: {e}") from e
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@click.command(name="resume")
|
|
143
|
+
@click.argument("session_id", required=True, help="The session ID to resume.")
|
|
144
|
+
@click.pass_context
|
|
145
|
+
def resume_session(ctx: click.Context, session_id: str) -> None:
|
|
146
|
+
"""Resume a saved conversation by session ID."""
|
|
147
|
+
ctx.invoke(ask, question=None, session_id=session_id)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
cli.add_command(init)
|
|
151
|
+
cli.add_command(ingest)
|
|
152
|
+
cli.add_command(ask)
|
|
153
|
+
cli.add_command(list_sessions)
|
|
154
|
+
cli.add_command(resume_session)
|
|
155
|
+
|
|
156
|
+
if __name__ == "__main__":
|
|
157
|
+
cli()
|
docuquery/rag.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import typing
|
|
3
|
+
from functools import cached_property
|
|
4
|
+
|
|
5
|
+
from openai import OpenAI
|
|
6
|
+
from sentence_transformers import SentenceTransformer
|
|
7
|
+
|
|
8
|
+
from docuquery.constants import (
|
|
9
|
+
DOCUQUERY_OPENAI_MODEL,
|
|
10
|
+
OPENAI_API_KEY,
|
|
11
|
+
SENTENCE_TRANSFORMER_MODEL,
|
|
12
|
+
SYSTEM_INSTRUCTIONS,
|
|
13
|
+
USER_PROMPT_TEMPLATE,
|
|
14
|
+
)
|
|
15
|
+
from docuquery.database import Database
|
|
16
|
+
from docuquery.tools import TOOLS
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class RAGBase:
|
|
20
|
+
MAX_SEARCH_RESULTS = 20
|
|
21
|
+
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
instructions: str = SYSTEM_INSTRUCTIONS,
|
|
25
|
+
prompt_template: str = USER_PROMPT_TEMPLATE,
|
|
26
|
+
model: str = DOCUQUERY_OPENAI_MODEL,
|
|
27
|
+
) -> None:
|
|
28
|
+
self.db = Database()
|
|
29
|
+
self.openai_client = OpenAI(api_key=OPENAI_API_KEY)
|
|
30
|
+
|
|
31
|
+
self.instructions = instructions
|
|
32
|
+
self.prompt_template = prompt_template
|
|
33
|
+
self.model = model
|
|
34
|
+
|
|
35
|
+
self.messages = [{"role": "system", "content": self.instructions}]
|
|
36
|
+
self.saved_count = 1
|
|
37
|
+
self.session_id = None
|
|
38
|
+
|
|
39
|
+
@cached_property
|
|
40
|
+
def embedder(self) -> SentenceTransformer:
|
|
41
|
+
return SentenceTransformer(SENTENCE_TRANSFORMER_MODEL)
|
|
42
|
+
|
|
43
|
+
def save_conversation(self, title: str) -> str:
|
|
44
|
+
self.session_id = self.__start_new_session(title)
|
|
45
|
+
self.db.add_messages(
|
|
46
|
+
conversation_id=self.session_id, messages=self.messages[self.saved_count :]
|
|
47
|
+
)
|
|
48
|
+
self.saved_count = len(self.messages)
|
|
49
|
+
return self.session_id
|
|
50
|
+
|
|
51
|
+
def load_conversation(self, session_id: str) -> dict:
|
|
52
|
+
"""Load a conversation session by its session ID.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
session_id (str): The ID of the conversation session to load.
|
|
56
|
+
Raises:
|
|
57
|
+
ValueError: If no conversation is found for the given session ID.
|
|
58
|
+
Returns:
|
|
59
|
+
dict: The conversation session data, including its messages.
|
|
60
|
+
"""
|
|
61
|
+
conversation = self.db.get_conversation_by_id(conversation_id=session_id)
|
|
62
|
+
if conversation is None:
|
|
63
|
+
raise ValueError(f"No conversation found for session_id: {session_id}")
|
|
64
|
+
message_content = self.instructions + "\n\n Session ID: " + session_id
|
|
65
|
+
self.messages = [{"role": "system", "content": message_content}]
|
|
66
|
+
self.messages.extend(conversation["messages"])
|
|
67
|
+
self.session_id = session_id
|
|
68
|
+
self.saved_count = len(self.messages)
|
|
69
|
+
return conversation
|
|
70
|
+
|
|
71
|
+
def list_sessions(self, page: int = 1, limit: int = 20) -> list[dict]:
|
|
72
|
+
"""Get the list of conversation sessions
|
|
73
|
+
|
|
74
|
+
Args:
|
|
75
|
+
page (int, optional): The page number to retrieve. Defaults to 1.
|
|
76
|
+
limit (int, optional): The number of sessions per page. Defaults to 20.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
list[dict]: Returns a list of conversation sessions, each represented as a dictionary with 'id' and 'title' keys.
|
|
80
|
+
"""
|
|
81
|
+
return self.db.list_conversations(page=page, page_size=limit)
|
|
82
|
+
|
|
83
|
+
def search(self, query: str, limit: int = 3) -> list[dict]:
|
|
84
|
+
limit = (
|
|
85
|
+
1
|
|
86
|
+
if limit < 1
|
|
87
|
+
else self.MAX_SEARCH_RESULTS
|
|
88
|
+
if limit > self.MAX_SEARCH_RESULTS
|
|
89
|
+
else limit
|
|
90
|
+
)
|
|
91
|
+
query_embedding = self.embedder.encode(query)
|
|
92
|
+
return self.db.search_similar_chunks(
|
|
93
|
+
query_embedding=query_embedding, limit=limit
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def get_session_messages(self, session_id: str) -> str:
|
|
97
|
+
"""Retrieve the formatted conversation messages for a given session ID.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
session_id (str): The ID of the conversation session to load.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
str: The formatted conversation messages for the session, or an error message if the session does not exist.
|
|
104
|
+
"""
|
|
105
|
+
conversation = self.db.get_conversation_by_id(conversation_id=session_id)
|
|
106
|
+
if conversation is None:
|
|
107
|
+
return "Session does not exist."
|
|
108
|
+
return "\n".join(
|
|
109
|
+
f"{m['role']}: {m.get('content') or '[tool call]'}"
|
|
110
|
+
for m in conversation["messages"]
|
|
111
|
+
if m["role"] in ("user", "assistant")
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def build_context(self, matches: list[dict]) -> str:
|
|
115
|
+
|
|
116
|
+
# Loop through the matches and convert the dictionary into formated strings assuming the keys are unknown.
|
|
117
|
+
context_str = ""
|
|
118
|
+
|
|
119
|
+
if not matches:
|
|
120
|
+
return "No relevant context found."
|
|
121
|
+
|
|
122
|
+
for match in matches:
|
|
123
|
+
for key in match:
|
|
124
|
+
if match[key] is None:
|
|
125
|
+
continue
|
|
126
|
+
field = key.split("_")
|
|
127
|
+
field = " ".join([word.capitalize() for word in field])
|
|
128
|
+
match[key] = f"{field}: {match[key]}"
|
|
129
|
+
context_str += f"{match[key]}\n"
|
|
130
|
+
|
|
131
|
+
context_str += "\n\n---\n\n"
|
|
132
|
+
|
|
133
|
+
return context_str
|
|
134
|
+
|
|
135
|
+
def build_prompt(self, matches: list[dict]) -> str:
|
|
136
|
+
context = self.build_context(matches)
|
|
137
|
+
return self.prompt_template.format(context=context)
|
|
138
|
+
|
|
139
|
+
def __run_tool(self, name: str, args: dict) -> str:
|
|
140
|
+
if name == "search":
|
|
141
|
+
matches = self.search(args["query"], limit=args.get("limit", 3))
|
|
142
|
+
return self.build_context(matches)
|
|
143
|
+
if name == "__count_documents":
|
|
144
|
+
return str(self.__count_documents(**args))
|
|
145
|
+
if name == "__list_documents":
|
|
146
|
+
matches = self.__list_documents(**args)
|
|
147
|
+
return self.build_context(matches)
|
|
148
|
+
if name == "list_sessions":
|
|
149
|
+
sessions = self.list_sessions(**args)
|
|
150
|
+
return self.build_context(sessions)
|
|
151
|
+
if name == "get_session_messages":
|
|
152
|
+
return self.get_session_messages(**args)
|
|
153
|
+
return f"Unknown tool: {name}"
|
|
154
|
+
|
|
155
|
+
def query_rag_stream(
|
|
156
|
+
self, prompt: str, max_turns: int = 3
|
|
157
|
+
) -> typing.Generator[str, None, None]:
|
|
158
|
+
self.messages.append({"role": "user", "content": prompt})
|
|
159
|
+
|
|
160
|
+
for _ in range(max_turns):
|
|
161
|
+
# Stream response tokens from OpenAI
|
|
162
|
+
stream = self.openai_client.chat.completions.create(
|
|
163
|
+
model=self.model,
|
|
164
|
+
messages=self.messages,
|
|
165
|
+
stream=True,
|
|
166
|
+
temperature=0.0,
|
|
167
|
+
tools=TOOLS,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
text_parts = []
|
|
171
|
+
tool_calls = {}
|
|
172
|
+
finish_reason = None
|
|
173
|
+
|
|
174
|
+
for chunk in stream:
|
|
175
|
+
if not chunk.choices:
|
|
176
|
+
continue
|
|
177
|
+
|
|
178
|
+
choice = chunk.choices[0]
|
|
179
|
+
delta = choice.delta
|
|
180
|
+
if delta.content:
|
|
181
|
+
text_parts.append(delta.content)
|
|
182
|
+
yield delta.content
|
|
183
|
+
|
|
184
|
+
for tc in delta.tool_calls or []:
|
|
185
|
+
call = tool_calls.setdefault(
|
|
186
|
+
tc.index, {"id": None, "name": "", "arguments": ""}
|
|
187
|
+
)
|
|
188
|
+
if tc.id:
|
|
189
|
+
call["id"] = tc.id
|
|
190
|
+
if tc.function and tc.function.name:
|
|
191
|
+
call["name"] += tc.function.name
|
|
192
|
+
if tc.function and tc.function.arguments:
|
|
193
|
+
call["arguments"] += tc.function.arguments
|
|
194
|
+
|
|
195
|
+
if choice.finish_reason:
|
|
196
|
+
finish_reason = choice.finish_reason
|
|
197
|
+
|
|
198
|
+
if finish_reason != "tool_calls":
|
|
199
|
+
self.messages.append(
|
|
200
|
+
{"role": "assistant", "content": "".join(text_parts) or None}
|
|
201
|
+
)
|
|
202
|
+
return
|
|
203
|
+
|
|
204
|
+
calls = [tool_calls[i] for i in sorted(tool_calls)]
|
|
205
|
+
self.messages.append(
|
|
206
|
+
{
|
|
207
|
+
"role": "assistant",
|
|
208
|
+
"content": "".join(text_parts) or None,
|
|
209
|
+
"tool_calls": [
|
|
210
|
+
{
|
|
211
|
+
"id": c["id"],
|
|
212
|
+
"type": "function",
|
|
213
|
+
"function": {
|
|
214
|
+
"name": c["name"],
|
|
215
|
+
"arguments": c["arguments"],
|
|
216
|
+
},
|
|
217
|
+
}
|
|
218
|
+
for c in calls
|
|
219
|
+
],
|
|
220
|
+
}
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
for call in calls:
|
|
224
|
+
function_name = call["name"]
|
|
225
|
+
try:
|
|
226
|
+
args = json.loads(call["arguments"] or "{}")
|
|
227
|
+
result = self.__run_tool(function_name, args)
|
|
228
|
+
except Exception as e:
|
|
229
|
+
result = (
|
|
230
|
+
f"Tool error: {e}" # let the model see the failure and recover
|
|
231
|
+
)
|
|
232
|
+
self.messages.append(
|
|
233
|
+
{"role": "tool", "tool_call_id": call["id"], "content": result}
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
yield "\n[Stopped: reached max tool-call turns]"
|
|
237
|
+
|
|
238
|
+
def __count_documents(self, query: str) -> int:
|
|
239
|
+
"""Searches for documents, and counts the total number of documents that match the query.
|
|
240
|
+
|
|
241
|
+
Args:
|
|
242
|
+
query (str): The search query to match against document names, content, and embeddings.
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
int: The total number of documents that match the query.
|
|
246
|
+
"""
|
|
247
|
+
filters = {}
|
|
248
|
+
if query.strip():
|
|
249
|
+
filters["query_embedding"] = self.embedder.encode(query)
|
|
250
|
+
return self.db.count_documents(**filters)
|
|
251
|
+
|
|
252
|
+
def __list_documents(
|
|
253
|
+
self, query: str = "", page: int = 1, limit: int = 5
|
|
254
|
+
) -> list[dict]:
|
|
255
|
+
"""Lists documents, optionally filtering by a query string and supporting pagination.
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
query (str): Optional search query to filter document names and content.
|
|
259
|
+
limit (int): Maximum number of documents to return per page.
|
|
260
|
+
page (int): The page number for pagination.
|
|
261
|
+
|
|
262
|
+
Returns:
|
|
263
|
+
list[dict]: A list of dictionaries containing document information.
|
|
264
|
+
"""
|
|
265
|
+
filters = {}
|
|
266
|
+
if query.strip():
|
|
267
|
+
filters["query_embedding"] = self.embedder.encode(query)
|
|
268
|
+
return self.db.list_documents(
|
|
269
|
+
page=page,
|
|
270
|
+
page_size=limit,
|
|
271
|
+
**filters,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
def __start_new_session(self, title: str) -> str:
|
|
275
|
+
if self.session_id:
|
|
276
|
+
return self.session_id
|
|
277
|
+
|
|
278
|
+
self.session_id = self.db.create_conversation(title=title)
|
|
279
|
+
return self.session_id
|
docuquery/tools.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
search_tool = {
|
|
2
|
+
"type": "function",
|
|
3
|
+
"function": {
|
|
4
|
+
"name": "search",
|
|
5
|
+
"description": "Search the user's ingested documents for passages relevant to a query.",
|
|
6
|
+
"parameters": {
|
|
7
|
+
"type": "object",
|
|
8
|
+
"properties": {
|
|
9
|
+
"query": {
|
|
10
|
+
"type": "string",
|
|
11
|
+
"description": "The search query to match against document content.",
|
|
12
|
+
},
|
|
13
|
+
"limit": {
|
|
14
|
+
"type": "number",
|
|
15
|
+
"description": "Maximum number of search results to return.",
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
"required": ["query"],
|
|
19
|
+
"additionalProperties": False,
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
count_documents_tool = {
|
|
25
|
+
"type": "function",
|
|
26
|
+
"function": {
|
|
27
|
+
"name": "__count_documents",
|
|
28
|
+
"description": "Searches for documents, and counts the total number of documents that match the query.\n\nReturns:\n int: The total number of documents that match thequery.",
|
|
29
|
+
"parameters": {
|
|
30
|
+
"type": "object",
|
|
31
|
+
"properties": {
|
|
32
|
+
"query": {
|
|
33
|
+
"type": "string",
|
|
34
|
+
"description": "The search query to match against document names, content, and embeddings.",
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"required": ["query"],
|
|
38
|
+
"additionalProperties": False,
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
list_documents_tool = {
|
|
44
|
+
"type": "function",
|
|
45
|
+
"function": {
|
|
46
|
+
"name": "__list_documents",
|
|
47
|
+
"description": "Lists documents, optionally filtering by a query string and supporting pagination.\n\nReturns:\n list[dict]: A list of dictionaries containing document information.",
|
|
48
|
+
"parameters": {
|
|
49
|
+
"type": "object",
|
|
50
|
+
"properties": {
|
|
51
|
+
"query": {
|
|
52
|
+
"type": "string",
|
|
53
|
+
"description": "Optional search query to filter document names and content.",
|
|
54
|
+
},
|
|
55
|
+
"page": {
|
|
56
|
+
"type": "number",
|
|
57
|
+
"description": "The page number for pagination.",
|
|
58
|
+
},
|
|
59
|
+
"limit": {
|
|
60
|
+
"type": "number",
|
|
61
|
+
"description": "Maximum number of documents to return per page.",
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
"required": [],
|
|
65
|
+
"additionalProperties": False,
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
list_sessions_tool = {
|
|
71
|
+
"type": "function",
|
|
72
|
+
"function": {
|
|
73
|
+
"name": "list_sessions",
|
|
74
|
+
"description": "Get the list of conversation sessions.\n\nReturns:\n list[dict]: Returns a list of conversation sessions, each represented as a dictionary with 'id' and 'title' keys.",
|
|
75
|
+
"parameters": {
|
|
76
|
+
"type": "object",
|
|
77
|
+
"properties": {
|
|
78
|
+
"page": {
|
|
79
|
+
"type": "number",
|
|
80
|
+
"description": "The page number to retrieve. Defaults to 1.",
|
|
81
|
+
},
|
|
82
|
+
"limit": {
|
|
83
|
+
"type": "number",
|
|
84
|
+
"description": "Maximum number of sessions to return per page. Defaults to 20.",
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
"required": [],
|
|
88
|
+
"additionalProperties": False,
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
get_session_messages_tool = {
|
|
94
|
+
"type": "function",
|
|
95
|
+
"function": {
|
|
96
|
+
"name": "get_session_messages",
|
|
97
|
+
"description": "Retrieve the formatted conversation messages for a given session ID.\n\nReturns:\n str: The formatted conversation messages for the session, or an error message if the session does not exist.",
|
|
98
|
+
"parameters": {
|
|
99
|
+
"type": "object",
|
|
100
|
+
"properties": {
|
|
101
|
+
"session_id": {
|
|
102
|
+
"type": "string",
|
|
103
|
+
"description": "The ID of the conversation session to load.",
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
"required": ["session_id"],
|
|
107
|
+
"additionalProperties": False,
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
TOOLS = [
|
|
113
|
+
search_tool,
|
|
114
|
+
count_documents_tool,
|
|
115
|
+
list_documents_tool,
|
|
116
|
+
list_sessions_tool,
|
|
117
|
+
get_session_messages_tool,
|
|
118
|
+
]
|
docuquery/utils.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class ConfigVariable:
|
|
11
|
+
key: str
|
|
12
|
+
name: str
|
|
13
|
+
desc: str
|
|
14
|
+
default: str = ""
|
|
15
|
+
secret: bool = False
|
|
16
|
+
required: bool = False
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
CONFIG_FILE = Path(click.get_app_dir("docuquery")) / "config.env"
|
|
20
|
+
|
|
21
|
+
CONFIG_VARIABLES: list[ConfigVariable] = [
|
|
22
|
+
ConfigVariable(
|
|
23
|
+
key="DOCUQUERY_POSTGRES_URL",
|
|
24
|
+
name="Postgres URL",
|
|
25
|
+
desc="Set the Postgres URL for the Docuquery database",
|
|
26
|
+
default="postgresql://postgres:postgres@localhost:5432/docuquery_db",
|
|
27
|
+
required=True,
|
|
28
|
+
),
|
|
29
|
+
ConfigVariable(
|
|
30
|
+
key="OPENAI_API_KEY",
|
|
31
|
+
name="OpenAI API Key",
|
|
32
|
+
desc="Get your OpenAI API Key from https://platform.openai.com/account/api-keys",
|
|
33
|
+
default="",
|
|
34
|
+
secret=True,
|
|
35
|
+
required=True,
|
|
36
|
+
),
|
|
37
|
+
ConfigVariable(
|
|
38
|
+
key="DOCUQUERY_OPENAI_MODEL",
|
|
39
|
+
name="OpenAI Model",
|
|
40
|
+
desc="See list of models you can use: https://platform.openai.com/docs/models",
|
|
41
|
+
default="gpt-5.4-mini",
|
|
42
|
+
required=False,
|
|
43
|
+
),
|
|
44
|
+
ConfigVariable(
|
|
45
|
+
key="SENTENCE_TRANSFORMER_MODEL",
|
|
46
|
+
name="Sentence Transformer Model",
|
|
47
|
+
desc="See list of models you can use: https://sbert.net/docs/sentence_transformer/pretrained_models.html#original-models",
|
|
48
|
+
default="sentence-transformers/all-MiniLM-L6-v2",
|
|
49
|
+
required=False,
|
|
50
|
+
),
|
|
51
|
+
ConfigVariable(
|
|
52
|
+
key="DOCUQUERY_CHUNK_SIZE",
|
|
53
|
+
name="Chunk Size",
|
|
54
|
+
desc="Set the chunk size for document processing",
|
|
55
|
+
default="512",
|
|
56
|
+
required=False,
|
|
57
|
+
),
|
|
58
|
+
ConfigVariable(
|
|
59
|
+
key="DOCUQUERY_OVERLAP",
|
|
60
|
+
name="Overlap",
|
|
61
|
+
desc="Set the overlap size for document processing",
|
|
62
|
+
default="50",
|
|
63
|
+
required=False,
|
|
64
|
+
),
|
|
65
|
+
]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def check_env_variable(name: str) -> bool:
|
|
69
|
+
"""Checks if an environment variable is set."""
|
|
70
|
+
return bool(os.getenv(name, ""))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def set_env_variable(name: str, value: str) -> None:
|
|
74
|
+
"""Sets an environment variable."""
|
|
75
|
+
os.environ[name] = value
|
|
76
|
+
CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
|
|
78
|
+
with CONFIG_FILE.open("a") as f:
|
|
79
|
+
f.write(f"{name}={value}\n")
|
|
80
|
+
CONFIG_FILE.chmod(0o600)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def check_config_file() -> bool:
|
|
84
|
+
"""Checks if the config file exists and loads environment variables from it."""
|
|
85
|
+
if CONFIG_FILE.exists():
|
|
86
|
+
for line in CONFIG_FILE.read_text().splitlines():
|
|
87
|
+
key, _, value = line.partition("=")
|
|
88
|
+
os.environ.setdefault(key.strip(), value.strip())
|
|
89
|
+
return True
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def ensure_env_variables() -> None:
|
|
94
|
+
if "--help" in sys.argv:
|
|
95
|
+
return
|
|
96
|
+
|
|
97
|
+
check_config_file()
|
|
98
|
+
|
|
99
|
+
for config in CONFIG_VARIABLES:
|
|
100
|
+
if check_env_variable(config.key):
|
|
101
|
+
continue
|
|
102
|
+
|
|
103
|
+
click.secho(
|
|
104
|
+
f"Missing{' required' if config.required else ''} config: {config.name}",
|
|
105
|
+
fg=f"{'red' if config.required else 'yellow'}",
|
|
106
|
+
)
|
|
107
|
+
if config.desc:
|
|
108
|
+
click.secho(config.desc, err=True, fg="blue")
|
|
109
|
+
|
|
110
|
+
while True:
|
|
111
|
+
value = click.prompt(
|
|
112
|
+
click.style(f"◉ {config.name}", fg="cyan"),
|
|
113
|
+
hide_input=config.secret or False,
|
|
114
|
+
prompt_suffix=" > ",
|
|
115
|
+
type=str,
|
|
116
|
+
default=config.default if not config.secret else None,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
if value:
|
|
120
|
+
set_env_variable(config.key, value)
|
|
121
|
+
break
|