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/__init__.py
ADDED
docuquery/chunker.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from docuquery.constants import CHUNK_SIZE, OVERLAP
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def split_text(text_block: str, chunk_size: int, separators: list[str]) -> list[str]:
|
|
5
|
+
"""Splits a text block into chunks based on the specified separators and chunk size.
|
|
6
|
+
|
|
7
|
+
Args:
|
|
8
|
+
text_block (str): The text block to be split.
|
|
9
|
+
chunk_size (int): The maximum size of each chunk.
|
|
10
|
+
separators (list[str]): A list of separators to use for splitting.
|
|
11
|
+
|
|
12
|
+
Returns:
|
|
13
|
+
list[str]: A list of text chunks.
|
|
14
|
+
"""
|
|
15
|
+
if len(text_block) <= chunk_size:
|
|
16
|
+
return [text_block]
|
|
17
|
+
|
|
18
|
+
for i, separator in enumerate(separators):
|
|
19
|
+
if separator not in text_block:
|
|
20
|
+
continue
|
|
21
|
+
chunks, current = [], ""
|
|
22
|
+
|
|
23
|
+
for part in text_block.split(separator):
|
|
24
|
+
if len(part) > chunk_size:
|
|
25
|
+
if current:
|
|
26
|
+
chunks.append(current)
|
|
27
|
+
current = ""
|
|
28
|
+
chunks.extend(split_text(part, chunk_size, separators[i + 1 :]))
|
|
29
|
+
elif not current:
|
|
30
|
+
current = part
|
|
31
|
+
elif len(current) + len(separator) + len(part) <= chunk_size:
|
|
32
|
+
current += separator + part
|
|
33
|
+
else:
|
|
34
|
+
chunks.append(current)
|
|
35
|
+
current = part
|
|
36
|
+
if current:
|
|
37
|
+
chunks.append(current)
|
|
38
|
+
return chunks
|
|
39
|
+
|
|
40
|
+
# No separator left: cut at chunk_size, so no text is lost
|
|
41
|
+
return [
|
|
42
|
+
text_block[i : i + chunk_size] for i in range(0, len(text_block), chunk_size)
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def overlap_chunked_texts(
|
|
47
|
+
text_chunks: list[str], overlap: int, chunk_size: int
|
|
48
|
+
) -> list[str]:
|
|
49
|
+
"""Applies overlap to a list of text chunks.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
text_chunks (list[str]): The list of text chunks.
|
|
53
|
+
overlap (int): The number of overlapping characters between consecutive chunks.
|
|
54
|
+
chunk_size (int): The maximum size of each chunk.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
list[str]: A list of text chunks with applied overlap.
|
|
58
|
+
"""
|
|
59
|
+
overlapped_chunks = []
|
|
60
|
+
for i, text_chunk in enumerate(text_chunks):
|
|
61
|
+
chunk = text_chunk
|
|
62
|
+
if overlap and i > 0 and len(text_chunk) + overlap <= chunk_size:
|
|
63
|
+
overlap_text_chunk = text_chunks[i - 1][-overlap:]
|
|
64
|
+
chunk = overlap_text_chunk + text_chunk
|
|
65
|
+
overlapped_chunks.append(chunk)
|
|
66
|
+
return overlapped_chunks
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def recursive_semantic_chunking(
|
|
70
|
+
text: str, chunk_size: int = CHUNK_SIZE, overlap: int = OVERLAP
|
|
71
|
+
) -> list[str]:
|
|
72
|
+
"""Recursively chunks the input text into smaller segments based on semantic separators.
|
|
73
|
+
If a chunk exceeds the specified chunk size, it will be further split using the next available separator.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
text (str): The input text to be chunked.
|
|
77
|
+
chunk_size (int, optional): The maximum size of each chunk. Defaults to CHUNK_SIZE.
|
|
78
|
+
overlap (int, optional): The number of overlapping characters between consecutive chunks. Defaults to OVERLAP.
|
|
79
|
+
|
|
80
|
+
Returns:
|
|
81
|
+
list[str]: A list of text chunks.
|
|
82
|
+
"""
|
|
83
|
+
separators = ["\n\n", "\n", ".", "!", "?", ",", " "]
|
|
84
|
+
|
|
85
|
+
text_chunks = split_text(text, chunk_size, separators)
|
|
86
|
+
|
|
87
|
+
return overlap_chunked_texts(text_chunks, overlap, chunk_size)
|
docuquery/constants.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def _int_env(name: str, default: int) -> int:
|
|
5
|
+
value = os.getenv(name, str(default))
|
|
6
|
+
try:
|
|
7
|
+
return int(value)
|
|
8
|
+
except ValueError:
|
|
9
|
+
raise SystemExit(
|
|
10
|
+
f"{name} must be a whole number. The current value is '{value}'."
|
|
11
|
+
) from None
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
DOCUQUERY_POSTGRES_URL = os.getenv(
|
|
15
|
+
"DOCUQUERY_POSTGRES_URL",
|
|
16
|
+
"postgresql://postgres:postgres@localhost:5432/docuquery_db",
|
|
17
|
+
)
|
|
18
|
+
DOCUQUERY_OPENAI_MODEL = os.getenv("DOCUQUERY_OPENAI_MODEL", "gpt-5.4-mini")
|
|
19
|
+
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
|
|
20
|
+
SENTENCE_TRANSFORMER_MODEL = os.getenv(
|
|
21
|
+
"SENTENCE_TRANSFORMER_MODEL", "sentence-transformers/all-MiniLM-L6-v2"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
CHUNK_SIZE = _int_env("DOCUQUERY_CHUNK_SIZE", 512)
|
|
26
|
+
OVERLAP = _int_env("DOCUQUERY_OVERLAP", 50)
|
|
27
|
+
|
|
28
|
+
if not 0 <= OVERLAP < CHUNK_SIZE:
|
|
29
|
+
raise SystemExit(
|
|
30
|
+
"DOCUQUERY_OVERLAP must be 0 or more, and less than DOCUQUERY_CHUNK_SIZE."
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
EMBEDDING_DIMENSION = 384 # must match SENTENCE_TRANSFORMER_MODEL's output size
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
SYSTEM_INSTRUCTIONS = """
|
|
37
|
+
You are an expert technical assistant. Answer the user's question accurately
|
|
38
|
+
using the provided tools. If the answer cannot be found,
|
|
39
|
+
say 'I cannot find the answer in the provided documents.'.
|
|
40
|
+
|
|
41
|
+
If you want to look up the documents, use the search tool. Feel free to do a multiple search
|
|
42
|
+
as needed to get the information you require from the documents.
|
|
43
|
+
|
|
44
|
+
There is a __list_documents tool that allows you to list documents with optional filtering and pagination.
|
|
45
|
+
Feel free to use it whenever you need to find documents. You can go over it, paginate, get more documents as needed.
|
|
46
|
+
|
|
47
|
+
There is a __count_documents tool that allows you to count the total number of documents matching a query.
|
|
48
|
+
Feel free to use it whenever you need to know how many documents match a specific search.
|
|
49
|
+
|
|
50
|
+
When using the search tool, provide a clear and concise query that will help retrieve the most relevant information.
|
|
51
|
+
If the user's question is about a specific document or set of documents, use the __list_documents tool first to identify them.
|
|
52
|
+
Then, use the search tool to find specific information within those documents.
|
|
53
|
+
|
|
54
|
+
User can ask about their session history and try to retrieve previous conversations using the list_sessions tool.
|
|
55
|
+
|
|
56
|
+
When a user requests for the session messages, you can use the get_session_messages tool to retrieve the messages for a specific session ID.
|
|
57
|
+
|
|
58
|
+
When call all theses tools. Ensure that you provide the necessary arguments and handle the responses appropriately. Do NOT provide
|
|
59
|
+
arguments that the functions cannot accept.
|
|
60
|
+
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
USER_PROMPT_TEMPLATE = """
|
|
64
|
+
|
|
65
|
+
CONTEXT:
|
|
66
|
+
{context}
|
|
67
|
+
"""
|
docuquery/database.py
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import typing
|
|
2
|
+
from typing import ClassVar
|
|
3
|
+
|
|
4
|
+
import psycopg
|
|
5
|
+
from pgvector.psycopg import register_vector
|
|
6
|
+
from psycopg.types.json import Jsonb
|
|
7
|
+
|
|
8
|
+
from docuquery.constants import DOCUQUERY_POSTGRES_URL, EMBEDDING_DIMENSION
|
|
9
|
+
|
|
10
|
+
DB_SQL_QUERY = f"""
|
|
11
|
+
-- 1. Enable the vector extension
|
|
12
|
+
CREATE EXTENSION IF NOT EXISTS vector;
|
|
13
|
+
|
|
14
|
+
-- 2. Create the documents table
|
|
15
|
+
CREATE TABLE IF NOT EXISTS document_chunks (
|
|
16
|
+
id BIGSERIAL PRIMARY KEY,
|
|
17
|
+
file_name TEXT NOT NULL,
|
|
18
|
+
chunk_index INT NOT NULL,
|
|
19
|
+
content TEXT NOT NULL,
|
|
20
|
+
embedding vector({EMBEDDING_DIMENSION})
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
-- 3. Create an HNSW index for lightning-fast vector searches
|
|
24
|
+
CREATE INDEX IF NOT EXISTS idx_chunks_hnsw
|
|
25
|
+
ON document_chunks
|
|
26
|
+
USING hnsw (embedding vector_cosine_ops)
|
|
27
|
+
WITH (m = 16, ef_construction = 200);
|
|
28
|
+
|
|
29
|
+
-- 4. Enable trigram matching and index file names for fuzzy name search
|
|
30
|
+
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
|
31
|
+
|
|
32
|
+
CREATE INDEX IF NOT EXISTS idx_chunks_file_name_trgm
|
|
33
|
+
ON document_chunks
|
|
34
|
+
USING gin (file_name gin_trgm_ops);
|
|
35
|
+
|
|
36
|
+
-- 5. Conversations and their messages (stored in OpenAI message format)
|
|
37
|
+
CREATE TABLE IF NOT EXISTS conversations (
|
|
38
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
39
|
+
title TEXT,
|
|
40
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
41
|
+
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
CREATE TABLE IF NOT EXISTS conversation_messages (
|
|
45
|
+
id BIGSERIAL PRIMARY KEY,
|
|
46
|
+
conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
|
|
47
|
+
position INT NOT NULL,
|
|
48
|
+
role TEXT NOT NULL CHECK (role IN ('system', 'user', 'assistant', 'tool')),
|
|
49
|
+
content TEXT,
|
|
50
|
+
tool_calls JSONB,
|
|
51
|
+
tool_call_id TEXT,
|
|
52
|
+
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
53
|
+
UNIQUE (conversation_id, position)
|
|
54
|
+
);
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class Database:
|
|
59
|
+
MAX_PAGE_SIZE: ClassVar[int] = 100
|
|
60
|
+
|
|
61
|
+
def __get_connection(self) -> psycopg.Connection:
|
|
62
|
+
# Connect using standard environment variable DOCUQUERY_POSTGRES_URL
|
|
63
|
+
conn = psycopg.connect(DOCUQUERY_POSTGRES_URL)
|
|
64
|
+
# register_vector needs the extension to exist, so it can't run before init
|
|
65
|
+
register_vector(conn)
|
|
66
|
+
return conn
|
|
67
|
+
|
|
68
|
+
def initialize_database(self) -> None:
|
|
69
|
+
with psycopg.connect(DOCUQUERY_POSTGRES_URL) as conn:
|
|
70
|
+
with conn.cursor() as cur:
|
|
71
|
+
cur.execute(DB_SQL_QUERY)
|
|
72
|
+
conn.commit()
|
|
73
|
+
|
|
74
|
+
def replace_document(
|
|
75
|
+
self, file_name: str, chunks: list[str], embeddings: list[list[float]]
|
|
76
|
+
) -> None:
|
|
77
|
+
with self.__get_connection() as conn:
|
|
78
|
+
with conn.cursor() as cur:
|
|
79
|
+
cur.execute(
|
|
80
|
+
"DELETE FROM document_chunks WHERE file_name = %s", (file_name,)
|
|
81
|
+
)
|
|
82
|
+
cur.executemany(
|
|
83
|
+
"INSERT INTO document_chunks (file_name, chunk_index, content, embedding) VALUES (%s, %s, %s, %s)",
|
|
84
|
+
[
|
|
85
|
+
(file_name, i, c, e)
|
|
86
|
+
for i, (c, e) in enumerate(zip(chunks, embeddings, strict=True))
|
|
87
|
+
],
|
|
88
|
+
)
|
|
89
|
+
conn.commit()
|
|
90
|
+
|
|
91
|
+
def search_similar_chunks(
|
|
92
|
+
self, query_embedding: list[float], limit: int = 3
|
|
93
|
+
) -> list[dict]:
|
|
94
|
+
with self.__get_connection() as conn, conn.cursor() as cur:
|
|
95
|
+
cur.execute(
|
|
96
|
+
"""
|
|
97
|
+
SELECT file_name, content, 1 - (embedding <=> %s) AS similarity
|
|
98
|
+
FROM document_chunks
|
|
99
|
+
ORDER BY embedding <=> %s
|
|
100
|
+
LIMIT %s
|
|
101
|
+
""",
|
|
102
|
+
(query_embedding, query_embedding, limit),
|
|
103
|
+
)
|
|
104
|
+
rows = cur.fetchall()
|
|
105
|
+
return [
|
|
106
|
+
{"file_name": r[0], "content": r[1], "similarity": float(r[2])}
|
|
107
|
+
for r in rows
|
|
108
|
+
]
|
|
109
|
+
|
|
110
|
+
# Allowed sort keys mapped to SQL, so user input is never put into the query directly
|
|
111
|
+
DOCUMENT_SORT_COLUMNS: ClassVar[dict[str, str]] = {
|
|
112
|
+
"file_name": "file_name",
|
|
113
|
+
"chunk_count": "chunk_count",
|
|
114
|
+
"total_characters": "total_characters",
|
|
115
|
+
"relevance": "relevance",
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
def __build_documents_query(
|
|
119
|
+
self,
|
|
120
|
+
file_name: str | None = None,
|
|
121
|
+
name_contains: str | None = None,
|
|
122
|
+
content_contains: str | None = None,
|
|
123
|
+
query_embedding: list[float] | None = None,
|
|
124
|
+
min_similarity: float = 0.3,
|
|
125
|
+
) -> tuple[str, list]:
|
|
126
|
+
"""Build the grouped documents query shared by list_documents and count_documents.
|
|
127
|
+
|
|
128
|
+
With `query_embedding`, a document matches when any of its chunks has a cosine
|
|
129
|
+
similarity of at least `min_similarity`, and `relevance` is its best chunk's similarity.
|
|
130
|
+
"""
|
|
131
|
+
select_params = []
|
|
132
|
+
where, where_params = [], []
|
|
133
|
+
having, having_params = [], []
|
|
134
|
+
|
|
135
|
+
if query_embedding is not None:
|
|
136
|
+
relevance = "MAX(1 - (embedding <=> %s::vector))"
|
|
137
|
+
select_params.append(query_embedding)
|
|
138
|
+
# Distance is 1 - similarity, so similarity >= min means distance <= 1 - min
|
|
139
|
+
where.append(
|
|
140
|
+
"file_name IN (SELECT file_name FROM document_chunks WHERE embedding <=> %s::vector <= %s)"
|
|
141
|
+
)
|
|
142
|
+
where_params += [query_embedding, 1 - min_similarity]
|
|
143
|
+
else:
|
|
144
|
+
relevance = "NULL::float"
|
|
145
|
+
|
|
146
|
+
if file_name is not None:
|
|
147
|
+
where.append("file_name = %s")
|
|
148
|
+
where_params.append(file_name)
|
|
149
|
+
if name_contains is not None:
|
|
150
|
+
where.append("file_name ILIKE %s")
|
|
151
|
+
where_params.append(f"%{name_contains}%")
|
|
152
|
+
if content_contains is not None:
|
|
153
|
+
# Match documents with any chunk containing the text, but still count all their chunks
|
|
154
|
+
where.append(
|
|
155
|
+
"file_name IN (SELECT file_name FROM document_chunks WHERE content ILIKE %s)"
|
|
156
|
+
)
|
|
157
|
+
where_params.append(f"%{content_contains}%")
|
|
158
|
+
|
|
159
|
+
query = f"""
|
|
160
|
+
SELECT
|
|
161
|
+
file_name,
|
|
162
|
+
COUNT(*) AS chunk_count,
|
|
163
|
+
SUM(LENGTH(content)) AS total_characters,
|
|
164
|
+
{relevance} AS relevance
|
|
165
|
+
FROM document_chunks
|
|
166
|
+
""" # noqa: S608
|
|
167
|
+
if where:
|
|
168
|
+
query += " WHERE " + " AND ".join(where)
|
|
169
|
+
query += " GROUP BY file_name"
|
|
170
|
+
if having:
|
|
171
|
+
query += " HAVING " + " AND ".join(having)
|
|
172
|
+
|
|
173
|
+
return query, select_params + where_params + having_params
|
|
174
|
+
|
|
175
|
+
def list_documents(
|
|
176
|
+
self,
|
|
177
|
+
page: int = 1,
|
|
178
|
+
page_size: int = 20,
|
|
179
|
+
sort_by: str = "file_name",
|
|
180
|
+
descending: bool = False,
|
|
181
|
+
file_name: str | None = None,
|
|
182
|
+
name_contains: str | None = None,
|
|
183
|
+
content_contains: str | None = None,
|
|
184
|
+
query_embedding: list[float] | None = None,
|
|
185
|
+
min_similarity: float = 0.3,
|
|
186
|
+
) -> list[dict]:
|
|
187
|
+
if page < 1:
|
|
188
|
+
raise ValueError("page must be 1 or greater")
|
|
189
|
+
if not 1 <= page_size <= self.MAX_PAGE_SIZE:
|
|
190
|
+
raise ValueError(f"page_size must be between 1 and {self.MAX_PAGE_SIZE}")
|
|
191
|
+
if sort_by not in self.DOCUMENT_SORT_COLUMNS:
|
|
192
|
+
raise ValueError(
|
|
193
|
+
f"sort_by must be one of {list(self.DOCUMENT_SORT_COLUMNS)}"
|
|
194
|
+
)
|
|
195
|
+
if sort_by == "relevance" and query_embedding is None:
|
|
196
|
+
raise ValueError("sort_by='relevance' requires query_embedding")
|
|
197
|
+
|
|
198
|
+
query, params = self.__build_documents_query(
|
|
199
|
+
file_name,
|
|
200
|
+
name_contains,
|
|
201
|
+
content_contains,
|
|
202
|
+
query_embedding,
|
|
203
|
+
min_similarity,
|
|
204
|
+
)
|
|
205
|
+
direction = "DESC" if descending else "ASC"
|
|
206
|
+
# file_name as a tie-breaker keeps page boundaries stable
|
|
207
|
+
query += f" ORDER BY {self.DOCUMENT_SORT_COLUMNS[sort_by]} {direction}, file_name ASC"
|
|
208
|
+
query += " LIMIT %s OFFSET %s"
|
|
209
|
+
params += [page_size, (page - 1) * page_size]
|
|
210
|
+
|
|
211
|
+
with self.__get_connection() as conn, conn.cursor() as cur:
|
|
212
|
+
cur.execute(query, params)
|
|
213
|
+
rows = cur.fetchall()
|
|
214
|
+
return [
|
|
215
|
+
{
|
|
216
|
+
"file_name": r[0],
|
|
217
|
+
"chunk_count": r[1],
|
|
218
|
+
"total_characters": r[2],
|
|
219
|
+
"relevance": None if r[3] is None else float(r[3]),
|
|
220
|
+
}
|
|
221
|
+
for r in rows
|
|
222
|
+
]
|
|
223
|
+
|
|
224
|
+
@typing.override
|
|
225
|
+
def count_documents(
|
|
226
|
+
self,
|
|
227
|
+
file_name: str | None = None,
|
|
228
|
+
name_contains: str | None = None,
|
|
229
|
+
content_contains: str | None = None,
|
|
230
|
+
query_embedding: list[float] | None = None,
|
|
231
|
+
min_similarity: float = 0.3,
|
|
232
|
+
) -> int:
|
|
233
|
+
query, params = self.__build_documents_query(
|
|
234
|
+
file_name,
|
|
235
|
+
name_contains,
|
|
236
|
+
content_contains,
|
|
237
|
+
query_embedding,
|
|
238
|
+
min_similarity,
|
|
239
|
+
)
|
|
240
|
+
with self.__get_connection() as conn, conn.cursor() as cur:
|
|
241
|
+
cur.execute(f"SELECT COUNT(*) FROM ({query}) AS documents", params) # noqa: S608
|
|
242
|
+
return cur.fetchone()[0]
|
|
243
|
+
|
|
244
|
+
with self.__get_connection() as conn, conn.cursor() as cur:
|
|
245
|
+
cur.execute(
|
|
246
|
+
"""
|
|
247
|
+
SELECT
|
|
248
|
+
file_name,
|
|
249
|
+
COUNT(*) AS chunk_count,
|
|
250
|
+
SUM(LENGTH(content)) AS total_characters,
|
|
251
|
+
json_agg(
|
|
252
|
+
json_build_object('chunk_index', chunk_index, 'content', content)
|
|
253
|
+
ORDER BY chunk_index
|
|
254
|
+
) AS chunks
|
|
255
|
+
FROM document_chunks
|
|
256
|
+
WHERE file_name ILIKE %(contains)s OR %(name)s <%% file_name
|
|
257
|
+
GROUP BY file_name
|
|
258
|
+
ORDER BY
|
|
259
|
+
LOWER(file_name) = LOWER(%(name)s) DESC,
|
|
260
|
+
file_name ILIKE %(prefix)s DESC,
|
|
261
|
+
file_name ASC
|
|
262
|
+
LIMIT %(limit)s
|
|
263
|
+
""",
|
|
264
|
+
params,
|
|
265
|
+
)
|
|
266
|
+
rows = cur.fetchall()
|
|
267
|
+
return [
|
|
268
|
+
{
|
|
269
|
+
"file_name": r[0],
|
|
270
|
+
"chunk_count": r[1],
|
|
271
|
+
"total_characters": r[2],
|
|
272
|
+
"chunks": r[3],
|
|
273
|
+
}
|
|
274
|
+
for r in rows
|
|
275
|
+
]
|
|
276
|
+
|
|
277
|
+
def create_conversation(self, title: str | None = None) -> str:
|
|
278
|
+
with self.__get_connection() as conn:
|
|
279
|
+
with conn.cursor() as cur:
|
|
280
|
+
cur.execute(
|
|
281
|
+
"INSERT INTO conversations (title) VALUES (%s) RETURNING id",
|
|
282
|
+
(title,),
|
|
283
|
+
)
|
|
284
|
+
conversation_id = cur.fetchone()[0]
|
|
285
|
+
conn.commit()
|
|
286
|
+
return str(conversation_id)
|
|
287
|
+
|
|
288
|
+
def add_messages(self, conversation_id: str, messages: list[dict]) -> None:
|
|
289
|
+
"""Append OpenAI-format messages to a conversation, after any already saved."""
|
|
290
|
+
with self.__get_connection() as conn:
|
|
291
|
+
with conn.cursor() as cur:
|
|
292
|
+
# Lock the conversation so concurrent appends can't take the same positions
|
|
293
|
+
cur.execute(
|
|
294
|
+
"UPDATE conversations SET updated_at = now() WHERE id = %s RETURNING id",
|
|
295
|
+
(conversation_id,),
|
|
296
|
+
)
|
|
297
|
+
if cur.fetchone() is None:
|
|
298
|
+
raise ValueError(f"Conversation {conversation_id} not found")
|
|
299
|
+
|
|
300
|
+
cur.execute(
|
|
301
|
+
"SELECT COALESCE(MAX(position), -1) FROM conversation_messages WHERE conversation_id = %s",
|
|
302
|
+
(conversation_id,),
|
|
303
|
+
)
|
|
304
|
+
next_position = cur.fetchone()[0] + 1
|
|
305
|
+
|
|
306
|
+
cur.executemany(
|
|
307
|
+
"""
|
|
308
|
+
INSERT INTO conversation_messages
|
|
309
|
+
(conversation_id, position, role, content, tool_calls, tool_call_id)
|
|
310
|
+
VALUES (%s, %s, %s, %s, %s, %s)
|
|
311
|
+
""",
|
|
312
|
+
[
|
|
313
|
+
(
|
|
314
|
+
conversation_id,
|
|
315
|
+
next_position + i,
|
|
316
|
+
m["role"],
|
|
317
|
+
m.get("content"),
|
|
318
|
+
Jsonb(m["tool_calls"]) if m.get("tool_calls") else None,
|
|
319
|
+
m.get("tool_call_id"),
|
|
320
|
+
)
|
|
321
|
+
for i, m in enumerate(messages)
|
|
322
|
+
],
|
|
323
|
+
)
|
|
324
|
+
conn.commit()
|
|
325
|
+
|
|
326
|
+
def get_conversation_by_id(self, conversation_id: str) -> dict | None:
|
|
327
|
+
"""Return the conversation with its messages in order, ready to pass back to OpenAI."""
|
|
328
|
+
with self.__get_connection() as conn, conn.cursor() as cur:
|
|
329
|
+
cur.execute(
|
|
330
|
+
"""
|
|
331
|
+
SELECT
|
|
332
|
+
c.id,
|
|
333
|
+
c.title,
|
|
334
|
+
c.created_at,
|
|
335
|
+
c.updated_at,
|
|
336
|
+
COALESCE(
|
|
337
|
+
json_agg(
|
|
338
|
+
json_strip_nulls(json_build_object(
|
|
339
|
+
'role', m.role,
|
|
340
|
+
'content', m.content,
|
|
341
|
+
'tool_calls', m.tool_calls,
|
|
342
|
+
'tool_call_id', m.tool_call_id
|
|
343
|
+
))
|
|
344
|
+
ORDER BY m.position
|
|
345
|
+
) FILTER (WHERE m.id IS NOT NULL),
|
|
346
|
+
'[]'
|
|
347
|
+
) AS messages
|
|
348
|
+
FROM conversations c
|
|
349
|
+
LEFT JOIN conversation_messages m ON m.conversation_id = c.id
|
|
350
|
+
WHERE c.id = %s
|
|
351
|
+
GROUP BY c.id
|
|
352
|
+
""",
|
|
353
|
+
(conversation_id,),
|
|
354
|
+
)
|
|
355
|
+
row = cur.fetchone()
|
|
356
|
+
|
|
357
|
+
if row is None:
|
|
358
|
+
return None
|
|
359
|
+
return {
|
|
360
|
+
"id": str(row[0]),
|
|
361
|
+
"title": row[1],
|
|
362
|
+
"created_at": row[2],
|
|
363
|
+
"updated_at": row[3],
|
|
364
|
+
"messages": row[4],
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
def list_conversations(self, page: int = 1, page_size: int = 20) -> list[dict]:
|
|
368
|
+
"""List conversations, most recently active first."""
|
|
369
|
+
if page < 1:
|
|
370
|
+
raise ValueError("page must be 1 or greater")
|
|
371
|
+
if not 1 <= page_size <= self.MAX_PAGE_SIZE:
|
|
372
|
+
raise ValueError(f"page_size must be between 1 and {self.MAX_PAGE_SIZE}")
|
|
373
|
+
with self.__get_connection() as conn:
|
|
374
|
+
with conn.cursor() as cur:
|
|
375
|
+
cur.execute(
|
|
376
|
+
"""
|
|
377
|
+
SELECT id, title
|
|
378
|
+
FROM conversations
|
|
379
|
+
ORDER BY updated_at DESC, id
|
|
380
|
+
LIMIT %s OFFSET %s
|
|
381
|
+
""",
|
|
382
|
+
(page_size, (page - 1) * page_size),
|
|
383
|
+
)
|
|
384
|
+
rows = cur.fetchall()
|
|
385
|
+
return [{"id": str(r[0]), "title": r[1]} for r in rows]
|
docuquery/ingest.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
from pypdf import PdfReader
|
|
5
|
+
from sentence_transformers import SentenceTransformer
|
|
6
|
+
|
|
7
|
+
from docuquery.chunker import recursive_semantic_chunking
|
|
8
|
+
from docuquery.constants import SENTENCE_TRANSFORMER_MODEL
|
|
9
|
+
from docuquery.database import Database
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DocumentIngestor:
|
|
13
|
+
def __init__(self) -> None:
|
|
14
|
+
self.embedder = SentenceTransformer(SENTENCE_TRANSFORMER_MODEL)
|
|
15
|
+
self.db = Database()
|
|
16
|
+
|
|
17
|
+
def load_text(self, path: str) -> str:
|
|
18
|
+
if Path(path).suffix.lower() == ".pdf":
|
|
19
|
+
reader = PdfReader(path)
|
|
20
|
+
return "\n\n".join(page.extract_text() or "" for page in reader.pages)
|
|
21
|
+
try:
|
|
22
|
+
return Path(path).read_text(encoding="utf-8")
|
|
23
|
+
except UnicodeDecodeError:
|
|
24
|
+
raise click.ClickException(
|
|
25
|
+
f"'{path}' is not a PDF or a UTF-8 text file"
|
|
26
|
+
) from None
|
|
27
|
+
|
|
28
|
+
def process_and_store_document(self, document_path: str) -> None:
|
|
29
|
+
"""Process a document, chunk it, and store in database."""
|
|
30
|
+
click.echo(f"[*] Processing document: {document_path}")
|
|
31
|
+
|
|
32
|
+
content = self.load_text(document_path)
|
|
33
|
+
|
|
34
|
+
file_name = Path(document_path).name
|
|
35
|
+
|
|
36
|
+
if not content.strip():
|
|
37
|
+
raise click.ClickException(
|
|
38
|
+
f"'{document_path}' has no text. If it is a scanned PDF, use OCR first."
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
click.echo("[*] Chunking content...")
|
|
42
|
+
|
|
43
|
+
# Chunk the content
|
|
44
|
+
chunks = recursive_semantic_chunking(content)
|
|
45
|
+
|
|
46
|
+
click.echo(f"[*] Ingesting '{file_name}': Split into {len(chunks)} chunks.")
|
|
47
|
+
click.echo(f"[*] Generated {len(chunks)} chunks.")
|
|
48
|
+
|
|
49
|
+
embeddings = self.embedder.encode(chunks, show_progress_bar=True)
|
|
50
|
+
self.db.replace_document(file_name, chunks, embeddings)
|
|
51
|
+
|
|
52
|
+
click.secho(f"[+] Successfully indexed {file_name}!", fg="green")
|