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/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