refactored connection token management and document handling

This commit is contained in:
ValueOn AG 2025-08-20 00:01:13 +02:00
parent 9d19b02fbb
commit db13db0f83
21 changed files with 1406 additions and 1691 deletions

View file

@ -1,114 +0,0 @@
# Google OAuth 2.0 Setup Guide for PowerOn
## Overview
This guide explains how to set up Google OAuth 2.0 authentication for the PowerOn application.
## Prerequisites
- A Google account
- Access to Google Cloud Console (https://console.cloud.google.com/)
## Step 1: Create a Google Cloud Project
1. Go to [Google Cloud Console](https://console.cloud.google.com/)
2. Click on the project dropdown at the top of the page
3. Click "New Project"
4. Enter a project name (e.g., "PowerOn OAuth")
5. Click "Create"
## Step 2: Enable Google+ API
1. In your new project, go to "APIs & Services" > "Library"
2. Search for "Google+ API" or "Google Identity"
3. Click on "Google+ API" and click "Enable"
## Step 3: Create OAuth 2.0 Credentials
1. Go to "APIs & Services" > "Credentials"
2. Click "Create Credentials" > "OAuth client ID"
3. If prompted, configure the OAuth consent screen first:
- Choose "External" user type
- Fill in the required fields (App name, User support email, Developer contact information)
- Add scopes: `https://www.googleapis.com/auth/userinfo.profile`, `https://www.googleapis.com/auth/userinfo.email`
- Add test users if needed
- Click "Save and Continue" through all sections
4. Back to creating OAuth client ID:
- Application type: "Web application"
- Name: "PowerOn Web Client"
- Authorized redirect URIs: Add your redirect URI
- For development: `http://localhost:8000/api/google/auth/callback`
- For production: `https://yourdomain.com/api/google/auth/callback`
5. Click "Create"
6. **Important**: Copy the Client ID and Client Secret - you'll need these for the next step
## Step 4: Configure PowerOn Application
1. Open your environment file (`gateway/env_dev.env` for development)
2. Replace the placeholder values with your actual Google OAuth credentials:
```env
# Google OAuth Configuration
Service_GOOGLE_CLIENT_ID = your-actual-client-id-from-google-console
Service_GOOGLE_CLIENT_SECRET = your-actual-client-secret-from-google-console
Service_GOOGLE_REDIRECT_URI = http://localhost:8000/api/google/auth/callback
```
3. Save the file
4. Restart your PowerOn gateway server
## Step 5: Test the Configuration
1. Start your PowerOn application
2. Go to the Connections module
3. Click "Connect Google"
4. You should be redirected to Google's OAuth consent screen
5. After authorization, you should be redirected back to PowerOn
## Troubleshooting
### Common Issues
#### 1. "Missing required parameter: redirect_uri"
- **Cause**: Google OAuth client is not properly configured with the redirect URI
- **Solution**: Ensure the redirect URI in Google Cloud Console exactly matches your application's callback URL
#### 2. "Invalid client" error
- **Cause**: Client ID or Client Secret is incorrect
- **Solution**: Double-check the credentials in your environment file
#### 3. "Redirect URI mismatch" error
- **Cause**: The redirect URI in your OAuth request doesn't match what's configured in Google Cloud Console
- **Solution**: Ensure both URIs are identical (including protocol, domain, port, and path)
### Debug Steps
1. Check the PowerOn gateway logs for OAuth configuration details
2. Verify environment variables are loaded correctly
3. Ensure the Google OAuth client is configured for "Web application" type
4. Check that the redirect URI includes the full path: `/api/google/auth/callback`
## Security Notes
- **Never commit** your Google OAuth credentials to version control
- Use environment variables or secure configuration management
- Regularly rotate your client secrets
- Monitor OAuth usage in Google Cloud Console
## Production Considerations
For production deployment:
1. Use HTTPS for all OAuth redirects
2. Configure proper domain verification in Google Cloud Console
3. Set up monitoring and alerting for OAuth usage
4. Consider implementing additional security measures like PKCE (Proof Key for Code Exchange)
## Support
If you continue to experience issues:
1. Check the PowerOn gateway logs for detailed error messages
2. Verify your Google OAuth configuration in Google Cloud Console
3. Test with a simple OAuth flow to isolate the issue
4. Ensure your Google Cloud project has billing enabled (required for some APIs)

View file

@ -21,12 +21,34 @@ class DocumentGenerator:
Returns a list of processed document dictionaries.
"""
try:
documents = action_result.data.get("documents", [])
# Read documents from the standard documents field (not data.documents)
documents = action_result.documents if hasattr(action_result, 'documents') else []
if not documents:
logger.info(f"No documents found in action_result.documents for {action.execMethod}.{action.execAction}")
return []
logger.info(f"Processing {len(documents)} documents from action_result.documents")
# Check if documents are references (strings starting with "docItem:") or actual document objects
if documents and isinstance(documents[0], str) and documents[0].startswith("docItem:"):
# These are document references, resolve them to actual documents
logger.info(f"Resolving {len(documents)} document references to actual documents")
try:
actual_documents = self.service.getChatDocumentsFromDocumentList(documents)
logger.info(f"Resolved {len(actual_documents)} actual documents from references")
documents = actual_documents
except Exception as e:
logger.error(f"Error resolving document references: {str(e)}")
return []
processed_documents = []
for doc in documents:
processed_doc = self.processSingleDocument(doc, action)
if processed_doc:
processed_documents.append(processed_doc)
logger.info(f"Successfully processed {len(processed_documents)} documents")
return processed_documents
except Exception as e:
logger.error(f"Error processing action result documents: {str(e)}")
@ -61,6 +83,35 @@ class DocumentGenerator:
'content': getattr(doc, 'content', ''),
'document': doc
}
elif hasattr(doc, 'documentName') and doc.documentName:
# ActionDocument object with documentName attribute
base_filename = doc.documentName
mime_type = getattr(doc, 'mimeType', 'application/octet-stream')
content = getattr(doc, 'documentData', '')
# Add result label to filename for ActionDocument objects
if hasattr(action, 'execResultLabel') and action.execResultLabel:
result_label = action.execResultLabel.strip()
if result_label:
# Check if filename already starts with resultLabel to avoid duplication
if not base_filename.startswith(f"{result_label}-"):
base_filename = f"{result_label}-{base_filename}"
logger.info(f"Added resultLabel '{result_label}' as prefix to ActionDocument filename: {base_filename}")
else:
logger.info(f"ActionDocument filename already has resultLabel prefix: {base_filename}")
# Calculate file size from actual content
fileSize = len(str(content)) if content else 0
logger.info(f"Processed ActionDocument: {base_filename}, content length: {len(str(content))}, mimeType: {mime_type}")
return {
'filename': base_filename,
'fileSize': fileSize,
'mimeType': mime_type,
'content': content,
'document': doc
}
elif isinstance(doc, dict):
# Dictionary format document - handle both 'documentName' and 'filename' keys
base_filename = doc.get('documentName', doc.get('filename', ''))
@ -159,7 +210,7 @@ class DocumentGenerator:
"""
try:
logger.info(f"Creating documents from action result for {action.execMethod}.{action.execAction}")
logger.info(f"Action result data keys: {list(action_result.data.keys())}")
logger.info(f"Action result documents count: {len(action_result.documents) if action_result.documents else 0}")
processed_docs = self.processActionResultDocuments(action_result, action, workflow)
logger.info(f"Processed {len(processed_docs)} documents")

View file

@ -33,8 +33,19 @@ class TaskExecutionState:
"""Get available results from successful actions"""
results = []
for action in self.successful_actions:
if action.data and action.data.get('result'):
results.append(action.data['result'])
if action.documents:
# Extract text content from documents
for doc in action.documents:
if hasattr(doc, 'documentData'):
if isinstance(doc.documentData, dict):
result_text = doc.documentData.get("result", "")
elif isinstance(doc.documentData, str):
result_text = doc.documentData
else:
result_text = str(doc.documentData)
if result_text and result_text.strip():
results.append(result_text)
return results
def shouldRetryTask(self) -> bool:

View file

@ -474,8 +474,16 @@ class HandlingTasks:
step_result={
'successful_actions': sum(1 for result in action_results if result.success),
'total_actions': len(action_results),
'results': [result.data.get('result', '') for result in action_results if result.success],
'errors': [result.error for result in action_results if not result.success]
'results': [self._extractResultText(result) for result in action_results if result.success],
'errors': [result.error for result in action_results if not result.success],
'documents': [
{
'action_index': i,
'documents_count': len(result.documents) if hasattr(result, 'documents') and result.documents else 0,
'documents': result.documents if hasattr(result, 'documents') and result.documents else []
}
for i, result in enumerate(action_results)
]
}
)
# Check workflow status before calling AI service
@ -624,9 +632,17 @@ class HandlingTasks:
if result.success:
created_documents = self.documentGenerator.createDocumentsFromActionResult(result, action, workflow)
action.setSuccess()
action.result = result.data.get("result", "")
action.execResultLabel = result_label
await self.createActionMessage(action, result, workflow, result_label, created_documents, task_step, task_index)
# Extract result text from documents if available, otherwise use empty string
action.result = ""
if result.documents and len(result.documents) > 0:
# Try to get text content from the first document
first_doc = result.documents[0]
if hasattr(first_doc, 'documentData') and isinstance(first_doc.documentData, dict):
action.result = first_doc.documentData.get("result", "")
elif hasattr(first_doc, 'documentData') and isinstance(first_doc.documentData, str):
action.result = first_doc.documentData
action.execResultLabel = result.resultLabel or result_label
await self.createActionMessage(action, result, workflow, result.resultLabel or result_label, created_documents, task_step, task_index)
# Log action results
logger.info(f"✓ Action completed successfully")
@ -689,38 +705,20 @@ class HandlingTasks:
"type": "error"
})
# Extract document filenames for the ActionResult
document_filenames = []
for doc in created_documents:
if hasattr(doc, 'filename'):
document_filenames.append(doc.filename)
elif isinstance(doc, dict) and 'filename' in doc:
document_filenames.append(doc['filename'])
# Also include the original documents from the service result for validation
original_documents = result.data.get("documents", [])
# Log action summary
logger.info(f"=== TASK {task_num} ACTION {action_num} COMPLETED ===")
# Preserve the original documents field from the method result
# This ensures the standard document format is maintained
original_documents = result.documents if hasattr(result, 'documents') else []
# Extract result text from documents if available
result_text = self._extractResultText(result)
return ActionResult(
success=result.success,
data={
"result": result.data.get("result", ""),
"documents": created_documents, # Include actual document objects in data
"actionId": action.id,
"actionMethod": action.execMethod,
"actionName": action.execAction,
"resultLabel": result_label
},
documents=document_filenames, # Keep as filenames for the documents field
metadata={
"actionId": action.id,
"actionMethod": action.execMethod,
"actionName": action.execAction,
"resultLabel": result_label
},
validation={},
documents=original_documents, # Preserve original documents field from method result
resultLabel=result.resultLabel or result_label,
error=result.error or ""
)
except Exception as e:
@ -728,18 +726,8 @@ class HandlingTasks:
action.setError(str(e))
return ActionResult(
success=False,
data={
"actionId": action.id,
"actionMethod": action.execMethod,
"actionName": action.execAction,
"documents": []
},
metadata={
"actionId": action.id,
"actionMethod": action.execMethod,
"actionName": action.execAction
},
validation={},
documents=[], # Empty documents for error case
resultLabel=result_label,
error=str(e)
)
@ -901,4 +889,18 @@ class HandlingTasks:
return True
except Exception as e:
logger.error(f"Error validating actions: {str(e)}")
return False
return False
def _extractResultText(self, result: ActionResult) -> str:
"""Extract result text from ActionResult documents"""
if not result.success or not result.documents:
return ""
# Try to get text content from the first document
first_doc = result.documents[0]
if hasattr(first_doc, 'documentData') and isinstance(first_doc.documentData, dict):
return first_doc.documentData.get("result", "")
elif hasattr(first_doc, 'documentData') and isinstance(first_doc.documentData, str):
return first_doc.documentData
else:
return ""

View file

@ -5,6 +5,9 @@ import json
import logging
from typing import Any, Dict
# Set up logger
logger = logging.getLogger(__name__)
# Prompt creation helpers extracted from managerChat.py
def createTaskPlanningPrompt(context: Dict[str, Any]) -> str:
@ -45,21 +48,21 @@ REQUIRED JSON STRUCTURE:
}}
EXAMPLES OF GOOD TASK OBJECTIVES:
- \"Extract key information from documents for email preparation\"
- \"Draft professional email incorporating analyzed information\"
- \"Send email using specified email account\"
- \"Store email draft and confirmation in system\"
- \"Analyze documents and extract key insights for business communication\"
- \"Create professional business communication incorporating analyzed information\"
- \"Execute business communication using specified channels\"
- \"Document and store all business communication outcomes\"
EXAMPLES OF GOOD SUCCESS CRITERIA:
- \"Document analysis completed with key points identified\"
- \"Email draft created with professional tone and clear structure\"
- \"Email successfully sent with delivery confirmation\"
- \"All outputs properly stored and accessible for future use\"
- \"Key insights extracted and ready for business use\"
- \"Professional communication created with clear business value\"
- \"Business communication successfully delivered\"
- \"All outcomes properly documented and accessible\"
EXAMPLES OF BAD TASK OBJECTIVES:
- \"Open and read the PDF file\" (too granular)
- \"Identify table structure\" (technical detail)
- \"Convert data to CSV format\" (implementation detail)
- \"Read the PDF file\" (too granular - should be \"Analyze document content\")
- \"Convert data to CSV\" (implementation detail - should be \"Structure data for analysis\")
- \"Send email\" (too specific - should be \"Deliver business communication\")
NOTE: Respond with ONLY the JSON object. Do not include any explanatory text."""
@ -73,6 +76,7 @@ async def createActionDefinitionPrompt(context, service) -> str:
retry_count = context.retry_count or 0
previous_action_results = context.previous_action_results or []
previous_review_result = context.previous_review_result
previous_handover = getattr(context, 'previous_handover', None)
methodList = service.getMethodsList()
method_actions = {}
for sig in methodList:
@ -106,10 +110,17 @@ RETRY CONTEXT (Attempt {retry_count}):
Previous action results that failed or were incomplete:
"""
for i, result in enumerate(previous_action_results):
retry_context += f"- Action {i+1}: {result.actionMethod or 'unknown'}.{result.actionName or 'unknown'}\n"
retry_context += f"- Action {i+1}: ActionResult\n"
retry_context += f" Status: {result.success and 'success' or 'failed'}\n"
retry_context += f" Error: {result.error or 'None'}\n"
retry_context += f" Result: {(result.data.get('result', '') if result.data else '')[:100]}...\n"
# Check if result has documents and show document info
if hasattr(result, 'documents') and result.documents:
doc_info = f"Documents: {len(result.documents)} document(s)"
if result.documents[0].documentName:
doc_info += f" - {result.documents[0].documentName}"
retry_context += f" {doc_info}\n"
else:
retry_context += f" Documents: None\n"
if previous_review_result:
retry_context += f"""
Previous review feedback:
@ -169,6 +180,16 @@ SUCCESS CRITERIA: {success_criteria_str}
CONTEXT - Chat History:
{messageSummary}
WORKFLOW CONTEXT - Previous Messages Summary:
The following summarizes key information from previous workflow interactions to provide context for continued workflows:
- Previous user inputs and their outcomes
- Key decisions and findings from earlier tasks
- Document processing results and insights
- User preferences and requirements established
- Any constraints or limitations identified
This context helps ensure your actions build upon previous work and maintain consistency with the overall workflow objectives.
AVAILABLE METHODS AND ACTIONS (with signatures):
{available_methods_str}
@ -191,7 +212,12 @@ DOCUMENT REFERENCE EXAMPLES:
- Inventing message IDs instead of using actual document labels
PREVIOUS RESULTS: {previous_results_str}
IMPROVEMENTS NEEDED: {improvements_str}{retry_context}
IMPROVEMENTS NEEDED: {improvements_str}
PREVIOUS TASK HANDOVER CONTEXT:
{previous_handover.workflowSummary if previous_handover and previous_handover.workflowSummary else 'No previous task handover available'}
{retry_context}
ACTION GENERATION PRINCIPLES:
- Create meaningful actions per task step
@ -206,6 +232,13 @@ ACTION GENERATION PRINCIPLES:
- Address specific issues mentioned in previous review feedback
- When specifying expectedDocumentFormats, ensure AI prompts explicitly request pure data without markdown formatting
DOCUMENT ROUTING GUIDANCE:
- Each action should produce documents with a clear resultLabel for routing
- Use consistent naming: "task{{task_id}}_action{{action_number}}_{{descriptive_label}}"
- Ensure document flow: Action A produces documents that Action B can consume
- Document labels should be descriptive of content, not just "results" or "output"
- Consider what subsequent actions will need and structure outputs accordingly
INSTRUCTIONS:
- Generate actions to accomplish this task step using available documents, connections, and previous results
- Use docItem for single documents and docList labels for groups of documents as shown in AVAILABLE DOCUMENTS
@ -382,46 +415,46 @@ async def createResultReviewPrompt(review_context) -> str:
for action_result in (review_context.action_results or []):
documents_metadata = []
# FIX: Look for documents in the correct place - action_result.data.documents contains actual document objects
# action_result.documents only contains document references (strings)
documents_to_check = action_result.data.get("documents", [])
# Get document information from step_result.documents
action_index = len(step_result_serializable['action_results'])
step_documents = step_result.get('documents', [])
for doc in documents_to_check:
if hasattr(doc, 'filename'):
logger.debug(f"Processing action {action_index}: step_documents count = {len(step_documents)}")
if action_index < len(step_documents):
# Use the document information from step_result
step_doc_info = step_documents[action_index]
documents_count = step_doc_info.get('documents_count', 0)
documents_list = step_doc_info.get('documents', [])
logger.debug(f"Action {action_index}: documents_count = {documents_count}, documents_list length = {len(documents_list)}")
# Process the actual documents
for doc in documents_list:
# These are ActionDocument objects from ActionResult.documents
documents_metadata.append({
'filename': doc.filename,
'fileSize': getattr(doc, 'fileSize', 0),
'filename': doc.documentName or 'unknown',
'fileSize': len(str(doc.documentData or '')),
'mimeType': getattr(doc, 'mimeType', 'unknown')
})
elif isinstance(doc, dict):
documents_metadata.append({
'filename': doc.get('filename', 'unknown'),
'fileSize': doc.get('fileSize', 0),
'mimeType': doc.get('mimeType', 'unknown')
})
elif isinstance(doc, str):
# Handle case where documents are just filenames
documents_metadata.append({
'filename': doc,
'fileSize': 0,
'mimeType': 'unknown'
})
else:
logger.warning(f"Action {action_index}: No step_documents info found - this should not happen with the new architecture")
# No fallback - if step_result.documents is missing, we have a bug
serializable_action_result = {
'status': 'completed' if action_result.success else 'failed',
'result_summary': action_result.data.get('result', '')[:200] + '...' if len(action_result.data.get('result', '')) > 200 else action_result.data.get('result', ''),
'result_summary': action_result.resultLabel or 'Action completed successfully',
'error': action_result.error,
'resultLabel': action_result.data.get('resultLabel', ''),
'resultLabel': action_result.resultLabel or '',
'documents_count': len(documents_metadata),
'documents_metadata': documents_metadata,
'actionId': action_result.actionId,
'actionMethod': action_result.actionMethod,
'actionName': action_result.actionName,
'success_indicator': (
'documents' if len(documents_metadata) > 0 else
'text_result' if action_result.data.get('result', '').strip() else 'none'
'documents' if len(documents_metadata) > 0 else 'none'
)
}
logger.debug(f"Action {action_index}: Final documents_count = {len(documents_metadata)}")
step_result_serializable['action_results'].append(serializable_action_result)
step_result_json = json.dumps(step_result_serializable, indent=2, ensure_ascii=False)
success_criteria_str = ', '.join(task_step.success_criteria or [])

View file

@ -3,7 +3,7 @@ from typing import Dict, List, Optional, Any, Literal
from datetime import datetime, UTC
from pydantic import BaseModel, Field
import logging
from modules.interfaces.interfaceChatModel import ActionResult
from functools import wraps
import inspect
@ -41,7 +41,7 @@ class MethodBase:
sig = inspect.signature(attr)
params = {}
for param_name, param in sig.parameters.items():
if param_name not in ['self', 'parameters', 'authData']:
if param_name not in ['self', 'parameters']:
param_type = param.annotation if param.annotation != param.empty else Any
params[param_name] = {
'type': param_type,
@ -130,18 +130,6 @@ class MethodBase:
descriptions[lastParam] += " " + line
return descriptions, types
def _validateDocumentListParameter(self, parameters: Dict[str, Any], paramName: str = "documentList") -> bool:
"""Validate that documentList parameter is a list of strings"""
if paramName not in parameters:
return False
value = parameters[paramName]
if not isinstance(value, list):
return False
# Check that all items in the list are strings
return all(isinstance(item, str) for item in value)
def _extractMainDescription(self, docstring: str) -> str:
"""Extract main description from docstring"""
if not docstring:
@ -167,109 +155,4 @@ class MethodBase:
elif hasattr(type_annotation, '_name'):
return type_annotation._name
else:
return str(type_annotation)
async def execute(self, action: str, parameters: Dict[str, Any], authData: Optional[Dict[str, Any]] = None) -> ActionResult:
"""
Execute method action with authentication data
Args:
action: The action to execute
parameters: Action parameters
authData: Authentication data
Returns:
ActionResult containing execution results
Raises:
ValueError: If action is not supported
RuntimeError: If authentication fails
"""
try:
# Validate action
if action not in self.actions:
raise ValueError(f"Unsupported action: {action}")
# Validate parameters
if not await self.validateParameters(action, parameters):
return self._createResult(
success=False,
data={},
error="Invalid parameters"
)
# Validate authentication
if not self._validateAuth(authData):
return self._createResult(
success=False,
data={},
error="Authentication failed"
)
# Execute action
return await self._executeAction(action, parameters, authData)
except Exception as e:
self.logger.error(f"Error executing action {action}: {str(e)}")
return self._createResult(
success=False,
data={},
error=str(e)
)
async def _executeAction(self, action: str, parameters: Dict[str, Any], authData: Optional[Dict[str, Any]] = None) -> ActionResult:
"""Execute specific action - to be implemented by subclasses"""
raise NotImplementedError
async def validateParameters(self, action: str, parameters: Dict[str, Any]) -> bool:
"""Validate action parameters"""
try:
if action not in self.actions:
return False
actionDef = self.actions[action]
requiredParams = {k for k, v in actionDef['parameters'].items() if v['required']}
# Check required parameters
if not all(param in parameters for param in requiredParams):
return False
# Validate documentList parameter if present
if "documentList" in parameters:
if not self._validateDocumentListParameter(parameters, "documentList"):
self.logger.error("documentList parameter must be a list of strings")
return False
return True
except Exception as e:
self.logger.error(f"Error validating parameters: {str(e)}")
return False
async def rollback(self, action: str, parameters: Dict[str, Any], authData: Optional[Dict[str, Any]] = None) -> None:
"""Rollback action if needed"""
try:
await self._rollbackAction(action, parameters, authData)
except Exception as e:
self.logger.error(f"Error rolling back action {action}: {str(e)}")
raise
async def _rollbackAction(self, action: str, parameters: Dict[str, Any], authData: Optional[Dict[str, Any]] = None) -> None:
"""Rollback specific action - to be implemented by subclasses"""
pass
def _createResult(self, success: bool, data: Dict[str, Any], metadata: Optional[Dict[str, Any]] = None, error: Optional[str] = None) -> ActionResult:
"""Create a method result"""
return ActionResult(
success=success,
data=data,
metadata=metadata or {},
validation={},
error=error
)
def _addValidationMessage(self, result: ActionResult, message: str) -> None:
"""Add a validation message to the result"""
if 'messages' not in result.validation:
result.validation['messages'] = []
result.validation['messages'].append(message)
return str(type_annotation)

View file

@ -68,13 +68,13 @@ class ServiceCenter:
# Discover actions from public methods
actions = {}
for methodName, method in inspect.getmembers(type(methodInstance), predicate=inspect.iscoroutinefunction):
if not methodName.startswith('_') and methodName not in ['execute', 'validateParameters']:
if not methodName.startswith('_'):
# Bind the method to the instance
bound_method = method.__get__(methodInstance, type(methodInstance))
sig = inspect.signature(method)
params = {}
for paramName, param in sig.parameters.items():
if paramName not in ['self', 'authData']:
if paramName not in ['self']:
# Get parameter type
paramType = param.annotation if param.annotation != param.empty else Any
@ -719,9 +719,8 @@ Please provide a clear summary of this message."""
documentId=document.id
)
# Update objectId to match document ID
extractedContent.objectId = document.id
extractedContent.objectType = "ChatDocument"
# Note: ExtractedContent model only has 'id' and 'contents' fields
# No need to set objectId or objectType as they don't exist in the model
return extractedContent

View file

@ -191,6 +191,7 @@ class Token(BaseModel, ModelMixin):
id: Optional[str] = None
userId: str
authority: AuthAuthority
connectionId: Optional[str] = Field(None, description="ID of the connection this token belongs to")
tokenAccess: str
tokenType: str = "bearer"
expiresAt: float
@ -208,6 +209,7 @@ register_model_labels(
"id": {"en": "ID", "fr": "ID"},
"userId": {"en": "User ID", "fr": "ID utilisateur"},
"authority": {"en": "Authority", "fr": "Autorité"},
"connectionId": {"en": "Connection ID", "fr": "ID de connexion"},
"tokenAccess": {"en": "Access Token", "fr": "Jeton d'accès"},
"tokenType": {"en": "Token Type", "fr": "Type de jeton"},
"expiresAt": {"en": "Expires At", "fr": "Expire le"},

View file

@ -756,6 +756,7 @@ class AppObjects:
# Convert to dict and ensure all fields are properly set
token_dict = token.dict()
# Ensure userId is set to current user (this might override the token's userId)
token_dict["userId"] = self.currentUser.id
# Convert datetime objects to ISO format strings
@ -776,8 +777,8 @@ class AppObjects:
logger.error(f"Error saving token: {str(e)}")
raise
def getToken(self, authority: str) -> Optional[Token]:
"""Get the latest valid token for the current user and authority"""
def getToken(self, authority: str, auto_refresh: bool = True) -> Optional[Token]:
"""Get the latest valid token for the current user and authority, optionally auto-refresh if expired"""
try:
# Get tokens for this user and authority
tokens = self.db.getRecordset("tokens", recordFilter={
@ -794,8 +795,28 @@ class AppObjects:
# Check if token is expired
if latest_token.expiresAt and latest_token.expiresAt < datetime.now().timestamp():
logger.warning(f"Token for {authority} is expired (expiresAt: {latest_token.expiresAt})")
return None # Don't return expired tokens
if auto_refresh:
logger.info(f"Token for {authority} is expired, attempting refresh...")
# Import TokenManager here to avoid circular imports
from modules.security.tokenManager import TokenManager
token_manager = TokenManager()
# Try to refresh the token
refreshed_token = token_manager.refresh_token(latest_token)
if refreshed_token:
# Save the new token and delete the old one
self.saveToken(refreshed_token)
self.deleteToken(authority)
logger.info(f"Successfully refreshed token for {authority}")
return refreshed_token
else:
logger.warning(f"Failed to refresh expired token for {authority}")
return None
else:
logger.warning(f"Token for {authority} is expired (expiresAt: {latest_token.expiresAt})")
return None
return latest_token
@ -803,6 +824,53 @@ class AppObjects:
logger.error(f"Error getting token: {str(e)}")
return None
def getTokenForConnection(self, connectionId: str, auto_refresh: bool = True) -> Optional[Token]:
"""Get the token for a specific connection, optionally auto-refresh if expired"""
try:
# Get token for this specific connection
tokens = self.db.getRecordset("tokens", recordFilter={
"connectionId": connectionId
})
if not tokens:
logger.warning(f"No token found for connection: {connectionId}")
return None
# Sort by creation date and get the latest
tokens.sort(key=lambda x: x.get("createdAt", ""), reverse=True)
latest_token = Token(**tokens[0])
# Check if token is expired
if latest_token.expiresAt and latest_token.expiresAt < datetime.now().timestamp():
if auto_refresh:
logger.info(f"Token for connection {connectionId} is expired, attempting refresh...")
# Import TokenManager here to avoid circular imports
from modules.security.tokenManager import TokenManager
token_manager = TokenManager()
# Try to refresh the token
refreshed_token = token_manager.refresh_token(latest_token)
if refreshed_token:
# Save the new token and delete the old one
self.saveToken(refreshed_token)
self.deleteTokenByConnectionId(connectionId)
logger.info(f"Successfully refreshed token for connection {connectionId}")
return refreshed_token
else:
logger.warning(f"Failed to refresh expired token for connection {connectionId}")
return None
else:
logger.warning(f"Token for connection {connectionId} is expired (expiresAt: {latest_token.expiresAt})")
return None
return latest_token
except Exception as e:
logger.error(f"Error getting token for connection {connectionId}: {str(e)}")
return None
def deleteToken(self, authority: str) -> None:
"""Delete all tokens for the current user and authority"""
try:
@ -823,6 +891,44 @@ class AppObjects:
logger.error(f"Error deleting token: {str(e)}")
raise
def deleteTokenByConnectionId(self, connectionId: str) -> None:
"""Delete all tokens for a specific connection"""
try:
# Get tokens to delete
tokens = self.db.getRecordset("tokens", recordFilter={
"connectionId": connectionId
})
# Delete each token
for token in tokens:
self.db.recordDelete("tokens", token["id"])
# Clear cache to ensure fresh data
self._clearTableCache("tokens")
except Exception as e:
logger.error(f"Error deleting token for connection {connectionId}: {str(e)}")
raise
def logout(self) -> None:
"""Logout current user - clear user context and tokens"""
try:
# Clear user context
self.currentUser = None
self.userId = None
self.mandateId = None
self.access = None
# Clear database context
if hasattr(self, 'db'):
self.db.updateContext("")
logger.info("User logged out successfully")
except Exception as e:
logger.error(f"Error during logout: {str(e)}")
raise
# Public Methods
def getInterface(currentUser: User) -> AppObjects:

View file

@ -12,73 +12,50 @@ from modules.shared.attributeUtils import register_model_labels, ModelMixin
# ===== Method Models =====
class ActionDocument(BaseModel, ModelMixin):
"""Clear document structure for action results"""
documentName: str = Field(description="Name of the document")
documentData: Any = Field(description="Content/data of the document")
mimeType: str = Field(description="MIME type of the document")
# Register labels for ActionDocument
register_model_labels(
"ActionDocument",
{"en": "Action Document", "fr": "Document d'action"},
{
"documentName": {"en": "Document Name", "fr": "Nom du document"},
"documentData": {"en": "Document Data", "fr": "Données du document"},
"mimeType": {"en": "MIME Type", "fr": "Type MIME"}
}
)
class ActionResult(BaseModel, ModelMixin):
"""Unified model for action results with workflow state management"""
# Core result fields
success: bool = Field(description="Whether the method execution was successful")
data: Dict[str, Any] = Field(description="Result data")
metadata: Dict[str, Any] = Field(default_factory=dict, description="Additional metadata")
error: Optional[str] = Field(None, description="Error message if any")
"""Clean action result with documents as primary output"""
# Core result
success: bool = Field(description="Whether execution succeeded")
error: Optional[str] = Field(None, description="Error message if failed")
# Action identification
actionId: Optional[str] = Field(None, description="ID of the action that produced this result")
actionMethod: Optional[str] = Field(None, description="Method of the action that produced this result")
actionName: Optional[str] = Field(None, description="Name of the action that produced this result")
# Document handling
documents: List[str] = Field(default_factory=list, description="List of document references")
resultLabel: Optional[str] = Field(None, description="Label for the result")
# Validation and workflow state
validation: Dict[str, Any] = Field(default_factory=dict, description="Validation information")
is_retry: bool = Field(default=False, description="Whether this is a retry attempt")
previous_error: Optional[str] = Field(None, description="Previous error message for retries")
applied_improvements: List[str] = Field(default_factory=list, description="Improvements applied for retry")
# Primary output - documents
documents: List[ActionDocument] = Field(default_factory=list, description="Document outputs")
resultLabel: Optional[str] = Field(None, description="Label for document routing")
@classmethod
def success(cls, documents: List[str] = None, resultLabel: str = None, data: Dict[str, Any] = None,
actionId: str = None, actionMethod: str = None, actionName: str = None) -> 'ActionResult':
def success(cls, documents: List[ActionDocument] = None, resultLabel: str = None) -> 'ActionResult':
"""Create a successful action result"""
return cls(
success=True,
data=data or {},
documents=documents or [],
resultLabel=resultLabel,
actionId=actionId,
actionMethod=actionMethod,
actionName=actionName
resultLabel=resultLabel
)
@classmethod
def failure(cls, error: str, data: Dict[str, Any] = None,
actionId: str = None, actionMethod: str = None, actionName: str = None) -> 'ActionResult':
def failure(cls, error: str, documents: List[ActionDocument] = None, resultLabel: str = None) -> 'ActionResult':
"""Create a failed action result"""
return cls(
success=False,
data=data or {},
documents=documents or [],
error=error,
actionId=actionId,
actionMethod=actionMethod,
actionName=actionName
)
@classmethod
def retry(cls, previous_result: 'ActionResult', improvements: List[str] = None) -> 'ActionResult':
"""Create a retry action result based on a previous result"""
return cls(
success=previous_result.success,
data=previous_result.data,
metadata=previous_result.metadata,
validation=previous_result.validation,
error=previous_result.error,
documents=previous_result.documents,
resultLabel=previous_result.resultLabel,
actionId=previous_result.actionId,
actionMethod=previous_result.actionMethod,
actionName=previous_result.actionName,
is_retry=True,
previous_error=previous_result.error,
applied_improvements=improvements or []
resultLabel=resultLabel
)
# Register labels for ActionResult
@ -87,18 +64,9 @@ register_model_labels(
{"en": "Action Result", "fr": "Résultat de l'action"},
{
"success": {"en": "Success", "fr": "Succès"},
"data": {"en": "Data", "fr": "Données"},
"metadata": {"en": "Metadata", "fr": "Métadonnées"},
"validation": {"en": "Validation", "fr": "Validation"},
"error": {"en": "Error", "fr": "Erreur"},
"documents": {"en": "Documents", "fr": "Documents"},
"resultLabel": {"en": "Result Label", "fr": "Étiquette du résultat"},
"actionId": {"en": "Action ID", "fr": "ID de l'action"},
"actionMethod": {"en": "Action Method", "fr": "Méthode de l'action"},
"actionName": {"en": "Action Name", "fr": "Nom de l'action"},
"is_retry": {"en": "Is Retry", "fr": "Est une nouvelle tentative"},
"previous_error": {"en": "Previous Error", "fr": "Erreur précédente"},
"applied_improvements": {"en": "Applied Improvements", "fr": "Améliorations appliquées"}
"resultLabel": {"en": "Result Label", "fr": "Étiquette du résultat"}
}
)
@ -536,21 +504,102 @@ class TaskStep(BaseModel, ModelMixin):
success_criteria: Optional[list[str]] = []
estimated_complexity: Optional[str] = None
class TaskHandover(BaseModel, ModelMixin):
"""Structured handover between workflow phases and tasks"""
taskId: str = Field(description="Target task ID")
sourceTask: Optional[str] = Field(None, description="Source task ID")
# Document handovers
inputDocuments: List[DocumentExchange] = Field(default_factory=list, description="Available input documents")
outputDocuments: List[DocumentExchange] = Field(default_factory=list, description="Produced output documents")
# Context and state
context: Dict[str, Any] = Field(default_factory=dict, description="Task context")
previousResults: List[str] = Field(default_factory=list, description="Previous result summaries")
improvements: List[str] = Field(default_factory=list, description="Improvement suggestions")
# Workflow context
workflowSummary: Optional[str] = Field(None, description="Summarized workflow context")
messageHistory: List[str] = Field(default_factory=list, description="Key message summaries")
# Metadata
timestamp: datetime = Field(default_factory=lambda: datetime.now(UTC), description="When the handover was created")
handoverType: str = Field(default="task", description="Type of handover: task, phase, or workflow")
def addInputDocument(self, documentExchange: DocumentExchange) -> None:
"""Add an input document exchange"""
self.inputDocuments.append(documentExchange)
def addOutputDocument(self, documentExchange: DocumentExchange) -> None:
"""Add an output document exchange"""
self.outputDocuments.append(documentExchange)
def getDocumentsForAction(self, actionId: str) -> List[DocumentExchange]:
"""Get all document exchanges relevant for a specific action"""
relevant = []
for doc_exchange in self.inputDocuments + self.outputDocuments:
if doc_exchange.isForAction(actionId):
relevant.append(doc_exchange)
return relevant
# Register labels for TaskHandover
register_model_labels(
"TaskHandover",
{"en": "Task Handover", "fr": "Transfert de tâche"},
{
"taskId": {"en": "Task ID", "fr": "ID de la tâche"},
"sourceTask": {"en": "Source Task", "fr": "Tâche source"},
"inputDocuments": {"en": "Input Documents", "fr": "Documents d'entrée"},
"outputDocuments": {"en": "Output Documents", "fr": "Documents de sortie"},
"context": {"en": "Context", "fr": "Contexte"},
"previousResults": {"en": "Previous Results", "fr": "Résultats précédents"},
"improvements": {"en": "Improvements", "fr": "Améliorations"},
"workflowSummary": {"en": "Workflow Summary", "fr": "Résumé du workflow"},
"messageHistory": {"en": "Message History", "fr": "Historique des messages"},
"timestamp": {"en": "Timestamp", "fr": "Horodatage"},
"handoverType": {"en": "Handover Type", "fr": "Type de transfert"}
}
)
class TaskContext(BaseModel, ModelMixin):
task_step: TaskStep
workflow: Optional['ChatWorkflow'] = None
workflow_id: Optional[str] = None
# Available resources
available_documents: Optional[list[str]] = []
available_connections: Optional[list[str]] = []
# Previous execution state
previous_results: Optional[list[str]] = []
previous_handover: Optional[TaskHandover] = None
# Current execution state
improvements: Optional[list[str]] = []
retry_count: Optional[int] = 0
previous_action_results: Optional[list] = []
previous_review_result: Optional[dict] = None
is_regeneration: Optional[bool] = False
# Failure analysis
failure_patterns: Optional[list[str]] = []
failed_actions: Optional[list] = []
successful_actions: Optional[list] = []
def getDocumentReferences(self) -> List[str]:
"""Get all available document references"""
docs = self.available_documents or []
if self.previous_handover:
for doc_exchange in self.previous_handover.inputDocuments:
docs.extend(doc_exchange.documents)
return list(set(docs)) # Remove duplicates
def addImprovement(self, improvement: str) -> None:
"""Add an improvement suggestion"""
if improvement not in (self.improvements or []):
if self.improvements is None:
self.improvements = []
self.improvements.append(improvement)
class ReviewContext(BaseModel, ModelMixin):
task_step: TaskStep
@ -582,3 +631,5 @@ class WorkflowResult(BaseModel, ModelMixin):
final_results_count: int
error: Optional[str] = None
phase: Optional[str] = None

View file

@ -1,327 +0,0 @@
from typing import Dict, Any, Optional, List
import logging
import uuid
from datetime import datetime, UTC
from modules.chat.methodBase import MethodBase, ActionResult, action
logger = logging.getLogger(__name__)
class MethodCoder(MethodBase):
"""Coder method implementation for code operations"""
def __init__(self, serviceCenter: Any):
"""Initialize the coder method"""
super().__init__(serviceCenter)
self.name = "coder"
self.description = "Handle code operations like analysis, generation, and refactoring"
@action
async def analyze(self, parameters: Dict[str, Any]) -> ActionResult:
"""
Analyze code quality and structure
Parameters:
documentList (str): Reference to the document list to analyze
aiPrompt (str): AI prompt for code analysis
language (str, optional): Programming language (default: "python")
checks (List[str], optional): Types of checks to perform (default: ["complexity", "style", "security"])
"""
try:
documentList = parameters.get("documentList")
aiPrompt = parameters.get("aiPrompt")
language = parameters.get("language", "python")
checks = parameters.get("checks", ["complexity", "style", "security"])
if not documentList:
return self._createResult(
success=False,
data={},
error="Document list reference is required"
)
if not aiPrompt:
return self._createResult(
success=False,
data={},
error="AI prompt is required"
)
# Handle new document list format (list of strings)
chatDocuments = self.service.getChatDocumentsFromDocumentList(documentList)
if not chatDocuments:
return self._createResult(
success=False,
data={},
error="No documents found for the provided reference"
)
# Process each document individually
all_code_content = []
for chatDocument in chatDocuments:
fileId = chatDocument.fileId
code = self.service.getFileData(fileId)
file_info = self.service.getFileInfo(fileId)
if not code:
logger.warning(f"Code file is empty for fileId: {fileId}")
continue
# Use AI prompt to extract relevant code content
extracted_content = await self.service.extractContentFromFileData(
prompt=aiPrompt,
fileData=code,
filename=file_info.get('name', 'code'),
mimeType=file_info.get('mimeType', 'text/plain'),
base64Encoded=False
)
all_code_content.append(extracted_content)
if not all_code_content:
return self._createResult(
success=False,
data={},
error="No code content could be extracted from any documents"
)
# Extract text content from ExtractedContent objects
text_contents = []
for content_obj in all_code_content:
if hasattr(content_obj, 'contents') and content_obj.contents:
# Extract text from ContentItem objects
for content_item in content_obj.contents:
if hasattr(content_item, 'data') and content_item.data:
text_contents.append(content_item.data)
elif isinstance(content_obj, str):
text_contents.append(content_obj)
else:
# Fallback: convert to string representation
text_contents.append(str(content_obj))
# Combine all extracted text content for analysis
combined_content = "\n\n--- CODE SEPARATOR ---\n\n".join(text_contents)
# Create analysis prompt
analysis_prompt = f"""
Analyze this {language} code for quality, structure, and potential issues.
Code to analyze:
{combined_content}
Please check for:
{', '.join(checks)}
Provide a detailed analysis including:
1. Code quality assessment
2. Potential issues and improvements
3. Security considerations
4. Performance optimizations
5. Best practices compliance
6. Summary of findings across all documents
"""
# Use AI service for analysis
analysis_result = await self.service.interfaceAiCalls.callAiTextAdvanced(analysis_prompt)
# Create result data
result_data = {
"documentCount": len(chatDocuments),
"language": language,
"checks": checks,
"analysis": analysis_result,
"timestamp": datetime.now(UTC).isoformat()
}
return self._createResult(
success=True,
data={
"documents": [
{
"documentName": f"code_analysis_{datetime.now(UTC).strftime('%Y%m%d_%H%M%S')}.json",
"documentData": result_data
}
]
}
)
except Exception as e:
logger.error(f"Error analyzing code: {str(e)}")
return self._createResult(
success=False,
data={},
error=str(e)
)
@action
async def generate(self, parameters: Dict[str, Any]) -> ActionResult:
"""
Generate code based on requirements
Parameters:
requirements (str): Requirements for the code to generate
language (str, optional): Programming language (default: "python")
template (str, optional): Template or pattern to follow
"""
try:
requirements = parameters.get("requirements")
language = parameters.get("language", "python")
template = parameters.get("template")
if not requirements:
return self._createResult(
success=False,
data={},
error="Requirements are required"
)
# Create generation prompt
generation_prompt = f"""
Generate {language} code based on the following requirements:
Requirements:
{requirements}
{f'Template to follow: {template}' if template else ''}
Please provide:
1. Complete, working code
2. Clear comments and documentation
3. Error handling where appropriate
4. Best practices implementation
"""
# Use AI service for code generation
generated_code = await self.service.interfaceAiCalls.callAiTextAdvanced(generation_prompt)
# Create result data
result_data = {
"language": language,
"requirements": requirements,
"code": generated_code,
"timestamp": datetime.now(UTC).isoformat()
}
return self._createResult(
success=True,
data={
"documentName": f"generated_code_{datetime.now(UTC).strftime('%Y%m%d_%H%M%S')}.{language}",
"documentData": result_data
}
)
except Exception as e:
logger.error(f"Error generating code: {str(e)}")
return self._createResult(
success=False,
data={},
error=str(e)
)
@action
async def refactor(self, parameters: Dict[str, Any]) -> ActionResult:
"""
Refactor code for better quality
Parameters:
documentList (str): Reference to the document list to refactor
aiImprovementPrompt (str): AI prompt for code improvements
language (str, optional): Programming language (default: "python")
"""
try:
documentList = parameters.get("documentList")
aiImprovementPrompt = parameters.get("aiImprovementPrompt")
language = parameters.get("language", "python")
if not documentList:
return self._createResult(
success=False,
data={},
error="Document list reference is required"
)
if not aiImprovementPrompt:
return self._createResult(
success=False,
data={},
error="AI improvement prompt is required"
)
# Handle new document list format (list of strings)
chatDocuments = self.service.getChatDocumentsFromDocumentList(documentList)
if not chatDocuments:
return self._createResult(
success=False,
data={},
error="No documents found for the provided reference"
)
# Process each document individually
refactored_results = []
for chatDocument in chatDocuments:
fileId = chatDocument.fileId
code = self.service.getFileData(fileId)
file_info = self.service.getFileInfo(fileId)
if not code:
logger.warning(f"Code file is empty for fileId: {fileId}")
continue
# Create refactoring prompt for this specific document
refactor_prompt = f"""
Refactor this {language} code based on the following improvement requirements:
Improvement requirements:
{aiImprovementPrompt}
Original code:
{code}
Please provide:
1. Refactored code with improvements
2. Explanation of changes made
3. Benefits of the refactoring
4. Any potential trade-offs
"""
# Use AI service for refactoring
refactored_code = await self.service.interfaceAiCalls.callAiTextAdvanced(refactor_prompt)
refactored_results.append({
"original_file": file_info.get('name', 'unknown'),
"original_code": code,
"refactored_code": refactored_code
})
if not refactored_results:
return self._createResult(
success=False,
data={},
error="No code could be refactored from any documents"
)
# Create result data
result_data = {
"documentCount": len(chatDocuments),
"language": language,
"refactored_results": refactored_results,
"timestamp": datetime.now(UTC).isoformat()
}
return self._createResult(
success=True,
data={
"documentName": f"refactored_code_{datetime.now(UTC).strftime('%Y%m%d_%H%M%S')}.{language}",
"documentData": result_data
}
)
except Exception as e:
logger.error(f"Error refactoring code: {str(e)}")
return self._createResult(
success=False,
data={},
error=str(e)
)

View file

@ -5,10 +5,10 @@ Handles direct AI calls for any type of task.
import logging
from typing import Dict, Any, List, Optional
import uuid
from datetime import datetime, UTC
from modules.chat.methodBase import MethodBase, ActionResult, action
from modules.chat.methodBase import MethodBase, action
from modules.interfaces.interfaceChatModel import ActionResult
logger = logging.getLogger(__name__)
@ -43,9 +43,7 @@ class MethodAi(MethodBase):
customInstructions = parameters.get("customInstructions", "")
if not aiPrompt:
return self._createResult(
success=False,
data={},
return ActionResult.failure(
error="AI prompt is required"
)
@ -56,27 +54,79 @@ class MethodAi(MethodBase):
if chatDocuments:
context_parts = []
for doc in chatDocuments:
fileId = doc.fileId
file_data = self.service.getFileData(fileId)
file_info = self.service.getFileInfo(fileId)
file_info = self.service.getFileInfo(doc.fileId)
if file_data:
try:
# Try to decode as text for context
content = file_data.decode('utf-8')
metadata_info = ""
if file_info and includeMetadata:
metadata_info = f" (Size: {file_info.get('fileSize', 'unknown')}, Type: {file_info.get('mimeType', 'unknown')})"
try:
# Use the document content extraction service with the specific AI prompt context
# This tells the extraction engine exactly what and how to extract
extraction_prompt = f"""
Extract content from this document for AI processing context.
AI Task: {aiPrompt}
Processing Mode: {processingMode}
Expected Output: {output_extension.upper()} format
Requirements:
1. Extract the most relevant text content that would be useful for the AI task
2. Focus on content that directly relates to: {aiPrompt}
3. Include key information, data, and insights that the AI needs
4. Provide clean, readable text without formatting artifacts
Document: {doc.filename}
"""
logger.debug(f"Extracting content from {doc.filename} with task-specific prompt: {extraction_prompt[:100]}...")
extracted_content = await self.service.extractContentFromDocument(
prompt=extraction_prompt.strip(),
document=doc
)
if extracted_content and extracted_content.contents:
# Get the first content item's data
content = ""
for content_item in extracted_content.contents:
if hasattr(content_item, 'data') and content_item.data:
content += content_item.data + " "
# Adjust context length based on processing mode
max_length = 5000 if processingMode == "detailed" else 3000 if processingMode == "advanced" else 2000
context_parts.append(f"Document: {doc.filename}{metadata_info}\nContent:\n{content[:max_length]}...")
except UnicodeDecodeError:
context_parts.append(f"Document: {doc.filename} [Binary content]")
if content.strip():
metadata_info = ""
if file_info and includeMetadata:
metadata_info = f" (Size: {file_info.get('fileSize', 'unknown')}, Type: {file_info.get('mimeType', 'unknown')})"
# Adjust context length based on processing mode and AI task relevance
base_length = 5000 if processingMode == "detailed" else 3000 if processingMode == "advanced" else 2000
# For detailed mode, include more context
if processingMode == "detailed":
context_parts.append(f"Document: {doc.filename}{metadata_info}\nRelevance to AI Task: This document contains content directly related to '{aiPrompt[:100]}...'\nContent:\n{content[:base_length]}...")
else:
context_parts.append(f"Document: {doc.filename}{metadata_info}\nContent:\n{content[:base_length]}...")
else:
context_parts.append(f"Document: {doc.filename} [No readable text content - binary file]")
else:
context_parts.append(f"Document: {doc.filename} [No readable text content - binary file]")
except Exception as extract_error:
context_parts.append(f"Document: {doc.filename} [Could not extract content - binary file]")
if context_parts:
context = "\n\n".join(context_parts)
logger.info(f"Included {len(chatDocuments)} documents in AI context")
# Add a summary header to help the AI understand the context
context_header = f"""
=== DOCUMENT CONTEXT FOR AI PROCESSING ===
AI Task: {aiPrompt[:100]}...
Processing Mode: {processingMode}
Expected Output Format: {output_extension.upper()}
Total Documents: {len(chatDocuments)}
The following documents contain content relevant to your task.
Use this information to provide the most accurate and helpful response.
================================================
"""
context = context_header + "\n\n" + "\n\n".join(context_parts)
logger.info(f"Included {len(chatDocuments)} documents in AI context with task-specific extraction")
# Determine output format
output_extension = ".txt" # Default
@ -126,39 +176,23 @@ class MethodAi(MethodBase):
timestamp = datetime.now(UTC).strftime('%Y%m%d_%H%M%S')
filename = f"ai_{processingMode}_{timestamp}{output_extension}"
# Create document through service (but don't add to workflow - let calling layer handle that)
document = self.service.createDocument(
fileName=filename,
mimeType=output_mime_type,
content=result,
base64encoded=False
)
return self._createResult(
success=True,
data={
"result": result,
"filename": filename,
"documentId": document.id if hasattr(document, 'id') else None,
"processedDocuments": len(documentList) if documentList else 0,
"processingMode": processingMode,
"document": document # Include the created document in the result data
},
metadata={
"method": "ai.process",
"promptLength": len(aiPrompt),
"contextLength": len(context),
"outputFormat": output_extension,
"includeMetadata": includeMetadata,
"processingMode": processingMode,
"hasCustomInstructions": bool(customInstructions)
}
# Return result in the standard ActionResult format
return ActionResult.success(
documents=[{
"documentName": filename,
"documentData": {
"result": result,
"filename": filename,
"processedDocuments": len(documentList) if documentList else 0
},
"mimeType": output_mime_type
}]
)
except Exception as e:
logger.error(f"Error in ai.process: {str(e)}")
return self._createResult(
success=False,
data={},
error=f"AI processing failed: {str(e)}"
logger.error(f"Error in AI processing: {str(e)}")
return ActionResult.failure(
error=str(e)
)

View file

@ -7,7 +7,8 @@ import logging
from typing import Dict, Any, List, Optional
from datetime import datetime, UTC
from modules.chat.methodBase import MethodBase, ActionResult, action
from modules.chat.methodBase import MethodBase, action
from modules.interfaces.interfaceChatModel import ActionResult
logger = logging.getLogger(__name__)
@ -23,13 +24,13 @@ class MethodDocument(MethodBase):
@action
async def extract(self, parameters: Dict[str, Any]) -> ActionResult:
"""
Extract specific content from document with AI prompt and return it in the specified format.
Extract content from any document using AI prompt.
Parameters:
documentList (str): Reference to the document list to extract content from
aiPrompt (str): AI prompt for content extraction
expectedDocumentFormats (list, optional): Expected document formats with extension, mimeType, description
includeMetadata (bool, optional): Whether to include metadata (default: True)
documentList (str): Document list reference
aiPrompt (str): AI prompt for extraction
expectedDocumentFormats (list, optional): Output formats
includeMetadata (bool, optional): Include metadata (default: True)
"""
try:
documentList = parameters.get("documentList")
@ -38,24 +39,18 @@ class MethodDocument(MethodBase):
includeMetadata = parameters.get("includeMetadata", True)
if not documentList:
return self._createResult(
success=False,
data={},
return ActionResult.failure(
error="Document list reference is required"
)
if not aiPrompt:
return self._createResult(
success=False,
data={},
return ActionResult.failure(
error="AI prompt is required"
)
chatDocuments = self.service.getChatDocumentsFromDocumentList(documentList)
if not chatDocuments:
return self._createResult(
success=False,
data={},
return ActionResult.failure(
error="No documents found for the provided reference"
)
@ -64,31 +59,30 @@ class MethodDocument(MethodBase):
file_infos = []
for chatDocument in chatDocuments:
fileId = chatDocument.fileId
file_data = self.service.getFileData(fileId)
file_info = self.service.getFileInfo(fileId)
file_info = self.service.getFileInfo(chatDocument.fileId)
if not file_data:
logger.warning(f"File not found or empty for fileId: {fileId}")
try:
# Use the document content extraction service with the specific AI prompt
# This handles all document types (text, binary, image, etc.) intelligently
extracted_content = await self.service.extractContentFromDocument(
prompt=aiPrompt,
document=chatDocument
)
if extracted_content and extracted_content.contents:
all_extracted_content.append(extracted_content)
if includeMetadata:
file_infos.append(file_info)
logger.info(f"Successfully extracted content from {chatDocument.filename}")
else:
logger.warning(f"No content extracted from {chatDocument.filename}")
except Exception as e:
logger.error(f"Error extracting content from {chatDocument.filename}: {str(e)}")
continue
extracted_content = await self.service.extractContentFromFileData(
prompt=aiPrompt,
fileData=file_data,
filename=file_info.get('name', 'document'),
mimeType=file_info.get('mimeType', 'application/octet-stream'),
base64Encoded=False,
documentId=chatDocument.id
)
all_extracted_content.append(extracted_content)
if includeMetadata:
file_infos.append(file_info)
if not all_extracted_content:
return self._createResult(
success=False,
data={},
return ActionResult.failure(
error="No content could be extracted from any documents"
)
@ -164,31 +158,25 @@ class MethodDocument(MethodBase):
"mimeType": final_mime_type
})
return self._createResult(
success=True,
data={
"documents": output_documents
}
return ActionResult.success(
documents=output_documents
)
except Exception as e:
logger.error(f"Error extracting content: {str(e)}")
return self._createResult(
success=False,
data={},
return ActionResult.failure(
error=str(e)
)
@action
async def generate(self, parameters: Dict[str, Any]) -> ActionResult:
"""
Generate documents in specific formats from document references.
This action automatically extracts content from documents and converts it to the specified format.
Convert TEXT-ONLY documents to target formats (NO AI usage).
Parameters:
documentList (list): List of document references to extract content from
expectedDocumentFormats (list): Expected document formats with extension, mimeType, description
originalDocuments (list, optional): List of original document names
includeMetadata (bool, optional): Whether to include metadata (default: True)
documentList (list): TEXT-ONLY documents only
expectedDocumentFormats (list): Target formats
originalDocuments (list, optional): Original names
includeMetadata (bool, optional): Include metadata (default: True)
"""
try:
document_list = parameters.get("documentList", [])
@ -197,16 +185,12 @@ class MethodDocument(MethodBase):
include_metadata = parameters.get("includeMetadata", True)
if not document_list:
return self._createResult(
success=False,
data={},
return ActionResult.failure(
error="Document list is required for generation"
)
if not expected_document_formats or len(expected_document_formats) == 0:
return self._createResult(
success=False,
data={},
return ActionResult.failure(
error="Expected document formats specification is required"
)
@ -215,9 +199,7 @@ class MethodDocument(MethodBase):
logger.info(f"Found {len(chat_documents)} chat documents")
if not chat_documents:
return self._createResult(
success=False,
data={},
return ActionResult.failure(
error="No documents found for the provided documentList reference"
)
@ -229,28 +211,42 @@ class MethodDocument(MethodBase):
output_documents = []
for i, chat_document in enumerate(chat_documents):
# Extract content from this document
# ChatDocument is just a reference, so we need to get file data using fileId
# Extract content from this document directly - NO AI, just read the data as-is
# This ensures we get the original text content for format conversion
content = ""
if hasattr(chat_document, 'fileId') and chat_document.fileId:
# Need to get file data
file_data = self.service.getFileData(chat_document.fileId)
if file_data:
if isinstance(file_data, bytes):
content = file_data.decode('utf-8', errors='ignore')
try:
# Get file data directly without AI processing
file_data = self.service.getFileData(chat_document.fileId)
if file_data:
# Check if it's text data and convert to string
if isinstance(file_data, bytes):
try:
# Try to decode as UTF-8 to check if it's text
content = file_data.decode('utf-8')
logger.info(f"Document {i+1} ({chat_document.filename}): Successfully decoded as UTF-8 text")
except UnicodeDecodeError:
logger.info(f"Document {i+1} ({chat_document.filename}): Binary data, not text - skipping")
continue
else:
# Already a string
content = str(file_data)
logger.info(f"Document {i+1} ({chat_document.filename}): Already text data")
else:
content = str(file_data)
else:
logger.warning(f"Could not get file data for document {i+1}, skipping")
logger.warning(f"Document {i+1} ({chat_document.filename}): No file data found")
continue
if not content.strip():
logger.info(f"Document {i+1} ({chat_document.filename}): Empty text content, skipping")
continue
except Exception as e:
logger.warning(f"Error reading document {i+1} ({chat_document.filename}): {str(e)}")
continue
else:
logger.warning(f"Document {i+1} has no fileId, skipping")
continue
if not content:
logger.warning(f"Could not extract content from document {i+1}, skipping")
continue
logger.info(f"Extracted content from document {i+1}: {len(content)} characters")
# Get the expected format for this document (or use default)
@ -300,23 +296,16 @@ class MethodDocument(MethodBase):
})
if not output_documents:
return self._createResult(
success=False,
data={},
return ActionResult.failure(
error="No documents could be generated"
)
return self._createResult(
success=True,
data={
"documents": output_documents
}
return ActionResult.success(
documents=output_documents
)
except Exception as e:
logger.error(f"Error generating document: {str(e)}")
return self._createResult(
success=False,
data={},
return ActionResult.failure(
error=str(e)
)
@ -502,37 +491,40 @@ class MethodDocument(MethodBase):
@action
async def generateReport(self, parameters: Dict[str, Any]) -> ActionResult:
"""
Generate a comprehensive, professional HTML report from multiple documents, consolidating and summarizing all findings using AI.
Generate HTML report from multiple documents using AI.
Parameters:
documentList (str): Reference to the document list to create the report from
title (str, optional): Title for the report (default: "Summary Report")
includeMetadata (bool, optional): Whether to include metadata (default: True)
documentList (str): Document list reference
prompt (str): AI prompt for report generation
title (str, optional): Report title (default: "Summary Report")
includeMetadata (bool, optional): Include metadata (default: True)
"""
try:
documentList = parameters.get("documentList")
prompt = parameters.get("prompt")
title = parameters.get("title", "Summary Report")
includeMetadata = parameters.get("includeMetadata", True)
if not documentList:
return self._createResult(
success=False,
data={},
return ActionResult.failure(
error="Document list reference is required"
)
if not prompt:
return ActionResult.failure(
error="Prompt is required to specify what kind of report to generate"
)
chatDocuments = self.service.getChatDocumentsFromDocumentList(documentList)
logger.info(f"Retrieved {len(chatDocuments)} chat documents for report generation")
if not chatDocuments:
return self._createResult(
success=False,
data={},
return ActionResult.failure(
error="No documents found for the provided reference"
)
# Generate HTML report
html_content = await self._generateHtmlReport(chatDocuments, title, includeMetadata)
html_content = await self._generateHtmlReport(chatDocuments, title, includeMetadata, prompt)
# Create output filename
timestamp = datetime.now(UTC).strftime('%Y%m%d_%H%M%S')
@ -547,25 +539,20 @@ class MethodDocument(MethodBase):
logger.info(f"Generated HTML report: {output_filename} with {len(html_content)} characters")
return self._createResult(
success=True,
data={
"documents": [{
"documentName": output_filename,
"documentData": result_data,
"mimeType": "text/html"
}]
}
return ActionResult.success(
documents=[{
"documentName": output_filename,
"documentData": result_data,
"mimeType": "text/html"
}]
)
except Exception as e:
logger.error(f"Error generating report: {str(e)}")
return self._createResult(
success=False,
data={},
return ActionResult.failure(
error=str(e)
)
async def _generateHtmlReport(self, chatDocuments: List[Any], title: str, includeMetadata: bool) -> str:
async def _generateHtmlReport(self, chatDocuments: List[Any], title: str, includeMetadata: bool, prompt: str) -> str:
"""
Generate a comprehensive HTML report using AI from all input documents.
"""
@ -578,28 +565,35 @@ class MethodDocument(MethodBase):
content = ""
logger.info(f"Processing document: type={type(doc)}")
# Get actual file content using the fileId reference
# Get actual file content using the document content extraction service
try:
file_data = self.service.getFileData(doc.fileId)
if file_data:
# Convert bytes to string
if isinstance(file_data, bytes):
content = file_data.decode('utf-8')
extracted_content = await self.service.extractContentFromDocument(
prompt="Extract readable text content for HTML report generation",
document=doc
)
if extracted_content and extracted_content.contents:
# Get the first content item's data
for content_item in extracted_content.contents:
if hasattr(content_item, 'data') and content_item.data:
content += content_item.data + " "
if content.strip():
logger.info(f" Retrieved content from file: {len(content)} characters")
else:
content = str(file_data)
logger.info(f" Retrieved content from file: {len(content)} characters")
logger.info(f" No readable text content found (binary file)")
else:
logger.warning(f" No file data found for fileId: {doc.fileId}")
logger.info(f" No content extracted (binary file)")
except Exception as e:
logger.error(f" Error retrieving file data: {str(e)}")
logger.info(f" Could not extract content (binary file): {str(e)}")
# Skip empty documents
if content:
if content and content.strip():
validDocuments.append(doc)
allContent.append(f"Document: {doc.filename}\n{content}\n")
logger.info(f" Added document to valid documents list")
else:
logger.warning(f" Skipping document with no content")
logger.info(f" Skipping document with no readable text content")
if not validDocuments:
# If no valid documents, create a simple report
@ -610,14 +604,14 @@ class MethodDocument(MethodBase):
html.append("</body></html>")
return '\n'.join(html)
# Create AI prompt for comprehensive report generation
# Create AI prompt for comprehensive report generation using user's prompt
combinedContent = "\n\n".join(allContent)
aiPrompt = f"""
Create a comprehensive, well-structured HTML report based on the following documents and content.
{prompt}
Report Title: {title}
Requirements:
Additional Requirements:
1. Create a professional, well-formatted HTML report
2. Include an executive summary at the beginning
3. Organize information logically with clear sections
@ -629,17 +623,17 @@ class MethodDocument(MethodBase):
Document Content:
{combinedContent}
Generate a complete HTML report that integrates all the information into a cohesive, professional document.
Generate a complete HTML report that addresses the user's specific requirements and integrates all the information into a cohesive, professional document.
"""
# Call AI to generate the report
logger.info(f"Generating AI report for {len(validDocuments)} documents")
aiReport = await self.service.callAiTextBasic(aiPrompt, combinedContent)
# If AI call fails, fall back to basic HTML
# If AI call fails, return error - AI is crucial for report generation
if not aiReport or aiReport.strip() == "":
logger.warning("AI report generation failed, using fallback HTML")
return self._generateFallbackHtmlReport(validDocuments, title, includeMetadata)
logger.error("AI report generation failed - AI is crucial for this action")
raise Exception("AI report generation failed - AI is required for report generation")
# Clean up the AI response and ensure it's valid HTML
if not aiReport.strip().startswith('<html'):
@ -665,66 +659,6 @@ class MethodDocument(MethodBase):
except Exception as e:
logger.error(f"Error generating AI report: {str(e)}")
# Fall back to basic HTML report
return self._generateFallbackHtmlReport(chatDocuments, title, includeMetadata)
def _generateFallbackHtmlReport(self, chatDocuments: List[Any], title: str, includeMetadata: bool) -> str:
"""
Generate a basic HTML report as fallback when AI generation fails.
"""
html = ["<html><head><meta charset='utf-8'><title>" + title + "</title></head><body>"]
# Check if any document content already contains a title/header
has_title = False
for doc in chatDocuments:
if hasattr(doc, 'fileId') and doc.fileId:
try:
file_data = self.service.getFileData(doc.fileId)
if file_data:
content = file_data.decode('utf-8') if isinstance(file_data, bytes) else str(file_data)
if any(title.lower() in content.lower() for title in [title, "outlook", "report", "status"]):
has_title = True
break
except Exception:
continue
# Only add the title if no document content already has one
if not has_title:
html.append(f"<h1>{title}</h1>")
html.append(f"<p><b>Generated:</b> {datetime.now(UTC).strftime('%Y-%m-%d %H:%M:%S UTC')}</p>")
html.append(f"<p><b>Total Documents:</b> {len(chatDocuments)}</p>")
for i, doc in enumerate(chatDocuments, 1):
html.append(f"<h2>Document {i}: {doc.filename}</h2>")
if includeMetadata:
html.append("<ul>")
html.append(f"<li><b>ID:</b> {doc.id}</li>")
html.append(f"<li><b>File ID:</b> {doc.fileId}</li>")
html.append(f"<li><b>Filename:</b> {doc.filename}</li>")
if hasattr(doc, 'createdAt'):
html.append(f"<li><b>Created:</b> {doc.createdAt}</li>")
html.append("</ul>")
# Add document content if available
content = ""
if hasattr(doc, 'fileId') and doc.fileId:
# ChatDocument is just a reference, so we need to get file data using fileId
try:
file_data = self.service.getFileData(doc.fileId)
if file_data:
if isinstance(file_data, bytes):
content = file_data.decode('utf-8')
else:
content = str(file_data)
except Exception as e:
logger.warning(f"Could not retrieve content for document {doc.filename}: {str(e)}")
if content:
html.append(f"<div style='white-space:pre-wrap; border:1px solid #ccc; padding:0.5em; margin-bottom:1em; background-color:#f9f9f9;'>{content}</div>")
else:
html.append("<p><em>No content available</em></p>")
html.append("</body></html>")
return '\n'.join(html)
# Re-raise the error - AI is crucial for report generation
raise

File diff suppressed because it is too large Load diff

View file

@ -1,18 +1,19 @@
"""
SharePoint method module.
Handles SharePoint operations using the SharePoint service.
SharePoint operations method module.
Handles SharePoint document operations using the SharePoint service.
"""
import logging
from typing import Dict, Any, List, Optional
from datetime import datetime, UTC
import json
import uuid
import base64
from urllib.parse import urlparse
import aiohttp
import asyncio
from urllib.parse import urlparse
from modules.chat.methodBase import MethodBase, ActionResult, action
from modules.chat.methodBase import MethodBase, action
from modules.interfaces.interfaceChatModel import ActionResult
logger = logging.getLogger(__name__)
@ -41,10 +42,10 @@ class MethodSharepoint(MethodBase):
logger.warning(f"Connection {userConnection.id} status is not active/pending: {userConnection.status.value}")
return None
# Get the corresponding token for this user and authority
token = self.service.interfaceApp.getToken(userConnection.authority.value)
# Get the token for this specific connection
token = self.service.interfaceApp.getTokenForConnection(userConnection.id)
if not token:
logger.warning(f"No token found for user {userConnection.userId} and authority {userConnection.authority.value}")
logger.warning(f"No token found for connection {userConnection.id}")
return None
# Check if token is expired
@ -178,37 +179,21 @@ class MethodSharepoint(MethodBase):
expectedDocumentFormats = parameters.get("expectedDocumentFormats", [])
if not connectionReference or not siteUrl or not query:
return self._createResult(
success=False,
data={},
error="Connection reference, site URL, and query are required"
)
return ActionResult.failure(error="Connection reference, site URL, and query are required")
connection = self._getMicrosoftConnection(connectionReference)
if not connection:
return self._createResult(
success=False,
data={},
error="No valid Microsoft connection found for the provided connection reference"
)
return ActionResult.failure(error="No valid Microsoft connection found for the provided connection reference")
# Parse site URL to get hostname and site path
site_info = self._parseSiteUrl(siteUrl)
if not site_info["hostname"] or not site_info["sitePath"]:
return self._createResult(
success=False,
data={},
error=f"Invalid SharePoint site URL: {siteUrl}"
)
return ActionResult.failure(error=f"Invalid SharePoint site URL: {siteUrl}")
# Get site ID
site_id = await self._getSiteId(connection["accessToken"], site_info["hostname"], site_info["sitePath"])
if not site_id:
return self._createResult(
success=False,
data={},
error="Failed to get SharePoint site ID"
)
return ActionResult.failure(error="Failed to get SharePoint site ID")
try:
# Use Microsoft Graph search API
@ -219,11 +204,7 @@ class MethodSharepoint(MethodBase):
search_result = await self._makeGraphApiCall(connection["accessToken"], endpoint)
if "error" in search_result:
return self._createResult(
success=False,
data={},
error=f"Search failed: {search_result['error']}"
)
return ActionResult.failure(error=f"Search failed: {search_result['error']}")
# Process search results
items = search_result.get("value", [])
@ -279,11 +260,7 @@ class MethodSharepoint(MethodBase):
except Exception as e:
logger.error(f"Error searching SharePoint: {str(e)}")
return self._createResult(
success=False,
data={},
error=str(e)
)
return ActionResult.failure(error=str(e))
# Determine output format based on expected formats
output_extension = ".json" # Default
@ -298,26 +275,21 @@ class MethodSharepoint(MethodBase):
else:
logger.info("No expected format specified, using default .json format")
return self._createResult(
return ActionResult(
success=True,
data={
"documents": [
{
"documentName": f"sharepoint_find_path_{datetime.now(UTC).strftime('%Y%m%d_%H%M%S')}{output_extension}",
"documentData": result_data,
"mimeType": output_mime_type
}
]
}
documents=[
{
"documentName": f"sharepoint_find_path_{datetime.now(UTC).strftime('%Y%m%d_%H%M%S')}{output_extension}",
"documentData": result_data,
"mimeType": output_mime_type
}
],
resultLabel="sharepoint_find_path"
)
except Exception as e:
logger.error(f"Error finding document path: {str(e)}")
return self._createResult(
success=False,
data={},
error=str(e)
)
return ActionResult.failure(error=str(e))
@action
async def readDocument(self, parameters: Dict[str, Any]) -> ActionResult:
@ -341,11 +313,7 @@ class MethodSharepoint(MethodBase):
expectedDocumentFormats = parameters.get("expectedDocumentFormats", [])
if not documentList or not connectionReference or not siteUrl or not documentPaths:
return self._createResult(
success=False,
data={},
error="Document list reference, connection reference, site URL, and document paths are required"
)
return ActionResult.failure(error="Document list reference, connection reference, site URL, and document paths are required")
# Get documents from reference - ensure documentList is a list, not a string
if isinstance(documentList, str):
@ -365,37 +333,21 @@ class MethodSharepoint(MethodBase):
logger.info(f"Created {len(chatDocuments)} mock documents for testing")
if not chatDocuments:
return self._createResult(
success=False,
data={},
error="No documents found for the provided reference"
)
return ActionResult.failure(error="No documents found for the provided reference")
connection = self._getMicrosoftConnection(connectionReference)
if not connection:
return self._createResult(
success=False,
data={},
error="No valid Microsoft connection found for the provided connection reference"
)
return ActionResult.failure(error="No valid Microsoft connection found for the provided connection reference")
# Parse site URL to get hostname and site path
site_info = self._parseSiteUrl(siteUrl)
if not site_info["hostname"] or not site_info["sitePath"]:
return self._createResult(
success=False,
data={},
error=f"Invalid SharePoint site URL: {siteUrl}"
)
return ActionResult.failure(error=f"Invalid SharePoint site URL: {siteUrl}")
# Get site ID
site_id = await self._getSiteId(connection["accessToken"], site_info["hostname"], site_info["sitePath"])
if not site_id:
return self._createResult(
success=False,
data={},
error="Failed to get SharePoint site ID"
)
return ActionResult.failure(error="Failed to get SharePoint site ID")
# Process each document path
read_results = []
@ -514,23 +466,21 @@ class MethodSharepoint(MethodBase):
else:
logger.info("No expected format specified, using default .json format")
return self._createResult(
return ActionResult(
success=True,
data={
"documents": [
{
"documentName": f"sharepoint_documents_{datetime.now(UTC).strftime('%Y%m%d_%H%M%S')}{output_extension}",
"documentData": result_data,
"mimeType": output_mime_type
}
]
}
documents=[
{
"documentName": f"sharepoint_documents_{datetime.now(UTC).strftime('%Y%m%d_%H%M%S')}{output_extension}",
"documentData": result_data,
"mimeType": output_mime_type
}
],
resultLabel="sharepoint_documents"
)
except Exception as e:
logger.error(f"Error reading SharePoint documents: {str(e)}")
return self._createResult(
return ActionResult(
success=False,
data={},
error=str(e)
)
@ -556,49 +506,29 @@ class MethodSharepoint(MethodBase):
expectedDocumentFormats = parameters.get("expectedDocumentFormats", [])
if not connectionReference or not siteUrl or not documentPaths or not documentList or not fileNames:
return self._createResult(
success=False,
data={},
error="Connection reference, site URL, document paths, document list, and file names are required"
)
return ActionResult.failure(error="Connection reference, site URL, document paths, document list, and file names are required")
# Get Microsoft connection
connection = self._getMicrosoftConnection(connectionReference)
if not connection:
return self._createResult(
success=False,
data={},
error="No valid Microsoft connection found for the provided connection reference"
)
return ActionResult.failure(error="No valid Microsoft connection found for the provided connection reference")
# Get documents from reference - ensure documentList is a list, not a string
if isinstance(documentList, str):
documentList = [documentList] # Convert string to list
chatDocuments = self.service.getChatDocumentsFromDocumentList(documentList)
if not chatDocuments:
return self._createResult(
success=False,
data={},
error="No documents found for the provided reference"
)
return ActionResult.failure(error="No documents found for the provided reference")
# Parse site URL to get hostname and site path
site_info = self._parseSiteUrl(siteUrl)
if not site_info["hostname"] or not site_info["sitePath"]:
return self._createResult(
success=False,
data={},
error=f"Invalid SharePoint site URL: {siteUrl}"
)
return ActionResult.failure(error=f"Invalid SharePoint site URL: {siteUrl}")
# Get site ID
site_id = await self._getSiteId(connection["accessToken"], site_info["hostname"], site_info["sitePath"])
if not site_id:
return self._createResult(
success=False,
data={},
error="Failed to get SharePoint site ID"
)
return ActionResult.failure(error="Failed to get SharePoint site ID")
# Process each document upload
upload_results = []
@ -714,24 +644,22 @@ class MethodSharepoint(MethodBase):
else:
logger.info("No expected format specified, using default .json format")
return self._createResult(
return ActionResult(
success=True,
data={
"documents": [
{
"documentName": f"sharepoint_upload_{datetime.now(UTC).strftime('%Y%m%d_%H%M%S')}{output_extension}",
"documentData": result_data,
"mimeType": output_mime_type
}
]
}
documents=[
{
"documentName": f"sharepoint_upload_{datetime.now(UTC).strftime('%Y%m%d_%H%M%S')}{output_extension}",
"documentData": result_data,
"mimeType": output_mime_type
}
],
resultLabel="sharepoint_upload"
)
except Exception as e:
logger.error(f"Error uploading to SharePoint: {str(e)}")
return self._createResult(
return ActionResult(
success=False,
data={},
error=str(e)
)
@ -755,20 +683,12 @@ class MethodSharepoint(MethodBase):
expectedDocumentFormats = parameters.get("expectedDocumentFormats", [])
if not connectionReference or not siteUrl or not folderPaths:
return self._createResult(
success=False,
data={},
error="Connection reference, site URL, and folder paths are required"
)
return ActionResult.failure(error="Connection reference, site URL, and folder paths are required")
# Get Microsoft connection
connection = self._getMicrosoftConnection(connectionReference)
if not connection:
return self._createResult(
success=False,
data={},
error="No valid Microsoft connection found for the provided connection reference"
)
return ActionResult.failure(error="No valid Microsoft connection found for the provided connection reference")
logger.info(f"Starting SharePoint listDocuments for site: {siteUrl}")
logger.debug(f"Connection ID: {connection['id']}")
@ -780,11 +700,7 @@ class MethodSharepoint(MethodBase):
if not site_info["hostname"] or not site_info["sitePath"]:
logger.error(f"Failed to parse site URL: {siteUrl}")
return self._createResult(
success=False,
data={},
error=f"Invalid SharePoint site URL: {siteUrl}"
)
return ActionResult.failure(error=f"Invalid SharePoint site URL: {siteUrl}")
# Get site ID
logger.info(f"Getting site ID for hostname: {site_info['hostname']}, path: {site_info['sitePath']}")
@ -792,11 +708,7 @@ class MethodSharepoint(MethodBase):
logger.info(f"Site ID result: {site_id}")
if not site_id:
return self._createResult(
success=False,
data={},
error="Failed to get SharePoint site ID"
)
return ActionResult.failure(error="Failed to get SharePoint site ID")
# Process each folder path
list_results = []
@ -941,23 +853,21 @@ class MethodSharepoint(MethodBase):
else:
logger.info("No expected format specified, using default .json format")
return self._createResult(
return ActionResult(
success=True,
data={
"documents": [
{
"documentName": f"sharepoint_document_list_{datetime.now(UTC).strftime('%Y%m%d_%H%M%S')}{output_extension}",
"documentData": result_data,
"mimeType": output_mime_type
}
]
}
documents=[
{
"documentName": f"sharepoint_document_list_{datetime.now(UTC).strftime('%Y%m%d_%H%M%S')}{output_extension}",
"documentData": result_data,
"mimeType": output_mime_type
}
],
resultLabel="sharepoint_document_list"
)
except Exception as e:
logger.error(f"Error listing SharePoint documents: {str(e)}")
return self._createResult(
return ActionResult(
success=False,
data={},
error=str(e)
)

View file

@ -1,17 +1,22 @@
"""
Web method module.
Handles web operations using the web service.
Web operations method module.
Handles web scraping, crawling, and search operations.
"""
import logging
import requests
import json
import re
import copy
from typing import Dict, Any, List, Optional
from datetime import datetime, UTC
import requests
from bs4 import BeautifulSoup
from urllib.parse import urlparse, urljoin
import time
import uuid
import json
import copy
import random
from bs4 import BeautifulSoup
import os
# Selenium imports for JavaScript-heavy pages
from selenium import webdriver
from selenium.webdriver.chrome.options import Options
from selenium.common.exceptions import WebDriverException
@ -19,7 +24,8 @@ from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
from modules.chat.methodBase import MethodBase, ActionResult, action
from modules.chat.methodBase import MethodBase, action
from modules.interfaces.interfaceChatModel import ActionResult
from modules.shared.configuration import APP_CONFIG
logger = logging.getLogger(__name__)
@ -491,18 +497,10 @@ class MethodWeb(MethodBase):
expectedDocumentFormats = parameters.get("expectedDocumentFormats", [])
if not query:
return self._createResult(
success=False,
data={},
error="Search query is required"
)
return ActionResult.failure(error="Search query is required")
if not self.srcApikey:
return self._createResult(
success=False,
data={},
error="SerpAPI key not configured"
)
return ActionResult.failure(error="SerpAPI key not configured")
userLanguage = "en"
if hasattr(self.service, 'user') and hasattr(self.service.user, 'language'):
@ -555,24 +553,22 @@ class MethodWeb(MethodBase):
"timestamp": datetime.now(UTC).isoformat()
}
return self._createResult(
return ActionResult(
success=True,
data={
"documents": [
{
"documentName": f"web_search_{datetime.now(UTC).strftime('%Y%m%d_%H%M%S')}{output_extension}",
"documentData": result_data,
"mimeType": output_mime_type
}
]
}
documents=[
{
"documentName": f"web_search_{datetime.now(UTC).strftime('%Y%m%d_%H%M%S')}{output_extension}",
"documentData": result_data,
"mimeType": output_mime_type
}
],
resultLabel="web_search_results"
)
except Exception as e:
logger.error(f"Error searching web: {str(e)}")
return self._createResult(
return ActionResult(
success=False,
data={},
error=str(e)
)
@ -614,11 +610,7 @@ class MethodWeb(MethodBase):
expectedDocumentFormats = parameters.get("expectedDocumentFormats", [])
if not document:
return self._createResult(
success=False,
data={},
error="No document with URL list provided."
)
return ActionResult.failure(error="No document with URL list provided.")
# Read the document content
with open(document, "r", encoding="utf-8") as f:
@ -630,11 +622,7 @@ class MethodWeb(MethodBase):
urls = [u.strip() for u in urls if u.strip()]
if not urls:
return self._createResult(
success=False,
data={},
error="No valid URLs provided in the document."
)
return ActionResult.failure(error="No valid URLs provided in the document.")
crawl_results = []
for url in urls:
@ -701,24 +689,22 @@ class MethodWeb(MethodBase):
"timestamp": datetime.now(UTC).isoformat()
}
return self._createResult(
return ActionResult(
success=True,
data={
"documents": [
{
"documentName": f"web_crawl_{datetime.now(UTC).strftime('%Y%m%d_%H%M%S')}{output_extension}",
"documentData": result_data,
"mimeType": output_mime_type
}
]
}
documents=[
{
"documentName": f"web_crawl_{datetime.now(UTC).strftime('%Y%m%d_%H%M%S')}{output_extension}",
"documentData": result_data,
"mimeType": output_mime_type
}
],
resultLabel="web_crawl_results"
)
except Exception as e:
logger.error(f"Error crawling web pages: {str(e)}")
return self._createResult(
return ActionResult(
success=False,
data={},
error=str(e)
)
@ -740,18 +726,16 @@ class MethodWeb(MethodBase):
expectedDocumentFormats = parameters.get("expectedDocumentFormats", [])
if not url or not selectors:
return self._createResult(
return ActionResult(
success=False,
data={},
error="URL and selectors are required"
)
# Read the URL
soup = self._readUrl(url)
if not soup:
return self._createResult(
return ActionResult(
success=False,
data={},
error="Failed to read URL"
)
@ -810,24 +794,22 @@ class MethodWeb(MethodBase):
else:
logger.info(f"No expected format specified, using format parameter: {format}")
return self._createResult(
return ActionResult(
success=True,
data={
"documents": [
{
"documentName": f"web_scrape_{datetime.now(UTC).strftime('%Y%m%d_%H%M%S')}{output_extension}",
"documentData": result_data,
"mimeType": output_mime_type
}
]
}
documents=[
{
"documentName": f"web_scrape_{datetime.now(UTC).strftime('%Y%m%d_%H%M%S')}{output_extension}",
"documentData": result_data,
"mimeType": output_mime_type
}
],
resultLabel="web_scrape_results"
)
except Exception as e:
logger.error(f"Error scraping web page: {str(e)}")
return self._createResult(
return ActionResult(
success=False,
data={},
error=str(e)
)

View file

@ -339,6 +339,7 @@ async def auth_callback(code: str, state: str, request: Request) -> HTMLResponse
token = Token(
userId=user.id, # Use local user's ID
authority=AuthAuthority.GOOGLE,
connectionId=connection_id, # Link token to this specific connection
tokenAccess=token_response["access_token"],
tokenRefresh=token_response.get("refresh_token", ""),
tokenType=token_response.get("token_type", "bearer"),
@ -457,4 +458,87 @@ async def logout(
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to logout: {str(e)}"
)
@router.post("/refresh")
@limiter.limit("10/minute")
async def refresh_token(
request: Request,
currentUser: User = Depends(getCurrentUser)
) -> Dict[str, Any]:
"""Refresh Google OAuth token for current user"""
try:
appInterface = getInterface(currentUser)
# Find Google connection for this user
logger.debug(f"Looking for Google connection for user {currentUser.id}")
connections = appInterface.getUserConnections(currentUser.id)
google_connection = None
for conn in connections:
if conn.authority == AuthAuthority.GOOGLE:
google_connection = conn
break
if not google_connection:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No Google connection found for current user"
)
logger.debug(f"Found Google connection: {google_connection.id}, status={google_connection.status}")
# Get the token for this specific connection using the new method
current_token = appInterface.getTokenForConnection(google_connection.id, auto_refresh=False)
if not current_token:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No Google token found for this connection"
)
logger.debug(f"Found Google token: expires={current_token.expiresAt}, refresh_token_exists={bool(current_token.tokenRefresh)}")
# Always attempt refresh (as per your requirement)
from modules.security.tokenManager import TokenManager
token_manager = TokenManager()
refreshed_token = token_manager.refresh_token(current_token)
if refreshed_token:
# Save the new token and delete the old one
appInterface.saveToken(refreshed_token)
appInterface.deleteTokenByConnectionId(google_connection.id)
# Update the connection's expiration time
google_connection.expiresAt = datetime.fromtimestamp(refreshed_token.expiresAt)
google_connection.lastChecked = datetime.now()
google_connection.status = ConnectionStatus.ACTIVE
# Save updated connection
appInterface.db.recordModify("connections", google_connection.id, google_connection.to_dict())
# Calculate time until expiration
import time
current_time = time.time()
expires_in = int(refreshed_token.expiresAt - current_time)
return {
"message": "Token refreshed successfully",
"expires_at": refreshed_token.expiresAt,
"expires_in_seconds": expires_in
}
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to refresh token"
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error refreshing Google token: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to refresh token: {str(e)}"
)

View file

@ -318,16 +318,21 @@ async def auth_callback(code: str, state: str, request: Request) -> HTMLResponse
rootInterface.db.clearTableCache("connections")
# Save token
logger.info(f"Creating token for connection {connection_id}")
token = Token(
userId=user.id, # Use local user's ID
authority=AuthAuthority.MSFT,
connectionId=connection_id, # Link token to this specific connection
tokenAccess=token_response["access_token"],
tokenRefresh=token_response.get("refresh_token", ""),
tokenType=token_response.get("token_type", "bearer"),
expiresAt=datetime.now().timestamp() + token_response.get("expires_in", 0),
createdAt=datetime.now()
)
logger.info(f"Saving token with connectionId: {token.connectionId}")
interface.saveToken(token)
logger.info(f"Token saved successfully for connection {connection_id}")
# Return success page with connection data
return HTMLResponse(
@ -358,7 +363,7 @@ async def auth_callback(code: str, state: str, request: Request) -> HTMLResponse
"""
)
except Exception as e:
logger.error(f"Error updating connection: {str(e)}", exc_info=True)
logger.error(f"Error updating connection or saving token: {str(e)}", exc_info=True)
return HTMLResponse(
content=f"""
<html>
@ -368,7 +373,7 @@ async def auth_callback(code: str, state: str, request: Request) -> HTMLResponse
if (window.opener) {{
window.opener.postMessage({{
type: 'msft_connection_error',
error: 'Failed to update connection: {str(e)}'
error: 'Failed to update connection or save token: {str(e)}'
}}, '*');
// Wait for message to be sent before closing
setTimeout(() => window.close(), 1000);
@ -440,3 +445,86 @@ async def logout(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to logout: {str(e)}"
)
@router.post("/refresh")
@limiter.limit("10/minute")
async def refresh_token(
request: Request,
currentUser: User = Depends(getCurrentUser)
) -> Dict[str, Any]:
"""Refresh Microsoft OAuth token for current user"""
try:
appInterface = getInterface(currentUser)
# Find Microsoft connection for this user
logger.debug(f"Looking for Microsoft connection for user {currentUser.id}")
connections = appInterface.getUserConnections(currentUser.id)
msft_connection = None
for conn in connections:
if conn.authority == AuthAuthority.MSFT:
msft_connection = conn
break
if not msft_connection:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No Microsoft connection found for current user"
)
logger.debug(f"Found Microsoft connection: {msft_connection.id}, status={msft_connection.status}")
# Get the token for this specific connection using the new method
current_token = appInterface.getTokenForConnection(msft_connection.id, auto_refresh=False)
if not current_token:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="No Microsoft token found for this connection"
)
logger.debug(f"Found Microsoft token: expires={current_token.expiresAt}, refresh_token_exists={bool(current_token.tokenRefresh)}")
# Always attempt refresh (as per your requirement)
from modules.security.tokenManager import TokenManager
token_manager = TokenManager()
refreshed_token = token_manager.refresh_token(current_token)
if refreshed_token:
# Save the new token and delete the old one
appInterface.saveToken(refreshed_token)
appInterface.deleteTokenByConnectionId(msft_connection.id)
# Update the connection's expiration time
msft_connection.expiresAt = datetime.fromtimestamp(refreshed_token.expiresAt)
msft_connection.lastChecked = datetime.now()
msft_connection.status = ConnectionStatus.ACTIVE
# Save updated connection
appInterface.db.recordModify("connections", msft_connection.id, msft_connection.to_dict())
# Calculate time until expiration
import time
current_time = time.time()
expires_in = int(refreshed_token.expiresAt - current_time)
return {
"message": "Token refreshed successfully",
"expires_at": refreshed_token.expiresAt,
"expires_in_seconds": expires_in
}
else:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to refresh token"
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error refreshing Microsoft token: {str(e)}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to refresh token: {str(e)}"
)

View file

@ -0,0 +1,170 @@
"""
Token Manager Service
Handles all token operations including automatic refresh for backend services.
"""
import logging
import httpx
from datetime import datetime
from typing import Optional, Dict, Any
from modules.interfaces.interfaceAppModel import Token, AuthAuthority
from modules.shared.configuration import APP_CONFIG
logger = logging.getLogger(__name__)
class TokenManager:
"""Centralized token management service"""
def __init__(self):
# Microsoft OAuth configuration
self.msft_client_id = APP_CONFIG.get("Service_MSFT_CLIENT_ID")
self.msft_client_secret = APP_CONFIG.get("Service_MSFT_CLIENT_SECRET")
self.msft_tenant_id = APP_CONFIG.get("Service_MSFT_TENANT_ID", "common")
# Google OAuth configuration
self.google_client_id = APP_CONFIG.get("Service_GOOGLE_CLIENT_ID")
self.google_client_secret = APP_CONFIG.get("Service_GOOGLE_CLIENT_SECRET")
def refresh_microsoft_token(self, refresh_token: str, user_id: str, old_token: Token) -> Optional[Token]:
"""Refresh Microsoft OAuth token using refresh token"""
try:
if not self.msft_client_id or not self.msft_client_secret:
logger.error("Microsoft OAuth configuration not found")
return None
# Microsoft token refresh endpoint
token_url = f"https://login.microsoftonline.com/{self.msft_tenant_id}/oauth2/v2.0/token"
# Prepare refresh request
data = {
"client_id": self.msft_client_id,
"client_secret": self.msft_client_secret,
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"scope": "Mail.ReadWrite Mail.Send Mail.ReadWrite.Shared User.Read"
}
# Make refresh request
with httpx.Client(timeout=30.0) as client:
response = client.post(token_url, data=data)
if response.status_code == 200:
token_data = response.json()
# Create new token
new_token = Token(
userId=user_id,
authority=AuthAuthority.MSFT,
connectionId=old_token.connectionId, # Preserve connection ID
tokenAccess=token_data["access_token"],
tokenRefresh=token_data.get("refresh_token", refresh_token), # Keep old refresh token if new one not provided
tokenType=token_data.get("token_type", "bearer"),
expiresAt=datetime.now().timestamp() + token_data.get("expires_in", 3600),
createdAt=datetime.now()
)
logger.info(f"Successfully refreshed Microsoft token for user {user_id}")
return new_token
else:
logger.error(f"Failed to refresh Microsoft token: {response.status_code} - {response.text}")
return None
except Exception as e:
logger.error(f"Error refreshing Microsoft token: {str(e)}")
return None
def refresh_google_token(self, refresh_token: str, user_id: str, old_token: Token) -> Optional[Token]:
"""Refresh Google OAuth token using refresh token"""
try:
if not self.google_client_id or not self.google_client_secret:
logger.error("Google OAuth configuration not found")
return None
# Google token refresh endpoint
token_url = "https://oauth2.googleapis.com/token"
# Prepare refresh request
data = {
"client_id": self.google_client_id,
"client_secret": self.google_client_secret,
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"scope": "https://www.googleapis.com/auth/gmail.readonly https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email openid"
}
# Make refresh request
with httpx.Client(timeout=30.0) as client:
response = client.post(token_url, data=data)
if response.status_code == 200:
token_data = response.json()
# Create new token
new_token = Token(
userId=user_id,
authority=AuthAuthority.GOOGLE,
connectionId=old_token.connectionId, # Preserve connection ID
tokenAccess=token_data["access_token"],
tokenRefresh=refresh_token, # Google doesn't always provide new refresh token
tokenType=token_data.get("token_type", "bearer"),
expiresAt=datetime.now().timestamp() + token_data.get("expires_in", 3600),
createdAt=datetime.now()
)
logger.info(f"Successfully refreshed Google token for user {user_id}")
return new_token
else:
logger.error(f"Failed to refresh Google token: {response.status_code} - {response.text}")
return None
except Exception as e:
logger.error(f"Error refreshing Google token: {str(e)}")
return None
def refresh_token(self, old_token: Token) -> Optional[Token]:
"""Refresh an expired token using the appropriate OAuth service"""
try:
if not old_token.tokenRefresh:
logger.warning(f"No refresh token available for {old_token.authority}")
return None
# Route to appropriate refresh method
if old_token.authority == AuthAuthority.MSFT:
return self.refresh_microsoft_token(old_token.tokenRefresh, old_token.userId, old_token)
elif old_token.authority == AuthAuthority.GOOGLE:
return self.refresh_google_token(old_token.tokenRefresh, old_token.userId, old_token)
else:
logger.warning(f"Unknown authority for token refresh: {old_token.authority}")
return None
except Exception as e:
logger.error(f"Error refreshing token: {str(e)}")
return None
def is_token_expired(self, token: Token) -> bool:
"""Check if a token is expired"""
if not token.expiresAt:
return False
return datetime.now().timestamp() > token.expiresAt
def get_token_status(self, token: Token) -> Dict[str, Any]:
"""Get comprehensive token status information"""
current_time = datetime.now().timestamp()
if not token.expiresAt:
return {
"status": "valid",
"expires_at": None,
"expires_in_seconds": None,
"expires_soon": False
}
expires_in = int(token.expiresAt - current_time)
return {
"status": "expired" if expires_in <= 0 else "valid",
"expires_at": token.expiresAt,
"expires_in_seconds": expires_in,
"expires_soon": expires_in <= 3600 # 1 hour
}

View file

@ -1,12 +1,19 @@
TODO
- check history --> tasks?
- model reference diagram for all models. who uses who? --> to see the basic building blocks
- automatischer token refresh msft und google integrieren
- check method outlook: alles
- check method sharepoint: alles
- check method webcrawler: alles
- check method google: alles
- check zusammenfassung von 10 dokumenten >10 MB
- test case bewerbung
- neutralizer to activate AND put back placeholders to the returned data
- referenceHandling and authentication for connections in the method actions
- check methods
- test for workflow backend with userdata
- prompt for task definition to fix
- method definition list directly based on functions
- neutralizer to put back placeholders to the returned data after ai

View file

@ -91,6 +91,7 @@ Für größere Installationen die JSON-basierte Datenbank ersetzen durch:
git remote set-url origin https://valueon@github.com/valueonag/gateway
git remote set-url origin https://valueon@github.com/valueonag/frontend_agents
git remote set-url origin https://valueon@github.com/valueonag/wiki
git remote set-url origin https://valueon@github.com/valueonag/customer-svbe
### git delete workflow runs (cleanup)