iatoolkit 0.91.1__py3-none-any.whl → 1.4.2__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.
- iatoolkit/__init__.py +6 -4
- iatoolkit/base_company.py +0 -16
- iatoolkit/cli_commands.py +3 -14
- iatoolkit/common/exceptions.py +1 -0
- iatoolkit/common/interfaces/__init__.py +0 -0
- iatoolkit/common/interfaces/asset_storage.py +34 -0
- iatoolkit/common/interfaces/database_provider.py +38 -0
- iatoolkit/common/model_registry.py +159 -0
- iatoolkit/common/routes.py +42 -5
- iatoolkit/common/util.py +11 -12
- iatoolkit/company_registry.py +5 -0
- iatoolkit/core.py +51 -20
- iatoolkit/infra/llm_providers/__init__.py +0 -0
- iatoolkit/infra/llm_providers/deepseek_adapter.py +278 -0
- iatoolkit/infra/{gemini_adapter.py → llm_providers/gemini_adapter.py} +11 -17
- iatoolkit/infra/{openai_adapter.py → llm_providers/openai_adapter.py} +41 -7
- iatoolkit/infra/llm_proxy.py +235 -134
- iatoolkit/infra/llm_response.py +5 -0
- iatoolkit/locales/en.yaml +124 -2
- iatoolkit/locales/es.yaml +122 -0
- iatoolkit/repositories/database_manager.py +44 -19
- iatoolkit/repositories/document_repo.py +7 -0
- iatoolkit/repositories/filesystem_asset_repository.py +36 -0
- iatoolkit/repositories/llm_query_repo.py +2 -0
- iatoolkit/repositories/models.py +72 -79
- iatoolkit/repositories/profile_repo.py +59 -3
- iatoolkit/repositories/vs_repo.py +22 -24
- iatoolkit/services/company_context_service.py +88 -39
- iatoolkit/services/configuration_service.py +157 -68
- iatoolkit/services/dispatcher_service.py +21 -3
- iatoolkit/services/file_processor_service.py +0 -5
- iatoolkit/services/history_manager_service.py +43 -24
- iatoolkit/services/knowledge_base_service.py +412 -0
- iatoolkit/{infra/llm_client.py → services/llm_client_service.py} +38 -29
- iatoolkit/services/load_documents_service.py +18 -47
- iatoolkit/services/profile_service.py +32 -4
- iatoolkit/services/prompt_service.py +32 -30
- iatoolkit/services/query_service.py +51 -26
- iatoolkit/services/sql_service.py +105 -74
- iatoolkit/services/tool_service.py +26 -11
- iatoolkit/services/user_session_context_service.py +115 -63
- iatoolkit/static/js/chat_main.js +44 -4
- iatoolkit/static/js/chat_model_selector.js +227 -0
- iatoolkit/static/js/chat_onboarding_button.js +1 -1
- iatoolkit/static/js/chat_reload_button.js +4 -1
- iatoolkit/static/styles/chat_iatoolkit.css +58 -2
- iatoolkit/static/styles/llm_output.css +34 -1
- iatoolkit/system_prompts/query_main.prompt +26 -2
- iatoolkit/templates/base.html +13 -0
- iatoolkit/templates/chat.html +44 -2
- iatoolkit/templates/onboarding_shell.html +0 -1
- iatoolkit/views/base_login_view.py +7 -2
- iatoolkit/views/chat_view.py +76 -0
- iatoolkit/views/load_company_configuration_api_view.py +49 -0
- iatoolkit/views/load_document_api_view.py +14 -10
- iatoolkit/views/login_view.py +8 -3
- iatoolkit/views/rag_api_view.py +216 -0
- iatoolkit/views/users_api_view.py +33 -0
- {iatoolkit-0.91.1.dist-info → iatoolkit-1.4.2.dist-info}/METADATA +4 -4
- {iatoolkit-0.91.1.dist-info → iatoolkit-1.4.2.dist-info}/RECORD +64 -56
- iatoolkit/repositories/tasks_repo.py +0 -52
- iatoolkit/services/search_service.py +0 -55
- iatoolkit/services/tasks_service.py +0 -188
- iatoolkit/views/tasks_api_view.py +0 -72
- iatoolkit/views/tasks_review_api_view.py +0 -55
- {iatoolkit-0.91.1.dist-info → iatoolkit-1.4.2.dist-info}/WHEEL +0 -0
- {iatoolkit-0.91.1.dist-info → iatoolkit-1.4.2.dist-info}/licenses/LICENSE +0 -0
- {iatoolkit-0.91.1.dist-info → iatoolkit-1.4.2.dist-info}/licenses/LICENSE_COMMUNITY.md +0 -0
- {iatoolkit-0.91.1.dist-info → iatoolkit-1.4.2.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
# Copyright (c) 2024 Fernando Libedinsky
|
|
2
|
+
# Product: IAToolkit
|
|
3
|
+
#
|
|
4
|
+
# IAToolkit is open source software.
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
from iatoolkit.repositories.models import Document, VSDoc, Company, DocumentStatus
|
|
8
|
+
from iatoolkit.repositories.document_repo import DocumentRepo
|
|
9
|
+
from iatoolkit.repositories.vs_repo import VSRepo
|
|
10
|
+
from iatoolkit.repositories.models import CollectionType
|
|
11
|
+
from iatoolkit.services.document_service import DocumentService
|
|
12
|
+
from iatoolkit.services.profile_service import ProfileService
|
|
13
|
+
from iatoolkit.services.i18n_service import I18nService
|
|
14
|
+
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
|
15
|
+
from sqlalchemy import desc
|
|
16
|
+
from typing import Dict
|
|
17
|
+
from iatoolkit.common.exceptions import IAToolkitException
|
|
18
|
+
import base64
|
|
19
|
+
import logging
|
|
20
|
+
import hashlib
|
|
21
|
+
from typing import List, Optional, Union
|
|
22
|
+
from datetime import datetime
|
|
23
|
+
from injector import inject
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class KnowledgeBaseService:
|
|
27
|
+
"""
|
|
28
|
+
Central service for managing the RAG (Retrieval-Augmented Generation) Knowledge Base.
|
|
29
|
+
Orchestrates ingestion (OCR -> Split -> Embed -> Store), retrieval, and management.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
@inject
|
|
33
|
+
def __init__(self,
|
|
34
|
+
document_repo: DocumentRepo,
|
|
35
|
+
vs_repo: VSRepo,
|
|
36
|
+
document_service: DocumentService,
|
|
37
|
+
profile_service: ProfileService,
|
|
38
|
+
i18n_service: I18nService):
|
|
39
|
+
self.document_repo = document_repo
|
|
40
|
+
self.vs_repo = vs_repo
|
|
41
|
+
self.document_service = document_service
|
|
42
|
+
self.profile_service = profile_service
|
|
43
|
+
self.i18n_service = i18n_service
|
|
44
|
+
|
|
45
|
+
# Configure LangChain for intelligent text splitting
|
|
46
|
+
self.text_splitter = RecursiveCharacterTextSplitter(
|
|
47
|
+
chunk_size=1000,
|
|
48
|
+
chunk_overlap=100,
|
|
49
|
+
separators=["\n\n", "\n", ".", " ", ""]
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def ingest_document_sync(self,
|
|
53
|
+
company: Company,
|
|
54
|
+
filename: str,
|
|
55
|
+
content: bytes,
|
|
56
|
+
user_identifier: str = None,
|
|
57
|
+
metadata: dict = None,
|
|
58
|
+
collection: str = None) -> Document:
|
|
59
|
+
"""
|
|
60
|
+
Synchronously processes a document through the entire RAG pipeline:
|
|
61
|
+
1. Saves initial metadata and raw content (base64) to the SQL Document table.
|
|
62
|
+
2. Extracts text using DocumentService (handles OCR, PDF, DOCX).
|
|
63
|
+
3. Splits the text into semantic chunks using LangChain.
|
|
64
|
+
4. Vectorizes and saves chunks to the Vector Store (VSRepo).
|
|
65
|
+
5. Updates the document status to ACTIVE or FAILED.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
company: The company owning the document.
|
|
69
|
+
filename: Original filename.
|
|
70
|
+
content: Raw bytes of the file.
|
|
71
|
+
metadata: Optional dictionary with additional info (e.g., document_type).
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
The created Document object.
|
|
75
|
+
"""
|
|
76
|
+
if not metadata:
|
|
77
|
+
metadata = {}
|
|
78
|
+
|
|
79
|
+
# --- Logic for Collection ---
|
|
80
|
+
# priority: 1. method parameter 2. metadata
|
|
81
|
+
collection_name = collection or metadata.get('collection')
|
|
82
|
+
collection_type_id = self._get_collection_type_id(company.id, collection_name)
|
|
83
|
+
|
|
84
|
+
# 1. Calculate SHA-256 hash of the content
|
|
85
|
+
file_hash = hashlib.sha256(content).hexdigest()
|
|
86
|
+
|
|
87
|
+
# 2. Check for duplicates by HASH (Content deduplication)
|
|
88
|
+
# If the same content exists (even with a different filename), we skip processing.
|
|
89
|
+
existing_doc = self.document_repo.get_by_hash(company.id, file_hash)
|
|
90
|
+
if existing_doc:
|
|
91
|
+
msg = self.i18n_service.t('rag.ingestion.duplicate', filename=filename, company_short_name=company.short_name)
|
|
92
|
+
logging.info(msg)
|
|
93
|
+
return existing_doc
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# 3. Create initial record with PENDING status
|
|
97
|
+
try:
|
|
98
|
+
# Encode to b64 for safe storage in DB if needed later for download
|
|
99
|
+
content_b64 = base64.b64encode(content).decode('utf-8')
|
|
100
|
+
|
|
101
|
+
new_doc = Document(
|
|
102
|
+
company_id=company.id,
|
|
103
|
+
collection_type_id=collection_type_id,
|
|
104
|
+
filename=filename,
|
|
105
|
+
hash=file_hash,
|
|
106
|
+
user_identifier=user_identifier,
|
|
107
|
+
content="", # Will be populated after text extraction
|
|
108
|
+
content_b64=content_b64,
|
|
109
|
+
meta=metadata,
|
|
110
|
+
status=DocumentStatus.PENDING
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
self.document_repo.insert(new_doc)
|
|
114
|
+
|
|
115
|
+
# 3. Start processing (Extraction + Vectorization)
|
|
116
|
+
self._process_document_content(company.short_name, new_doc, content)
|
|
117
|
+
|
|
118
|
+
return new_doc
|
|
119
|
+
|
|
120
|
+
except Exception as e:
|
|
121
|
+
logging.exception(f"Error initializing document ingestion for {filename}: {e}")
|
|
122
|
+
error_msg = self.i18n_service.t('rag.ingestion.failed', error=str(e))
|
|
123
|
+
|
|
124
|
+
raise IAToolkitException(IAToolkitException.ErrorType.LOAD_DOCUMENT_ERROR, error_msg)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _process_document_content(self, company_short_name: str, document: Document, raw_content: bytes):
|
|
128
|
+
"""
|
|
129
|
+
Internal method to handle the heavy lifting of extraction and vectorization.
|
|
130
|
+
Updates the document status directly via the session.
|
|
131
|
+
"""
|
|
132
|
+
session = self.document_repo.session
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
# A. Update status to PROCESSING
|
|
136
|
+
document.status = DocumentStatus.PROCESSING
|
|
137
|
+
session.commit()
|
|
138
|
+
|
|
139
|
+
# B. Text Extraction (Uses existing service logic for OCR, etc.)
|
|
140
|
+
extracted_text = self.document_service.file_to_txt(document.filename, raw_content)
|
|
141
|
+
|
|
142
|
+
if not extracted_text:
|
|
143
|
+
raise ValueError(self.i18n_service.t('rag.ingestion.empty_text'))
|
|
144
|
+
|
|
145
|
+
# Update the extracted content in the original document record
|
|
146
|
+
document.content = extracted_text
|
|
147
|
+
|
|
148
|
+
# C. Splitting (LangChain)
|
|
149
|
+
chunks = self.text_splitter.split_text(extracted_text)
|
|
150
|
+
|
|
151
|
+
# D. Create VSDocs (Chunks)
|
|
152
|
+
# Note: The embedding generation happens inside VSRepo or can be explicit here
|
|
153
|
+
vs_docs = []
|
|
154
|
+
for chunk_text in chunks:
|
|
155
|
+
vs_doc = VSDoc(
|
|
156
|
+
company_id=document.company_id,
|
|
157
|
+
document_id=document.id,
|
|
158
|
+
text=chunk_text
|
|
159
|
+
)
|
|
160
|
+
vs_docs.append(vs_doc)
|
|
161
|
+
|
|
162
|
+
# E. Vector Storage
|
|
163
|
+
# We need the short_name so VSRepo knows which API Key to use for embeddings
|
|
164
|
+
self.vs_repo.add_document(company_short_name, vs_docs)
|
|
165
|
+
|
|
166
|
+
# F. Finalize
|
|
167
|
+
document.status = DocumentStatus.ACTIVE
|
|
168
|
+
session.commit()
|
|
169
|
+
logging.info(f"Successfully ingested {document.description} with {len(chunks)} chunks.")
|
|
170
|
+
|
|
171
|
+
except Exception as e:
|
|
172
|
+
session.rollback()
|
|
173
|
+
logging.error(f"Failed to process document {document.id}: {e}")
|
|
174
|
+
|
|
175
|
+
# Attempt to save the error state
|
|
176
|
+
try:
|
|
177
|
+
document.status = DocumentStatus.FAILED
|
|
178
|
+
document.error_message = str(e)
|
|
179
|
+
session.commit()
|
|
180
|
+
except:
|
|
181
|
+
pass # If error commit fails, we can't do much more
|
|
182
|
+
|
|
183
|
+
error_msg = self.i18n_service.t('rag.ingestion.processing_failed', error=str(e))
|
|
184
|
+
raise IAToolkitException(IAToolkitException.ErrorType.LOAD_DOCUMENT_ERROR, error_msg)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def search(self, company_short_name: str, query: str, n_results: int = 5, metadata_filter: dict = None) -> str:
|
|
188
|
+
"""
|
|
189
|
+
Performs a semantic search against the vector store and formats the result as a context string for LLMs.
|
|
190
|
+
Replaces the legacy SearchService logic.
|
|
191
|
+
|
|
192
|
+
Args:
|
|
193
|
+
company_short_name: The target company.
|
|
194
|
+
query: The user's question or search term.
|
|
195
|
+
n_results: Max number of chunks to retrieve.
|
|
196
|
+
metadata_filter: Optional filter for document metadata.
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
Formatted string with context.
|
|
200
|
+
"""
|
|
201
|
+
company = self.profile_service.get_company_by_short_name(company_short_name)
|
|
202
|
+
if not company:
|
|
203
|
+
return f"error: {self.i18n_service.t('rag.search.company_not_found', company_short_name=company_short_name)}"
|
|
204
|
+
|
|
205
|
+
# Queries VSRepo (which typically uses pgvector/SQL underneath)
|
|
206
|
+
chunk_list = self.vs_repo.query(
|
|
207
|
+
company_short_name=company_short_name,
|
|
208
|
+
query_text=query,
|
|
209
|
+
n_results=n_results,
|
|
210
|
+
metadata_filter=metadata_filter
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
search_context = ''
|
|
214
|
+
for chunk in chunk_list:
|
|
215
|
+
# 'doc' here is a reconstructed Document object containing the chunk text
|
|
216
|
+
search_context += f'document "{chunk['filename']}"'
|
|
217
|
+
|
|
218
|
+
if chunk.get('meta') and 'document_type' in chunk.get('meta'):
|
|
219
|
+
doc_type = chunk.get('meta').get('document_type', '')
|
|
220
|
+
search_context += f' type: {doc_type}'
|
|
221
|
+
|
|
222
|
+
search_context += f': {chunk.get('text')}\n\n'
|
|
223
|
+
|
|
224
|
+
return search_context
|
|
225
|
+
|
|
226
|
+
def search_raw(self,
|
|
227
|
+
company_short_name: str,
|
|
228
|
+
query: str, n_results: int = 5,
|
|
229
|
+
collection: str = None,
|
|
230
|
+
metadata_filter: dict = None
|
|
231
|
+
) -> List[Dict]:
|
|
232
|
+
"""
|
|
233
|
+
Performs a semantic search and returns the list of Document objects (chunks).
|
|
234
|
+
Useful for UI displays where structured data is needed instead of a raw string context.
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
company_short_name: The target company.
|
|
238
|
+
query: The user's question or search term.
|
|
239
|
+
n_results: Max number of chunks to retrieve.
|
|
240
|
+
metadata_filter: Optional filter for document metadata.
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
List of Document objects found.
|
|
244
|
+
"""
|
|
245
|
+
company = self.profile_service.get_company_by_short_name(company_short_name)
|
|
246
|
+
if not company:
|
|
247
|
+
# We return empty list instead of error string for consistency
|
|
248
|
+
logging.warning(f"Company {company_short_name} not found during raw search.")
|
|
249
|
+
return []
|
|
250
|
+
|
|
251
|
+
# If collection name provided, resolve to ID or handle in VSRepo
|
|
252
|
+
collection_id = None
|
|
253
|
+
if collection:
|
|
254
|
+
collection_id = self._get_collection_type_id(company.id, collection)
|
|
255
|
+
if not collection_id:
|
|
256
|
+
logging.warning(f"Collection '{collection}' not found. Searching all.")
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# Queries VSRepo directly
|
|
260
|
+
chunk_list = self.vs_repo.query(
|
|
261
|
+
company_short_name=company_short_name,
|
|
262
|
+
query_text=query,
|
|
263
|
+
n_results=n_results,
|
|
264
|
+
metadata_filter=metadata_filter,
|
|
265
|
+
collection_id=collection_id,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
return chunk_list
|
|
269
|
+
|
|
270
|
+
def list_documents(self,
|
|
271
|
+
company_short_name: str,
|
|
272
|
+
status: Optional[Union[str, List[str]]] = None,
|
|
273
|
+
user_identifier: Optional[str] = None,
|
|
274
|
+
collection: str = None,
|
|
275
|
+
filename_keyword: Optional[str] = None,
|
|
276
|
+
from_date: Optional[datetime] = None,
|
|
277
|
+
to_date: Optional[datetime] = None,
|
|
278
|
+
limit: int = 100,
|
|
279
|
+
offset: int = 0) -> List[Document]:
|
|
280
|
+
"""
|
|
281
|
+
Retrieves a paginated list of documents based on various filters.
|
|
282
|
+
Used by the frontend to display the Knowledge Base grid.
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
company_short_name: Required. Filters by company.
|
|
286
|
+
status: Optional status enum value or list of values (e.g. 'active' or ['active', 'failed']).
|
|
287
|
+
user_identifier: Optional. Filters by the user who uploaded the document.
|
|
288
|
+
filename_keyword: Optional substring to search in filename.
|
|
289
|
+
from_date: Optional start date filter (created_at).
|
|
290
|
+
to_date: Optional end date filter (created_at).
|
|
291
|
+
limit: Pagination limit.
|
|
292
|
+
offset: Pagination offset.
|
|
293
|
+
|
|
294
|
+
Returns:
|
|
295
|
+
List of Document objects matching the criteria.
|
|
296
|
+
"""
|
|
297
|
+
session = self.document_repo.session
|
|
298
|
+
|
|
299
|
+
# Start building the query
|
|
300
|
+
query = session.query(Document).join(Company).filter(Company.short_name == company_short_name)
|
|
301
|
+
|
|
302
|
+
# Filter by status (single string or list)
|
|
303
|
+
if status:
|
|
304
|
+
if isinstance(status, list):
|
|
305
|
+
query = query.filter(Document.status.in_(status))
|
|
306
|
+
else:
|
|
307
|
+
query = query.filter(Document.status == status)
|
|
308
|
+
|
|
309
|
+
# filter by collection
|
|
310
|
+
if collection:
|
|
311
|
+
query = query.join(CollectionType).filter(CollectionType.name == collection)
|
|
312
|
+
|
|
313
|
+
# Filter by user identifier
|
|
314
|
+
if user_identifier:
|
|
315
|
+
query = query.filter(Document.user_identifier.ilike(f"%{user_identifier}%"))
|
|
316
|
+
|
|
317
|
+
if filename_keyword:
|
|
318
|
+
# Case-insensitive search
|
|
319
|
+
query = query.filter(Document.filename.ilike(f"%{filename_keyword}%"))
|
|
320
|
+
|
|
321
|
+
if from_date:
|
|
322
|
+
query = query.filter(Document.created_at >= from_date)
|
|
323
|
+
|
|
324
|
+
if to_date:
|
|
325
|
+
query = query.filter(Document.created_at <= to_date)
|
|
326
|
+
|
|
327
|
+
# Apply sorting (newest first) and pagination
|
|
328
|
+
query = query.order_by(desc(Document.created_at))
|
|
329
|
+
query = query.limit(limit).offset(offset)
|
|
330
|
+
|
|
331
|
+
return query.all()
|
|
332
|
+
|
|
333
|
+
def get_document_content(self, document_id: int) -> tuple[bytes, str]:
|
|
334
|
+
"""
|
|
335
|
+
Retrieves the raw content of a document and its filename.
|
|
336
|
+
|
|
337
|
+
Args:
|
|
338
|
+
document_id: ID of the document.
|
|
339
|
+
|
|
340
|
+
Returns:
|
|
341
|
+
A tuple containing (file_bytes, filename).
|
|
342
|
+
Returns (None, None) if document not found.
|
|
343
|
+
"""
|
|
344
|
+
doc = self.document_repo.get_by_id(document_id)
|
|
345
|
+
if not doc or not doc.content_b64:
|
|
346
|
+
return None, None
|
|
347
|
+
|
|
348
|
+
try:
|
|
349
|
+
file_bytes = base64.b64decode(doc.content_b64)
|
|
350
|
+
return file_bytes, doc.filename
|
|
351
|
+
except Exception as e:
|
|
352
|
+
logging.error(f"Error decoding content for document {document_id}: {e}")
|
|
353
|
+
raise IAToolkitException(IAToolkitException.ErrorType.FILE_FORMAT_ERROR,
|
|
354
|
+
f"Error reading file content: {e}")
|
|
355
|
+
|
|
356
|
+
def delete_document(self, document_id: int) -> bool:
|
|
357
|
+
"""
|
|
358
|
+
Deletes a document and its associated vectors.
|
|
359
|
+
Since vectors are linked via FK with ON DELETE CASCADE, deleting the Document record is sufficient.
|
|
360
|
+
|
|
361
|
+
Args:
|
|
362
|
+
document_id: The ID of the document to delete.
|
|
363
|
+
|
|
364
|
+
Returns:
|
|
365
|
+
True if deleted, False if not found.
|
|
366
|
+
"""
|
|
367
|
+
doc = self.document_repo.get_by_id(document_id)
|
|
368
|
+
if not doc:
|
|
369
|
+
return False
|
|
370
|
+
|
|
371
|
+
session = self.document_repo.session
|
|
372
|
+
try:
|
|
373
|
+
session.delete(doc)
|
|
374
|
+
session.commit()
|
|
375
|
+
return True
|
|
376
|
+
except Exception as e:
|
|
377
|
+
session.rollback()
|
|
378
|
+
logging.error(f"Error deleting document {document_id}: {e}")
|
|
379
|
+
raise IAToolkitException(IAToolkitException.ErrorType.DATABASE_ERROR,
|
|
380
|
+
f"Error deleting document: {e}")
|
|
381
|
+
|
|
382
|
+
def sync_collection_types(self, company_short_name: str, categories_config: list):
|
|
383
|
+
"""
|
|
384
|
+
This should be called during company initialization or configuration reload.
|
|
385
|
+
"""
|
|
386
|
+
company = self.profile_service.get_company_by_short_name(company_short_name)
|
|
387
|
+
if not company:
|
|
388
|
+
raise IAToolkitException(IAToolkitException.ErrorType.INVALID_NAME,
|
|
389
|
+
f'Company {company_short_name} not found')
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
session = self.document_repo.session
|
|
393
|
+
existing_types = session.query(CollectionType).filter_by(company_id=company.id).all()
|
|
394
|
+
existing_names = {ct.name: ct for ct in existing_types}
|
|
395
|
+
|
|
396
|
+
for cat_name in categories_config:
|
|
397
|
+
if cat_name not in existing_names:
|
|
398
|
+
new_type = CollectionType(company_id=company.id, name=cat_name)
|
|
399
|
+
session.add(new_type)
|
|
400
|
+
|
|
401
|
+
# Opcional: Eliminar los que ya no están en el config?
|
|
402
|
+
# Por seguridad de datos, mejor no borrar automáticamente, o marcarlos inactivos.
|
|
403
|
+
|
|
404
|
+
session.commit()
|
|
405
|
+
|
|
406
|
+
def _get_collection_type_id(self, company_id: int, collection_name: str) -> Optional[int]:
|
|
407
|
+
"""Helper to get ID by name"""
|
|
408
|
+
if not collection_name:
|
|
409
|
+
return None
|
|
410
|
+
session = self.document_repo.session
|
|
411
|
+
ct = session.query(CollectionType).filter_by(company_id=company_id, name=collection_name).first()
|
|
412
|
+
return ct.id if ct else None
|
|
@@ -8,6 +8,7 @@ from iatoolkit.repositories.models import Company, LLMQuery
|
|
|
8
8
|
from iatoolkit.repositories.llm_query_repo import LLMQueryRepo
|
|
9
9
|
from sqlalchemy.exc import SQLAlchemyError, OperationalError
|
|
10
10
|
from iatoolkit.common.util import Utility
|
|
11
|
+
from iatoolkit.common.model_registry import ModelRegistry
|
|
11
12
|
from injector import inject
|
|
12
13
|
import time
|
|
13
14
|
import markdown2
|
|
@@ -30,11 +31,13 @@ class llmClient:
|
|
|
30
31
|
@inject
|
|
31
32
|
def __init__(self,
|
|
32
33
|
llmquery_repo: LLMQueryRepo,
|
|
33
|
-
|
|
34
|
+
llm_proxy: LLMProxy,
|
|
35
|
+
model_registry: ModelRegistry,
|
|
34
36
|
util: Utility
|
|
35
37
|
):
|
|
36
38
|
self.llmquery_repo = llmquery_repo
|
|
37
|
-
self.
|
|
39
|
+
self.llm_proxy = llm_proxy
|
|
40
|
+
self.model_registry = model_registry
|
|
38
41
|
self.util = util
|
|
39
42
|
self._dispatcher = None # Cache for the lazy-loaded dispatcher
|
|
40
43
|
|
|
@@ -73,21 +76,15 @@ class llmClient:
|
|
|
73
76
|
response = None
|
|
74
77
|
sql_retry_count = 0
|
|
75
78
|
force_tool_name = None
|
|
76
|
-
reasoning = {}
|
|
77
79
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
text['verbosity'] = "low"
|
|
83
|
-
reasoning = {"effort": 'low'}
|
|
80
|
+
# Resolve per-model defaults and apply overrides (without mutating inputs).
|
|
81
|
+
request_params = self.model_registry.resolve_request_params(model=model, text=text)
|
|
82
|
+
text_payload = request_params["text"]
|
|
83
|
+
reasoning = request_params["reasoning"]
|
|
84
84
|
|
|
85
85
|
try:
|
|
86
86
|
start_time = time.time()
|
|
87
|
-
logging.info(f"calling llm model '{model}' with {self.count_tokens(context)} tokens...")
|
|
88
|
-
|
|
89
|
-
# get the proxy for the company
|
|
90
|
-
llm_proxy = self.llm_proxy_factory.create_for_company(company)
|
|
87
|
+
logging.info(f"calling llm model '{model}' with {self.count_tokens(context, context_history)} tokens...")
|
|
91
88
|
|
|
92
89
|
# this is the first call to the LLM on the iteration
|
|
93
90
|
try:
|
|
@@ -96,13 +93,14 @@ class llmClient:
|
|
|
96
93
|
"content": context
|
|
97
94
|
}]
|
|
98
95
|
|
|
99
|
-
response = llm_proxy.create_response(
|
|
96
|
+
response = self.llm_proxy.create_response(
|
|
97
|
+
company_short_name=company.short_name,
|
|
100
98
|
model=model,
|
|
99
|
+
input=input_messages,
|
|
101
100
|
previous_response_id=previous_response_id,
|
|
102
101
|
context_history=context_history,
|
|
103
|
-
input=input_messages,
|
|
104
102
|
tools=tools,
|
|
105
|
-
text=
|
|
103
|
+
text=text_payload,
|
|
106
104
|
reasoning=reasoning,
|
|
107
105
|
)
|
|
108
106
|
stats = self.get_stats(response)
|
|
@@ -130,8 +128,14 @@ class llmClient:
|
|
|
130
128
|
# execute the function call through the dispatcher
|
|
131
129
|
fcall_time = time.time()
|
|
132
130
|
function_name = tool_call.name
|
|
133
|
-
|
|
134
|
-
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
args = json.loads(tool_call.arguments)
|
|
134
|
+
except Exception as e:
|
|
135
|
+
logging.error(f"[Dispatcher] json.loads failed: {e}")
|
|
136
|
+
raise
|
|
137
|
+
logging.debug(f"[Dispatcher] Parsed args = {args}")
|
|
138
|
+
|
|
135
139
|
try:
|
|
136
140
|
result = self.dispatcher.dispatch(
|
|
137
141
|
company_short_name=company.short_name,
|
|
@@ -163,6 +167,7 @@ class llmClient:
|
|
|
163
167
|
input_messages.append({
|
|
164
168
|
"type": "function_call_output",
|
|
165
169
|
"call_id": tool_call.call_id,
|
|
170
|
+
"status": "completed",
|
|
166
171
|
"output": str(result)
|
|
167
172
|
})
|
|
168
173
|
function_calls = True
|
|
@@ -173,7 +178,7 @@ class llmClient:
|
|
|
173
178
|
f_calls.append(f_call_identity)
|
|
174
179
|
f_call_time += elapsed
|
|
175
180
|
|
|
176
|
-
logging.info(f"[{company.short_name}]
|
|
181
|
+
logging.info(f"[{company.short_name}] end execution of tool: {function_name} in {elapsed:.1f} secs.")
|
|
177
182
|
|
|
178
183
|
if not function_calls:
|
|
179
184
|
break # no more function calls, the answer to send back to llm
|
|
@@ -183,7 +188,8 @@ class llmClient:
|
|
|
183
188
|
if force_tool_name:
|
|
184
189
|
tool_choice_value = "required"
|
|
185
190
|
|
|
186
|
-
response = llm_proxy.create_response(
|
|
191
|
+
response = self.llm_proxy.create_response(
|
|
192
|
+
company_short_name=company.short_name,
|
|
187
193
|
model=model,
|
|
188
194
|
input=input_messages,
|
|
189
195
|
previous_response_id=response.id,
|
|
@@ -191,7 +197,7 @@ class llmClient:
|
|
|
191
197
|
reasoning=reasoning,
|
|
192
198
|
tool_choice=tool_choice_value,
|
|
193
199
|
tools=tools,
|
|
194
|
-
text=
|
|
200
|
+
text=text_payload,
|
|
195
201
|
)
|
|
196
202
|
stats_fcall = self.add_stats(stats_fcall, self.get_stats(response))
|
|
197
203
|
|
|
@@ -203,9 +209,11 @@ class llmClient:
|
|
|
203
209
|
# decode the LLM response
|
|
204
210
|
decoded_response = self.decode_response(response)
|
|
205
211
|
|
|
212
|
+
# Extract reasoning from the final response object
|
|
213
|
+
final_reasoning = getattr(response, 'reasoning_content', '')
|
|
214
|
+
|
|
206
215
|
# save the query and response
|
|
207
216
|
query = LLMQuery(user_identifier=user_identifier,
|
|
208
|
-
task_id=0,
|
|
209
217
|
company_id=company.id,
|
|
210
218
|
query=question,
|
|
211
219
|
output=decoded_response.get('answer', ''),
|
|
@@ -230,6 +238,7 @@ class llmClient:
|
|
|
230
238
|
'response_id': response.id,
|
|
231
239
|
'query_id': query.id,
|
|
232
240
|
'model': model,
|
|
241
|
+
'reasoning_content': final_reasoning,
|
|
233
242
|
}
|
|
234
243
|
except SQLAlchemyError as db_error:
|
|
235
244
|
# rollback
|
|
@@ -244,11 +253,10 @@ class llmClient:
|
|
|
244
253
|
|
|
245
254
|
# log the error in the llm_query table
|
|
246
255
|
query = LLMQuery(user_identifier=user_identifier,
|
|
247
|
-
task_id=0,
|
|
248
256
|
company_id=company.id,
|
|
249
257
|
query=question,
|
|
250
258
|
output=error_message,
|
|
251
|
-
response=
|
|
259
|
+
response={},
|
|
252
260
|
valid_response=False,
|
|
253
261
|
function_calls=f_calls,
|
|
254
262
|
)
|
|
@@ -269,14 +277,15 @@ class llmClient:
|
|
|
269
277
|
|
|
270
278
|
logging.info(f"initializing model '{model}' with company context: {self.count_tokens(company_base_context)} tokens...")
|
|
271
279
|
|
|
272
|
-
llm_proxy = self.llm_proxy_factory.create_for_company(company)
|
|
273
280
|
try:
|
|
274
|
-
response = llm_proxy.create_response(
|
|
281
|
+
response = self.llm_proxy.create_response(
|
|
282
|
+
company_short_name=company.short_name,
|
|
275
283
|
model=model,
|
|
276
284
|
input=[{
|
|
277
285
|
"role": "system",
|
|
278
286
|
"content": company_base_context
|
|
279
|
-
}]
|
|
287
|
+
}],
|
|
288
|
+
|
|
280
289
|
)
|
|
281
290
|
|
|
282
291
|
except Exception as e:
|
|
@@ -423,7 +432,7 @@ class llmClient:
|
|
|
423
432
|
html_answer = markdown2.markdown(answer).replace("\n", "")
|
|
424
433
|
return html_answer
|
|
425
434
|
|
|
426
|
-
def count_tokens(self, text):
|
|
435
|
+
def count_tokens(self, text, history = []):
|
|
427
436
|
# Codifica el texto y cuenta la cantidad de tokens
|
|
428
|
-
tokens = self.encoding.encode(text)
|
|
437
|
+
tokens = self.encoding.encode(text + json.dumps(history))
|
|
429
438
|
return len(tokens)
|