fixed import chain, removed invalid imports by moving logic between modules

This commit is contained in:
ValueOn AG 2025-12-03 07:31:51 +01:00
parent 61f42259e5
commit 9f46ca3b03
31 changed files with 3058 additions and 1863 deletions

11
app.py
View file

@ -16,6 +16,7 @@ from datetime import datetime
from modules.shared.configuration import APP_CONFIG
from modules.shared.eventManagement import eventManager
from modules.features import featuresLifecycle as featuresLifecycle
from modules.interfaces.interfaceDbAppObjects import getRootInterface
class DailyRotatingFileHandler(RotatingFileHandler):
"""
@ -275,15 +276,21 @@ instanceLabel = APP_CONFIG.get("APP_ENV_LABEL")
async def lifespan(app: FastAPI):
logger.info("Application is starting up")
# Get event user for feature lifecycle (system-level user for background operations)
rootInterface = getRootInterface()
eventUser = rootInterface.getUserByUsername("event")
if not eventUser:
logger.error("Could not get event user - some features may not start properly")
# --- Init Managers ---
await featuresLifecycle.start()
await featuresLifecycle.start(eventUser)
eventManager.start()
yield
# --- Stop Managers ---
eventManager.stop()
await featuresLifecycle.stop()
await featuresLifecycle.stop(eventUser)
logger.info("Application has been shut down")

View file

@ -0,0 +1,326 @@
<mxfile host="app.diagrams.net">
<diagram name="Module Dependencies" id="dependency-diagram">
<mxGraphModel dx="1422" dy="794" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1400" pageHeight="1200" math="0" shadow="0">
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
<!-- Foundation Layer -->
<mxCell id="shared" value="shared/&#xa;(Foundation)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1f5ff;strokeColor=#01579b;strokeWidth=2;fontStyle=1" vertex="1" parent="1">
<mxGeometry x="600" y="100" width="120" height="60" as="geometry" />
</mxCell>
<mxCell id="datamodels" value="datamodels/&#xa;(Foundation)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1f5ff;strokeColor=#01579b;strokeWidth=2;fontStyle=1" vertex="1" parent="1">
<mxGeometry x="400" y="100" width="120" height="60" as="geometry" />
</mxCell>
<mxCell id="aicore" value="aicore/&#xa;(Infrastructure)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1f5ff;strokeColor=#01579b;strokeWidth=2;fontStyle=1" vertex="1" parent="1">
<mxGeometry x="200" y="100" width="120" height="60" as="geometry" />
</mxCell>
<mxCell id="connectors" value="connectors/&#xa;(Infrastructure)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1f5ff;strokeColor=#01579b;strokeWidth=2;fontStyle=1" vertex="1" parent="1">
<mxGeometry x="800" y="100" width="120" height="60" as="geometry" />
</mxCell>
<!-- Data Layer -->
<mxCell id="interfaces" value="interfaces/&#xa;(Data Access)&#xa;✅ No violations" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f3e5f5;strokeColor=#4a148c;strokeWidth=3;fontStyle=1" vertex="1" parent="1">
<mxGeometry x="500" y="250" width="200" height="80" as="geometry" />
</mxCell>
<!-- Business Logic Layer -->
<mxCell id="services" value="services/&#xa;(Business Logic)&#xa;✅ Unidirectional" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e8f5e9;strokeColor=#1b5e20;strokeWidth=2;fontStyle=1" vertex="1" parent="1">
<mxGeometry x="300" y="400" width="200" height="80" as="geometry" />
</mxCell>
<mxCell id="workflows" value="workflows/&#xa;(Business Logic)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e8f5e9;strokeColor=#1b5e20;strokeWidth=2;fontStyle=1" vertex="1" parent="1">
<mxGeometry x="700" y="400" width="200" height="80" as="geometry" />
</mxCell>
<!-- Feature Layer -->
<mxCell id="features" value="features/&#xa;(Features)&#xa;✅ Unidirectional" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff3e0;strokeColor=#e65100;strokeWidth=2;fontStyle=1" vertex="1" parent="1">
<mxGeometry x="500" y="550" width="200" height="80" as="geometry" />
</mxCell>
<!-- API Layer -->
<mxCell id="routes" value="routes/&#xa;(API Layer)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fce4ec;strokeColor=#880e4f;strokeWidth=2;fontStyle=1" vertex="1" parent="1">
<mxGeometry x="300" y="700" width="200" height="80" as="geometry" />
</mxCell>
<mxCell id="security" value="security/&#xa;(Security)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fce4ec;strokeColor=#880e4f;strokeWidth=2;fontStyle=1" vertex="1" parent="1">
<mxGeometry x="700" y="700" width="200" height="80" as="geometry" />
</mxCell>
<!-- Foundation dependencies -->
<mxCell id="edge1" value="" style="endArrow=classic;html=1;rounded=0;strokeWidth=2;strokeColor=#01579b" edge="1" parent="1" source="datamodels" target="shared">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="400" y="300" as="sourcePoint" />
<mxPoint x="450" y="250" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="edge2" value="" style="endArrow=classic;html=1;rounded=0;strokeWidth=2;strokeColor=#01579b" edge="1" parent="1" source="aicore" target="datamodels">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="300" y="300" as="sourcePoint" />
<mxPoint x="350" y="250" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="edge3" value="" style="endArrow=classic;html=1;rounded=0;strokeWidth=2;strokeColor=#01579b" edge="1" parent="1" source="aicore" target="shared">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="300" y="300" as="sourcePoint" />
<mxPoint x="350" y="250" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="edge4" value="" style="endArrow=classic;html=1;rounded=0;strokeWidth=2;strokeColor=#01579b" edge="1" parent="1" source="connectors" target="datamodels">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="800" y="300" as="sourcePoint" />
<mxPoint x="450" y="250" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="edge5" value="" style="endArrow=classic;html=1;rounded=0;strokeWidth=2;strokeColor=#01579b" edge="1" parent="1" source="connectors" target="shared">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="800" y="300" as="sourcePoint" />
<mxPoint x="650" y="250" as="targetPoint" />
</mxGeometry>
</mxCell>
<!-- Interface layer dependencies -->
<mxCell id="edge6" value="" style="endArrow=classic;html=1;rounded=0;strokeWidth=2;strokeColor=#4a148c" edge="1" parent="1" source="interfaces" target="aicore">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="500" y="400" as="sourcePoint" />
<mxPoint x="350" y="300" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="edge7" value="" style="endArrow=classic;html=1;rounded=0;strokeWidth=2;strokeColor=#4a148c" edge="1" parent="1" source="interfaces" target="connectors">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="600" y="400" as="sourcePoint" />
<mxPoint x="750" y="300" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="edge8" value="" style="endArrow=classic;html=1;rounded=0;strokeWidth=2;strokeColor=#4a148c" edge="1" parent="1" source="interfaces" target="datamodels">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="550" y="400" as="sourcePoint" />
<mxPoint x="450" y="300" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="edge9" value="callbackRegistry" style="endArrow=classic;html=1;rounded=0;strokeWidth=2;strokeColor=#4a148c;dashed=1" edge="1" parent="1" source="interfaces" target="shared">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="600" y="400" as="sourcePoint" />
<mxPoint x="650" y="300" as="targetPoint" />
</mxGeometry>
</mxCell>
<!-- Service layer dependencies -->
<mxCell id="edge10" value="✅" style="endArrow=classic;html=1;rounded=0;strokeWidth=3;strokeColor=#1b5e20" edge="1" parent="1" source="services" target="interfaces">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="400" y="500" as="sourcePoint" />
<mxPoint x="550" y="450" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="edge11" value="" style="endArrow=classic;html=1;rounded=0;strokeWidth=2;strokeColor=#1b5e20" edge="1" parent="1" source="services" target="aicore">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="350" y="500" as="sourcePoint" />
<mxPoint x="250" y="300" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="edge12" value="" style="endArrow=classic;html=1;rounded=0;strokeWidth=2;strokeColor=#1b5e20" edge="1" parent="1" source="services" target="datamodels">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="400" y="500" as="sourcePoint" />
<mxPoint x="450" y="300" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="edge13" value="" style="endArrow=classic;html=1;rounded=0;strokeWidth=2;strokeColor=#1b5e20" edge="1" parent="1" source="services" target="shared">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="450" y="500" as="sourcePoint" />
<mxPoint x="650" y="300" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="edge14" value="" style="endArrow=classic;html=1;rounded=0;strokeWidth=2;strokeColor=#1b5e20" edge="1" parent="1" source="services" target="security">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="400" y="500" as="sourcePoint" />
<mxPoint x="800" y="650" as="targetPoint" />
</mxGeometry>
</mxCell>
<!-- Workflow layer dependencies -->
<mxCell id="edge15" value="" style="endArrow=classic;html=1;rounded=0;strokeWidth=2;strokeColor=#1b5e20" edge="1" parent="1" source="workflows" target="aicore">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="750" y="500" as="sourcePoint" />
<mxPoint x="250" y="300" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="edge16" value="" style="endArrow=classic;html=1;rounded=0;strokeWidth=2;strokeColor=#1b5e20" edge="1" parent="1" source="workflows" target="datamodels">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="800" y="500" as="sourcePoint" />
<mxPoint x="450" y="300" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="edge17" value="" style="endArrow=classic;html=1;rounded=0;strokeWidth=2;strokeColor=#1b5e20" edge="1" parent="1" source="workflows" target="services">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="750" y="500" as="sourcePoint" />
<mxPoint x="450" y="450" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="edge18" value="" style="endArrow=classic;html=1;rounded=0;strokeWidth=2;strokeColor=#1b5e20" edge="1" parent="1" source="workflows" target="shared">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="800" y="500" as="sourcePoint" />
<mxPoint x="650" y="300" as="targetPoint" />
</mxGeometry>
</mxCell>
<!-- Feature layer dependencies -->
<mxCell id="edge19" value="✅" style="endArrow=classic;html=1;rounded=0;strokeWidth=3;strokeColor=#e65100" edge="1" parent="1" source="features" target="interfaces">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="550" y="600" as="sourcePoint" />
<mxPoint x="600" y="450" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="edge20" value="✅" style="endArrow=classic;html=1;rounded=0;strokeWidth=3;strokeColor=#e65100" edge="1" parent="1" source="features" target="services">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="550" y="600" as="sourcePoint" />
<mxPoint x="450" y="500" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="edge21" value="" style="endArrow=classic;html=1;rounded=0;strokeWidth=2;strokeColor=#e65100" edge="1" parent="1" source="features" target="datamodels">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="550" y="600" as="sourcePoint" />
<mxPoint x="450" y="300" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="edge22" value="" style="endArrow=classic;html=1;rounded=0;strokeWidth=2;strokeColor=#e65100" edge="1" parent="1" source="features" target="workflows">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="600" y="600" as="sourcePoint" />
<mxPoint x="750" y="500" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="edge23" value="" style="endArrow=classic;html=1;rounded=0;strokeWidth=2;strokeColor=#e65100" edge="1" parent="1" source="features" target="shared">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="600" y="600" as="sourcePoint" />
<mxPoint x="650" y="300" as="targetPoint" />
</mxGeometry>
</mxCell>
<!-- Routes dependencies -->
<mxCell id="edge24" value="" style="endArrow=classic;html=1;rounded=0;strokeWidth=2;strokeColor=#880e4f" edge="1" parent="1" source="routes" target="interfaces">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="400" y="800" as="sourcePoint" />
<mxPoint x="550" y="450" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="edge25" value="" style="endArrow=classic;html=1;rounded=0;strokeWidth=2;strokeColor=#880e4f" edge="1" parent="1" source="routes" target="features">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="400" y="800" as="sourcePoint" />
<mxPoint x="550" y="600" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="edge26" value="" style="endArrow=classic;html=1;rounded=0;strokeWidth=2;strokeColor=#880e4f" edge="1" parent="1" source="routes" target="services">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="400" y="800" as="sourcePoint" />
<mxPoint x="450" y="500" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="edge27" value="" style="endArrow=classic;html=1;rounded=0;strokeWidth=2;strokeColor=#880e4f" edge="1" parent="1" source="routes" target="security">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="500" y="800" as="sourcePoint" />
<mxPoint x="800" y="700" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="edge28" value="" style="endArrow=classic;html=1;rounded=0;strokeWidth=2;strokeColor=#880e4f" edge="1" parent="1" source="routes" target="datamodels">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="400" y="800" as="sourcePoint" />
<mxPoint x="450" y="300" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="edge29" value="" style="endArrow=classic;html=1;rounded=0;strokeWidth=2;strokeColor=#880e4f" edge="1" parent="1" source="routes" target="shared">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="450" y="800" as="sourcePoint" />
<mxPoint x="650" y="300" as="targetPoint" />
</mxGeometry>
</mxCell>
<!-- Security dependencies -->
<mxCell id="edge30" value="" style="endArrow=classic;html=1;rounded=0;strokeWidth=2;strokeColor=#880e4f" edge="1" parent="1" source="security" target="interfaces">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="800" y="800" as="sourcePoint" />
<mxPoint x="600" y="450" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="edge31" value="" style="endArrow=classic;html=1;rounded=0;strokeWidth=2;strokeColor=#880e4f" edge="1" parent="1" source="security" target="datamodels">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="800" y="800" as="sourcePoint" />
<mxPoint x="450" y="300" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="edge32" value="" style="endArrow=classic;html=1;rounded=0;strokeWidth=2;strokeColor=#880e4f" edge="1" parent="1" source="security" target="shared">
<mxGeometry width="50" height="50" relative="1" as="geometry">
<mxPoint x="800" y="800" as="sourcePoint" />
<mxPoint x="650" y="300" as="targetPoint" />
</mxGeometry>
</mxCell>
<!-- Legend -->
<mxCell id="legend" value="Legend" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontStyle=1;fontSize=14" vertex="1" parent="1">
<mxGeometry x="1000" y="100" width="100" height="30" as="geometry" />
</mxCell>
<mxCell id="legend1" value="Foundation Layer" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1f5ff;strokeColor=#01579b;strokeWidth=2" vertex="1" parent="1">
<mxGeometry x="1000" y="140" width="150" height="30" as="geometry" />
</mxCell>
<mxCell id="legend2" value="Data Access Layer" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f3e5f5;strokeColor=#4a148c;strokeWidth=2" vertex="1" parent="1">
<mxGeometry x="1000" y="180" width="150" height="30" as="geometry" />
</mxCell>
<mxCell id="legend3" value="Business Logic Layer" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e8f5e9;strokeColor=#1b5e20;strokeWidth=2" vertex="1" parent="1">
<mxGeometry x="1000" y="220" width="150" height="30" as="geometry" />
</mxCell>
<mxCell id="legend4" value="Feature Layer" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff3e0;strokeColor=#e65100;strokeWidth=2" vertex="1" parent="1">
<mxGeometry x="1000" y="260" width="150" height="30" as="geometry" />
</mxCell>
<mxCell id="legend5" value="API Layer" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fce4ec;strokeColor=#880e4f;strokeWidth=2" vertex="1" parent="1">
<mxGeometry x="1000" y="300" width="150" height="30" as="geometry" />
</mxCell>
<mxCell id="legend6" value="✅ Correct Direction" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0" vertex="1" parent="1">
<mxGeometry x="1000" y="350" width="150" height="20" as="geometry" />
</mxCell>
<mxCell id="legend7" value="--- Callback Registry" style="text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;dashed=1" vertex="1" parent="1">
<mxGeometry x="1000" y="380" width="150" height="20" as="geometry" />
</mxCell>
<!-- Status Box -->
<mxCell id="status" value="✅ ZERO VIOLATIONS&#xa;Perfect Architectural Compliance" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#c8e6c9;strokeColor=#2e7d32;strokeWidth=3;fontStyle=1;fontSize=12" vertex="1" parent="1">
<mxGeometry x="1000" y="450" width="200" height="60" as="geometry" />
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>

View file

@ -0,0 +1,407 @@
# Automation Feature Analysis: Moving Automation Handler to Features Layer
## Executive Summary
**Status: ✅ HIGHLY RECOMMENDED - Architectural Improvement**
Moving automation workflow handler functionality from `interfaces/interfaceDbChatObjects.py` to a new feature in `features/` is **architecturally correct** and aligns with separation of concerns.
---
## Current Architecture Analysis
### Current Location: `interfaces/interfaceDbChatObjects.py`
**Automation-related methods:**
1. `executeAutomation(automationId: str)` - Executes automation workflow immediately (test mode)
2. `syncAutomationEvents()` - Syncs scheduler with all active automations
3. `_createAutomationEventHandler(automationId: str)` - Creates event handler for scheduled execution
4. `_parseScheduleToCron(schedule: str)` - Parses schedule string to cron kwargs
5. `_planToPrompt(plan: Dict)` - Converts plan structure to prompt string
6. `_replacePlaceholders(template: str, placeholders: Dict)` - Replaces placeholders in template
**Dependencies:**
- Uses `getAutomationDefinition()` - Database access (should stay in interface)
- Uses `chatStart()` from `features.chatPlayground` - Already imports from features
- Uses `eventManager` from `shared.eventManagement` - Foundation layer
- Creates workflows using `WorkflowModeEnum.WORKFLOW_AUTOMATION`
---
## Why This Should Be a Feature
### 1. **Business Logic vs. Data Access**
**Current Problem:**
- Automation execution logic is **business logic** (orchestration, workflow creation)
- It's mixed with **data access** (interface layer)
- Interface layer should only provide data access, not business orchestration
**After Move:**
- Interface layer: `getAutomationDefinition()`, `saveAutomationDefinition()` (data access)
- Feature layer: `executeAutomation()`, `syncAutomationEvents()` (business logic)
### 2. **Feature Pattern Consistency**
**Existing Features Pattern:**
- `features/chatPlayground/` - Chat workflow execution
- `features/chatAlthaus/` - Scheduled data updates
- `features/syncDelta/` - Sync management
- `features/neutralizePlayground/` - Neutralization workflows
**Automation Handler Pattern:**
- Scheduled execution (like `chatAlthaus`)
- Workflow orchestration (like `chatPlayground`)
- Event-driven (like `syncDelta`)
**Conclusion:** Automation handler fits the feature pattern perfectly.
### 3. **Dependency Direction**
**Current Violation:**
- `interfaces/` imports from `features/` (line 1927: `from modules.features.chatPlayground.mainChatPlayground import chatStart`)
- This creates bidirectional dependency: `interfaces/ ↔ features/`
**After Move:**
- `features/automation/` imports from `interfaces/` (correct direction)
- `features/automation/` imports from `features/chatPlayground/` (feature-to-feature)
- Eliminates `interfaces/ → features/` import
### 4. **Separation of Concerns**
**Interface Layer Should:**
- ✅ Provide data access (`getAutomationDefinition`, `saveAutomationDefinition`)
- ✅ Handle CRUD operations
- ❌ NOT orchestrate workflows
- ❌ NOT manage scheduling
- ❌ NOT execute business logic
**Feature Layer Should:**
- ✅ Orchestrate workflows
- ✅ Manage scheduling
- ✅ Execute business logic
- ✅ Coordinate between services and interfaces
---
## Proposed Architecture
### New Structure: `features/automation/`
```
features/automation/
├── mainAutomation.py # Main automation service
│ ├── executeAutomation() # Execute automation workflow
│ ├── syncAutomationEvents() # Sync scheduler with automations
│ └── _createAutomationEventHandler() # Create event handler
└── subAutomationUtils.py # Utility functions
├── _parseScheduleToCron() # Parse schedule to cron
├── _planToPrompt() # Convert plan to prompt
└── _replacePlaceholders() # Replace template placeholders
```
### Interface Layer (Keep)
```
interfaces/interfaceDbChatObjects.py
├── getAutomationDefinition() # Data access - KEEP
├── saveAutomationDefinition() # Data access - KEEP
└── (other CRUD methods) # Data access - KEEP
```
### Feature Lifecycle Integration
```
features/featuresLifecycle.py
├── start()
│ └── from features.automation import mainAutomation
│ mainAutomation.startScheduler(eventUser)
└── stop()
└── mainAutomation.stopScheduler()
```
---
## Detailed Functionality Analysis
### Functions to Move
#### 1. `executeAutomation(automationId: str)``features/automation/mainAutomation.py`
**Current Implementation:**
- Loads automation definition (calls interface method)
- Replaces placeholders in template
- Creates UserInputRequest
- Calls `chatStart()` from `features.chatPlayground`
- Returns ChatWorkflow
**Dependencies:**
- `getAutomationDefinition()` - Interface method (import from interface)
- `_replacePlaceholders()` - Utility (move to feature)
- `_planToPrompt()` - Utility (move to feature)
- `chatStart()` - Feature method (import from feature)
- `getInterface()` - Interface factory (import from interface)
**After Move:**
```python
# features/automation/mainAutomation.py
from modules.interfaces.interfaceDbChatObjects import getInterface as getChatInterface
from modules.interfaces.interfaceDbAppObjects import getInterface as getAppInterface
from modules.features.chatPlayground.mainChatPlayground import chatStart
from .subAutomationUtils import replacePlaceholders, planToPrompt
async def executeAutomation(automationId: str, chatInterface) -> ChatWorkflow:
"""Execute automation workflow immediately."""
# Load automation (uses interface)
automation = chatInterface.getAutomationDefinition(automationId)
# ... rest of logic
```
#### 2. `syncAutomationEvents()``features/automation/mainAutomation.py`
**Current Implementation:**
- Gets all automation definitions (calls interface method)
- Parses schedules
- Registers cron jobs with eventManager
- Creates event handlers
**Dependencies:**
- `getRecordset()` - Interface method (via chatInterface)
- `_parseScheduleToCron()` - Utility (move to feature)
- `_createAutomationEventHandler()` - Handler creation (move to feature)
- `eventManager` - Foundation layer (import from shared)
**After Move:**
```python
# features/automation/mainAutomation.py
from modules.shared.eventManagement import eventManager
from modules.interfaces.interfaceDbChatObjects import getInterface as getChatInterface
from .subAutomationUtils import parseScheduleToCron
async def syncAutomationEvents(chatInterface) -> Dict[str, Any]:
"""Sync scheduler with all active automations."""
# Get automations (uses interface)
allAutomations = chatInterface.db.getRecordset(AutomationDefinition)
# ... rest of logic
```
#### 3. `_createAutomationEventHandler(automationId: str)``features/automation/mainAutomation.py`
**Current Implementation:**
- Creates async handler function
- Gets event user
- Loads automation
- Executes automation with creator user context
**Dependencies:**
- `getRootInterface()` - Interface factory (import from interface)
- `getInterface()` - Interface factories (import from interfaces)
- `executeAutomation()` - Will be in same module
**After Move:**
```python
# features/automation/mainAutomation.py
def createAutomationEventHandler(automationId: str):
"""Create event handler function for scheduled automation."""
async def handler():
# Uses interfaces and executeAutomation from same module
await executeAutomation(automationId, eventInterface)
return handler
```
#### 4. Utility Functions → `features/automation/subAutomationUtils.py`
**Functions:**
- `_parseScheduleToCron(schedule: str)` - Parse schedule to cron kwargs
- `_planToPrompt(plan: Dict)` - Convert plan to prompt string
- `_replacePlaceholders(template: str, placeholders: Dict)` - Replace placeholders
**Dependencies:**
- No external dependencies (pure utility functions)
---
## Migration Plan
### Phase 1: Create Feature Structure
1. Create `features/automation/` directory
2. Create `features/automation/__init__.py`
3. Create `features/automation/mainAutomation.py`
4. Create `features/automation/subAutomationUtils.py`
### Phase 2: Move Functions
1. Move utility functions to `subAutomationUtils.py`
2. Move `executeAutomation()` to `mainAutomation.py`
3. Move `syncAutomationEvents()` to `mainAutomation.py`
4. Move `_createAutomationEventHandler()` to `mainAutomation.py`
### Phase 3: Update Dependencies
1. Update `features/featuresLifecycle.py` to use new feature
2. Update `routes/routeAdminAutomationEvents.py` to use new feature
3. Update any other call sites
### Phase 4: Cleanup Interface
1. Remove moved functions from `interfaceDbChatObjects.py`
2. Keep only data access methods (`getAutomationDefinition`, etc.)
3. Remove import from `features.chatPlayground` from interface
### Phase 5: Update Documentation
1. Update `BIDIRECTIONAL_IMPORTS.md` to reflect resolved dependency
2. Document new feature structure
---
## Benefits
### ✅ Architectural Benefits
1. **Correct Separation of Concerns**
- Interface layer: Data access only
- Feature layer: Business logic and orchestration
2. **Resolves Bidirectional Dependency**
- Eliminates `interfaces/ → features/` import
- Only `features/ → interfaces/` remains (correct direction)
3. **Consistency with Existing Patterns**
- Matches other feature implementations
- Follows established architecture
4. **Better Testability**
- Feature logic can be tested independently
- Interface layer remains focused
### ✅ Maintainability Benefits
1. **Clearer Code Organization**
- Automation logic in one place
- Easier to find and modify
2. **Reduced Coupling**
- Interface layer doesn't depend on features
- Features depend on interfaces (correct direction)
3. **Easier to Extend**
- New automation features can be added to feature module
- Interface layer remains stable
---
## Risks and Considerations
### 🟢 Low Risk
- **Functionality preservation** - Logic doesn't change, only location
- **Interface methods remain** - Data access methods stay in interface
- **Lazy imports** - Already using lazy imports for event handlers
### 🟡 Medium Risk
- **Call site updates** - Need to update routes and lifecycle
- **Interface method access** - Feature needs to call interface methods
- **Event user context** - Need to ensure proper user context handling
### 🔴 Potential Issues
1. **Interface method access** - Feature needs `chatInterface` instance
- **Solution:** Pass interface instance as parameter or create in feature
2. **Event handler context** - Event handlers need interface access
- **Solution:** Create interface instances in handler (already doing this)
3. **Backward compatibility** - Existing code calling `chatInterface.executeAutomation()`
- **Solution:** Update all call sites, or create wrapper method in interface (deprecated)
---
## Call Sites Analysis
### Current Call Sites
1. **`features/featuresLifecycle.py` (line 20)**
```python
await chatInterface.syncAutomationEvents()
```
**Update:** Import and call feature directly
2. **`routes/routeAdminAutomationEvents.py` (line 97)**
```python
result = await chatInterface.syncAutomationEvents()
```
**Update:** Import and call feature directly
3. **`interfaces/interfaceDbChatObjects.py` (lines 1683, 1714, 1744)**
```python
asyncio.create_task(self.syncAutomationEvents())
```
**Update:** Remove (will be handled by feature lifecycle)
4. **`_createAutomationEventHandler()` (line 2105)**
```python
await creatorInterface.executeAutomation(automationId)
```
**Update:** Call feature method instead
### Proposed Call Sites
1. **`features/featuresLifecycle.py`**
```python
from modules.features.automation import mainAutomation
await mainAutomation.syncAutomationEvents(chatInterface)
```
2. **`routes/routeAdminAutomationEvents.py`**
```python
from modules.features.automation import mainAutomation
result = await mainAutomation.syncAutomationEvents(chatInterface)
```
3. **`features/automation/mainAutomation.py` (event handler)**
```python
from .mainAutomation import executeAutomation
await executeAutomation(automationId, eventInterface)
```
---
## Implementation Checklist
- [ ] Create `features/automation/` directory structure
- [ ] Create `features/automation/__init__.py`
- [ ] Create `features/automation/mainAutomation.py`
- [ ] Create `features/automation/subAutomationUtils.py`
- [ ] Move `_parseScheduleToCron()` to `subAutomationUtils.py`
- [ ] Move `_planToPrompt()` to `subAutomationUtils.py`
- [ ] Move `_replacePlaceholders()` to `subAutomationUtils.py`
- [ ] Move `executeAutomation()` to `mainAutomation.py`
- [ ] Move `syncAutomationEvents()` to `mainAutomation.py`
- [ ] Move `_createAutomationEventHandler()` to `mainAutomation.py`
- [ ] Update `features/featuresLifecycle.py` to use feature
- [ ] Update `routes/routeAdminAutomationEvents.py` to use feature
- [ ] Remove moved functions from `interfaceDbChatObjects.py`
- [ ] Remove `features.chatPlayground` import from `interfaceDbChatObjects.py`
- [ ] Update `BIDIRECTIONAL_IMPORTS.md`
- [ ] Test automation execution (manual)
- [ ] Test automation scheduling (scheduled)
- [ ] Verify no circular dependencies
---
## Conclusion
**✅ STRONGLY RECOMMENDED: Move to Features Layer**
This refactoring is:
- **Architecturally correct** - Business logic belongs in features, not interfaces
- **Resolves dependency violation** - Eliminates `interfaces/ → features/` import
- **Consistent with patterns** - Matches existing feature implementations
- **Low risk** - Logic doesn't change, only location
- **Improves maintainability** - Clearer separation of concerns
**Recommendation: PROCEED** with moving automation handler functionality to `features/automation/` following the plan above.

View file

@ -0,0 +1,406 @@
# Bidirectional Import Analysis
## Summary
After refactoring extraction functions and automation handler, **ALL bidirectional dependencies have been RESOLVED**:
**Current Status:**
- ✅ **interfaces/ → services/**: **RESOLVED** (no imports)
- ✅ **interfaces/ → features/**: **RESOLVED** (uses callback registry, no direct imports)
- ✅ **services/ → interfaces/**: **UNIDIRECTIONAL** (correct dependency direction)
- ✅ **services/ → features/**: **NONE** (no imports)
- ✅ **features/ → interfaces/**: **UNIDIRECTIONAL** (correct dependency direction)
- ✅ **features/ → services/**: **1 lazy import** (correct direction)
**Result:** ✅ **ZERO VIOLATIONS** - Perfect architectural compliance achieved.
---
## Dependency Diagram
### Mermaid Diagram
```mermaid
graph TB
%% Foundation Layer (no dependencies)
shared[shared/<br/>Foundation]
datamodels[datamodels/<br/>Foundation]
aicore[aicore/<br/>Infrastructure]
connectors[connectors/<br/>Infrastructure]
%% Data Layer
interfaces[interfaces/<br/>Data Access<br/>✅ No violations]
%% Business Logic Layer
services[services/<br/>Business Logic<br/>✅ Unidirectional]
workflows[workflows/<br/>Business Logic]
%% Feature Layer
features[features/<br/>Features<br/>✅ Unidirectional]
%% API Layer
routes[routes/<br/>API Layer]
security[security/<br/>Security]
%% Foundation dependencies
datamodels -->|imports| shared
aicore -->|imports| datamodels
aicore -->|imports| shared
connectors -->|imports| datamodels
connectors -->|imports| shared
%% Interface layer (foundation only)
interfaces -->|imports| aicore
interfaces -->|imports| connectors
interfaces -->|imports| datamodels
interfaces -.->|callbackRegistry| shared
%% Service layer (interfaces only)
services -->|✅ imports| interfaces
services -->|imports| aicore
services -->|imports| datamodels
services -->|imports| security
services -->|imports| shared
%% Workflow layer
workflows -->|imports| aicore
workflows -->|imports| datamodels
workflows -->|imports| services
workflows -->|imports| shared
%% Feature layer (interfaces + services)
features -->|✅ imports| interfaces
features -->|✅ imports| services
features -->|imports| datamodels
features -->|imports| workflows
features -->|imports| shared
%% API layer
routes -->|imports| interfaces
routes -->|imports| features
routes -->|imports| services
routes -->|imports| security
routes -->|imports| datamodels
routes -->|imports| shared
%% Security layer
security -->|imports| interfaces
security -->|imports| datamodels
security -->|imports| shared
%% Styling
classDef foundation fill:#e1f5ff,stroke:#01579b,stroke-width:2px
classDef data fill:#f3e5f5,stroke:#4a148c,stroke-width:3px
classDef business fill:#e8f5e9,stroke:#1b5e20,stroke-width:2px
classDef feature fill:#fff3e0,stroke:#e65100,stroke-width:2px
classDef api fill:#fce4ec,stroke:#880e4f,stroke-width:2px
class shared,datamodels,aicore,connectors foundation
class interfaces data
class services,workflows business
class features feature
class routes,security api
```
### Draw.io Diagram
A detailed draw.io diagram is available in `DEPENDENCY_DIAGRAM.drawio` with:
- Color-coded layers (Foundation, Data, Business Logic, Features, API)
- Arrow directions showing import relationships
- ✅ markers on correct dependency directions
- Dashed line for callback registry pattern
- Status box showing zero violations
**Key Visual Elements:**
- **Thick green arrows (✅)**: Correct dependency directions (services→interfaces, features→interfaces, features→services)
- **Dashed purple line**: Callback registry pattern (interfaces→shared, decoupled from features)
- **Color coding**: Each layer has distinct colors for easy identification
- **Status indicators**: ✅ markers show compliance, "No violations" labels confirm architectural correctness
---
## Detailed Analysis
### 1. interfaces/ → services/ ✅ RESOLVED
**Current State:**
- ✅ **No imports from services/** in `interfaces/`
- All extraction-related functions moved to `services/serviceExtraction/`
- Dependency violations resolved
**Impact:** Major architectural improvement - interfaces no longer depend on services.
---
### 2. interfaces/ → features/ ✅ RESOLVED
**Previous State:**
- `interfaceDbChatObjects.py` (line 1754): Lazy import in `_triggerAutomationSync()` helper method
```python
from modules.features.automation import syncAutomationEvents
```
**Current State:**
- ✅ **No imports from features/** in `interfaces/`
- Uses callback registry pattern (`shared.callbackRegistry`) for decoupled notifications
- Interface triggers callbacks without knowing which features are listening
- Feature registers callback in `featuresLifecycle.py` startup
**Refactoring:**
- Created `shared/callbackRegistry.py` - decoupled event notification system
- Interface calls `callbackRegistry.trigger('automation.changed', self)` instead of importing feature
- Feature registers callback on startup: `callbackRegistry.register('automation.changed', onAutomationChanged)`
**Impact:** Perfect separation - interface doesn't know about features, uses shared callback registry.
---
### 3. services/ → interfaces/ ✅ CORRECT DIRECTION
**Current State:**
- `serviceAi/mainServiceAi.py`: Imports `AiObjects` from `interfaceAiObjects`
- `serviceExtraction/mainServiceExtraction.py`: Lazy import from `interfaceDbComponentObjects`
- `serviceUtils/mainServiceUtils.py`: Lazy import from `interfaceDbChatObjects`
- `serviceTicket/mainServiceTicket.py`: Imports from `interfaceTicketObjects`
- `services/__init__.py`: Lazy imports from multiple interfaces
**Impact:** This is **correct** - services should use interfaces for data access. This follows the dependency rule: `services/``interfaces/`
**Note:** This is **unidirectional** (services → interfaces), not bidirectional.
---
### 4. services/ → features/ ✅ NONE
**Current State:**
- ✅ **No imports from features/** in `services/`
**Impact:** Services correctly do not depend on features.
---
### 5. features/ → interfaces/ ✅ CORRECT DIRECTION
**Current State:**
- `features/automation/mainAutomation.py`: Imports from `interfaceDbChatObjects`, `interfaceDbAppObjects`
- `features/featuresLifecycle.py`: Imports from `interfaceDbAppObjects`, `interfaceDbChatObjects` (lazy)
**Impact:** This is **correct** - features should use interfaces for data access. This follows the dependency rule: `features/``interfaces/`
**Note:** This is **unidirectional** (features → interfaces), not bidirectional.
---
### 6. features/ → services/ ✅ CORRECT DIRECTION
**Current State:**
- `features/neutralizePlayground/mainNeutralizePlayground.py` (line 123): Lazy import from `serviceSharepoint`
**Impact:** This is **correct** - features can use services. This follows the dependency rule: `features/``services/`
---
## Complete Import Matrix (Fact-Based)
### aicore/
- **Imports from:** `datamodels/`, `shared/`
- **Imported by:** `interfaces/`, `services/`, `workflows/`
- **Bidirectional:** None ✅
### connectors/
- **Imports from:** `datamodels/`, `shared/`
- **Imported by:** `interfaces/`
- **Bidirectional:** None ✅
### datamodels/
- **Imports from:** `shared/`
- **Imported by:** `aicore/`, `connectors/`, `features/`, `interfaces/`, `routes/`, `security/`, `services/`, `workflows/`
- **Bidirectional:** None ✅
### features/
- **Imports from:** `datamodels/`, `interfaces/`, `services/`, `shared/`, `workflows/`
- **Imported by:** `routes/`
- **✅ UNIDIRECTIONAL:** No longer imported by `interfaces/`
**Detailed imports:**
- From `interfaces/`: `interfaceDbChatObjects`, `interfaceDbAppObjects`
- From `services/`: `serviceSharepoint` (lazy, in `neutralizePlayground`)
- From `features/`: `chatPlayground` (in `automation`), `syncDelta`, `chatAlthaus` (in `featuresLifecycle`)
### interfaces/
- **Imports from:** `aicore/`, `connectors/`, `datamodels/`, `shared/`
- **Imported by:** `features/`, `routes/`, `security/`, `services/`
- **✅ RESOLVED:** No longer imports from `services/` or `features/`
**Detailed imports:**
- From `shared/`: `callbackRegistry` (for decoupled event notifications), `eventManagement` (for event removal in delete), `timeUtils`, `configuration`, `debugLogger`
- From `interfaces/`: Internal imports (`interfaceDbChatAccess`, `interfaceDbAppAccess`, `interfaceDbComponentAccess`)
### routes/
- **Imports from:** `datamodels/`, `features/`, `interfaces/`, `security/`, `services/`, `shared/`
- **Imported by:** None (top-level API layer)
- **Bidirectional:** None ✅
**Detailed imports:**
- From `interfaces/`: `interfaceDbChatObjects`, `interfaceDbAppObjects`, `interfaceDbComponentObjects`, `interfaceVoiceObjects`
- From `features/**: `features.automation`, `features.chatPlayground`, `features.neutralizePlayground`
### security/
- **Imports from:** `datamodels/`, `interfaces/`, `shared/`
- **Imported by:** `routes/`, `services/`
- **Bidirectional:** None ✅
**Detailed imports:**
- From `interfaces/**: `interfaceDbAppObjects` (lazy imports)
### services/
- **Imports from:** `aicore/`, `datamodels/`, `interfaces/`, `security/`, `shared/`
- **Imported by:** `features/`, `routes/`, `workflows/`
- **✅ UNIDIRECTIONAL:** Only imports from `interfaces/` (correct direction)
- **✅ RESOLVED:** No longer imported by `interfaces/`
**Detailed imports:**
- From `interfaces/**: `interfaceAiObjects`, `interfaceDbComponentObjects` (lazy), `interfaceDbChatObjects` (lazy), `interfaceTicketObjects`
- From `services/**: Internal imports (service-to-service)
### shared/
- **Imports from:** None (foundation layer)
- **Imported by:** `aicore/`, `connectors/`, `datamodels/`, `features/`, `interfaces/`, `routes/`, `security/`, `services/`, `workflows/`
- **Bidirectional:** None ✅
### workflows/
- **Imports from:** `aicore/`, `datamodels/`, `services/`, `shared/`
- **Imported by:** `features/`
- **Bidirectional:** None ✅
**Detailed imports:**
- From `services/**: `serviceGeneration` (lazy, in `methodAi.py`)
---
## Refactoring Impact Summary
### Before Refactoring:
- ❌ **interfaces/ ↔ services/**: Bidirectional (violations)
- `interfaces/` imported from `services/serviceExtraction/` (6 violations)
- `services/` imported from `interfaces/` (correct)
- ❌ **interfaces/ ↔ features/**: Bidirectional (violation)
- `interfaces/` imported from `features.chatPlayground` (1 violation)
- `features/` imported from `interfaces/` (correct)
### After Refactoring:
- ✅ **interfaces/ → services/**: RESOLVED (no imports)
- ✅ **services/ → interfaces/**: UNIDIRECTIONAL (correct direction)
- ✅ **interfaces/ → features/**: RESOLVED (uses callback registry pattern)
- ✅ **features/ → interfaces/**: UNIDIRECTIONAL (correct direction)
- ✅ **features/ → services/**: CORRECT DIRECTION (1 lazy import)
---
## Specific Import Details
### interfaces/ → features/ ✅ RESOLVED
**Previous:** `interfaceDbChatObjects.py` (line 1754) had lazy import from `features.automation`
```python
from modules.features.automation import syncAutomationEvents
```
**Current:** Uses `shared.callbackRegistry` pattern (line 1754):
- Interface calls: `callbackRegistry.trigger('automation.changed', self)`
- Feature registers callback in `featuresLifecycle.py`: `callbackRegistry.register('automation.changed', onAutomationChanged)`
- **Zero direct imports** from features in interfaces
- **Verification:** `grep "from modules.features" interfaces/` returns no matches ✅
### features/ → services/ (1 import, correct direction)
**File:** `features/neutralizePlayground/mainNeutralizePlayground.py`
- **Line:** 123
- **Import:** `from modules.services.serviceSharepoint.mainServiceSharepoint import SharepointService`
- **Type:** Lazy import (inside method)
- **Context:** Used for SharePoint file processing
- **Status:** ✅ CORRECT - Features can import from services
---
## Recommendations
### ✅ Completed Improvements
1. **Resolved interfaces/ → services/ violations** - Moved extraction functions to `serviceExtraction/`
2. **Resolved interfaces/ → features/ violations** - Moved automation handler to `features/automation/`
3. **Eliminated ALL bidirectional dependencies** - `interfaces/` no longer imports from `services/` or `features/`
4. **Implemented callback registry pattern** - Decoupled event notifications using `shared.callbackRegistry`
5. **Follows dependency rules perfectly** - All dependencies now follow correct direction:
- `services/``interfaces/`
- `features/``interfaces/`
- `features/``services/`
- `interfaces/``shared/` only ✅
### Best Practices
- ✅ Use lazy imports (inside functions) for dependencies when appropriate
- ✅ Services correctly depend on interfaces (unidirectional)
- ✅ Features correctly depend on interfaces and services (unidirectional)
- ✅ Interfaces completely independent (only foundation layers)
- ✅ Use callback registry for decoupled event notifications
- ✅ Document dependency relationships clearly
- ✅ Monitor for circular import errors
---
## Dependency Rule Compliance
### Current Rules:
- ✅ **features/****services/** ✅ (correct)
- ✅ **services/****interfaces/** ✅ (correct)
- ✅ **features/****interfaces/** ✅ (correct)
### Status:
- ✅ **interfaces/****services/**: **RESOLVED** (was violation, now compliant)
- ✅ **services/****interfaces/**: **COMPLIANT** (correct direction)
- ✅ **features/****interfaces/**: **COMPLIANT** (correct direction)
- ✅ **features/****services/**: **COMPLIANT** (correct direction)
- ✅ **interfaces/****features/**: **RESOLVED** (was violation, now uses callback registry)
---
## Conclusion
The refactoring successfully resolved **ALL bidirectional dependencies**:
- ✅ **interfaces/ ↔ services/**: RESOLVED
- ✅ **interfaces/ ↔ features/**: RESOLVED (using callback registry pattern)
The architecture now follows the intended dependency rules **perfectly**:
- `interfaces/` only imports from foundation layers (`aicore/`, `connectors/`, `datamodels/`, `shared/`)
- `services/` imports from `interfaces/` (correct direction)
- `features/` imports from `interfaces/` and `services/` (correct direction)
- **Zero violations** - perfect architectural compliance achieved through callback registry pattern
---
## Architecture Layers
The codebase follows a clean layered architecture:
1. **Foundation Layer** (`shared/`, `datamodels/`)
- No dependencies on other modules
- Used by all layers
2. **Infrastructure Layer** (`aicore/`, `connectors/`)
- Depends only on foundation
- Provides core capabilities
3. **Data Access Layer** (`interfaces/`)
- Depends only on foundation and infrastructure
- Provides data access abstraction
4. **Business Logic Layer** (`services/`, `workflows/`)
- Depends on interfaces and foundation
- Implements business logic
5. **Feature Layer** (`features/`)
- Depends on interfaces, services, and foundation
- Implements user-facing features
6. **API Layer** (`routes/`, `security/`)
- Depends on all layers
- Provides HTTP API endpoints

View file

@ -0,0 +1 @@
<mxfile host="Electron" modified="2025-12-02T16:28:53.356Z" agent="5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) draw.io/20.3.0 Chrome/104.0.5112.114 Electron/20.1.3 Safari/537.36" etag="L77fz7tGmuPPsr8RbEBA" version="20.3.0" type="device"><diagram name="Module Dependencies" id="dependency-diagram">5Z1bb6M4FMc/DdLOQyUuIZfHNm1nK3Wno8lepH1zwSSoBFeGNO1++jWNSXzLNKW2MR1ppCbHBJLz//n4cIAzXjRfP3/F4HH1B0ph4YV++uxFl14YBmEYes0/P31pLcFkZ1niPKW2g2GR/wep0afWTZ7CituwRqio80femKCyhEnN2QDGaMtvlqGCP+ojWELJsEhAIVv/ydN6Ra0j3z8M/A7z5ao9dNiOrEG7NTVUK5CiLWOKrrxojhGqd6/Wz3NYNO5rHbP73PWR0f03w7CsT/kAOT6G6e5TT6DYQN587YVjL4yI46OL367RpkxBnaPyC/3y9UvrEtyMwWanAdl0u8pruHgESTO6JRQQ26peF3Q4y4tijgqEXz8bwSCLs4zYqxqjB8iM+EE8md3vR1pnh80+UFkv6PEVv5qaniCu4TNjol74CtEa1viFbEJHwxlVhEIZtQptGYVb24oRd0xtgEK13O/64HbygnperQLxKlg386SSlGCHfh014phXY2xRDJAnCENJiNbMiXBTZhgQd2ySeoPhJxRiH7naWO1bVILGb4TlacEO/VqKTPtUJC9riDPiLlkRdohT5JJEMLLpeUKGqi/MiHcVerO5N43J6DfU7C1HxWtEq7TKlkUwzmKVbCMQjKaJJFtkYiJNeNlmI1m2/WRjZZtqkK2C+ClXiXYY4CS72FR5SdQiG9+iZZ4cU+2vMk9zTOYh0QwUeufaNIvhTCVacB/D0Lcy18a8ZiNFTmBMsy3CD1nR5KqiaMzIW6p9NkEmQvAbKYKfMUUyCJo1RRbkMMBnaNTcw+zJMhL0fJVYcBw3K4YNsWbC9JnZFIt4r1ZI1Zo5oc6/3zRzBrxArHfOZAkcwUQlw3Tqw1FmRYZImDMTmzJUMNngvH5RrDztACfFgpo/oQ5i7LKqA0yXMJBEkJwMy/S8KZCQd0kBqipPeL8eRFCHkGOJ8THvVWiDE8WZ7mtJB+AlpJuzZYrmp/zU34w/Y4U7WxuGTbL5xNd0VD6mR/iOSIp7kHMkyLmfZu0udr+NfootwYg7Es5191+w3dHOEdKOXiXf/+yTKQjdpoA9+2YJEOlwhAIxuHamILJLQTRMChyMA0MlYOQ2AWLtZwCxQKzMDGVFiIdLgoPxQBsFY7sUjHuggNbfTqFArDyyFLCrhSMUxMcKFR9dFSSc9FIwGS4FYpxwhISxLhImdkmYDpcER/MD8aJmZxLE/MAwCTOJhAQUxT1IHn7AZV61lQgbZEQXKahWtAr0UUwcTB60BQsxeTCMSHtnDhstDpVujXBEP7kAcQoP/JUwlgaRFEeIEMtMcVcipOhjNp0M+qg2agHBwWxSTAI7QxBaDgvhUCFwNIXQFg0spxBBH/VGLSA4mCSI2nWGwHaS0EfJUQ8E3FVMVzDQFQvEitXYcGbQR73xHRgIN944nhqIVYHBpAZ91Bv1UOBobiBO48HkBn3UHPWQwK8YjnCgLSKIHJg+Y+yj4qiJA/eSRG3RwHaSKFcbLVaS6N2Rp+DA3w06gEqS/ExNVyLEIqXhG5Z6rS1qIcLJdUIbD2+ej2rmoY/KohYMHE0cjYFgeKlodR8eCEIq4QgHYlzvzMGbGahmDvooLupZF9zLG7VBYDlvDPsoLtKnB06BgH0eZQDJolhcnHbFwPJlx7CP4qIGDPgY8dkhkIKKZgj6qC1qgMDJ0wRtENg+TeijrKgFAgevNYm3s3aGQCxLTQxD0EdNUQMEjp4pGosFppNDRVFxCBg4eH4gKtcZAcvnB5GiiugSAnzYH8AZghjJu4Ngt5wc9VE+1AKCo4uCNhAsLwpROFQQHFwW9EUDexAUcEn0lSC4ZcwMCjV8rnnxeXFLVJItuc4T1ASKfFk2BMEmkhND0zMiT0BxTgfWeZo2h1F2teAR45tS0Pe7Bpnt7XUfalIR+Ke0F1N1qYj848yc2qVip4gcoA+9Dj3ap0WSx3JfNxOeVnQEC1RTVp+n5QjINWsz4ux3d2Mz4+ypdWfLVynEhl1m4H5/3y4D/t43vLXnb/mCAG3IZQbr93fcMuFmVaNUs26WC+5MOy29Ln5/FycDLo5UC6JZFyvK2UwbuTnCTRM58uqy7SZnIHkpYFZ/JHUxoUR8ohKhNiXkmvLZ2VmjAX0glrw8+kxs/xqc9PTsRwQ5dVHVIUhVk2AuN/hjZ8a/Vz/uyJ+/b+5uz/+8ufu2YDrNfYc4282ac5w0fmv6BTddGMl8Wj8WOSgTqDV6JVM4TpTrcAgnaRRK0UvuQsun+0ai20gxp5RN6Tp0EyZvD43td+drh/8gILr6Hw==</diagram></mxfile>

View file

@ -0,0 +1,199 @@
# Features → Interfaces Import Analysis
## Summary
This document details all imports from `features/` modules to `interfaces/` modules.
**Total Feature Modules:** 6
**Modules Importing from Interfaces:** 2 (only `getRootInterface` - system-level function)
**Total Interface Imports:** 2 (both for `getRootInterface` only)
**✅ REFACTORED:** All feature modules now use `services.getInterface()` to access interfaces, following the same pattern as `chatPlayground`. Only `getRootInterface()` remains as a direct import (system-level function, not user-specific).
---
## Detailed Import List
### 1. `features/featuresLifecycle.py`
**Imports:**
- **Line 2:** `from modules.interfaces.interfaceDbAppObjects import getRootInterface`
- **Type:** Direct import
- **Usage:** Gets root interface to retrieve event user for feature initialization
- **Context:** Used in `start()` function to get event user for automation, syncDelta, and chatAlthaus features
- **Note:** `getRootInterface()` is a system-level function (no user required), so it remains as direct import
**Refactored:**
- ✅ **Removed:** `from modules.interfaces.interfaceDbChatObjects import getInterface as getChatInterface`
- ✅ **Added:** `from modules.services import getInterface as getServices`
- ✅ **Changed:** Now uses `services.interfaceDbChat` instead of direct interface import
- **Pattern:** `services = getServices(eventUser, None)` then `services.interfaceDbChat`
**Purpose:** Feature lifecycle management - initializes and manages all features on startup/shutdown.
---
### 2. `features/automation/mainAutomation.py`
**Imports:**
- **Line 14:** `from modules.interfaces.interfaceDbAppObjects import getRootInterface`
- **Type:** Direct import
- **Usage:** Gets root interface to retrieve event user (system-level function)
- **Context:** Used in `createAutomationEventHandler()` to get event user
- **Note:** `getRootInterface()` is a system-level function (no user required), so it remains as direct import
**Refactored:**
- ✅ **Removed:** `from modules.interfaces.interfaceDbChatObjects import getInterface as getChatInterface`
- ✅ **Removed:** `from modules.interfaces.interfaceDbAppObjects import getInterface as getAppInterface`
- ✅ **Added:** `from modules.services import getInterface as getServices`
- ✅ **Changed:** All interface access now goes through services:
- `executeAutomation()`: Uses `services.interfaceDbApp` and `services.interfaceDbChat`
- `createAutomationEventHandler()`: Uses `eventServices.interfaceDbChat` and `eventServices.interfaceDbApp`
- **Pattern:** `services = getServices(user, None)` then `services.interfaceDbChat` or `services.interfaceDbApp`
**Purpose:** Automation workflow execution and scheduling - handles automated workflow triggers and event scheduling.
---
### 3. `features/chatPlayground/mainChatPlayground.py`
**Imports:**
- ❌ **No imports from interfaces/**
- Uses `modules.services.getInterface` instead (indirect access through services layer)
**Purpose:** Chat playground feature - interactive chat interface.
---
### 4. `features/chatAlthaus/mainChatAlthaus.py`
**Imports:**
- ❌ **No imports from interfaces/**
- Uses `modules.services.getInterface` instead (indirect access through services layer)
**Purpose:** Chat Althaus data scheduler - scheduled data updates for Althaus preprocessing.
---
### 5. `features/syncDelta/mainSyncDelta.py`
**Imports:**
- ❌ **No imports from interfaces/**
- Uses `modules.services.getInterface` instead (indirect access through services layer)
**Purpose:** Delta Group sync manager - synchronizes tickets to SharePoint.
---
### 6. `features/neutralizePlayground/mainNeutralizePlayground.py`
**Imports:**
- ❌ **No imports from interfaces/**
- Uses `modules.services.getInterface` instead (indirect access through services layer)
**Purpose:** Neutralization playground - UI wrapper for data neutralization service.
---
## Import Statistics
### By Interface Module
**`interfaceDbAppObjects`:**
- `getRootInterface`: 2 imports (system-level function, remains as direct import)
- `features/featuresLifecycle.py` (line 2)
- `features/automation/mainAutomation.py` (line 14)
**`interfaceDbChatObjects`:**
- ✅ **Removed:** All direct imports refactored to use services layer
- Now accessed via `services.interfaceDbChat` after calling `getServices(user, None)`
**`interfaceDbAppObjects.getInterface`:**
- ✅ **Removed:** All direct imports refactored to use services layer
- Now accessed via `services.interfaceDbApp` after calling `getServices(user, None)`
### By Import Type
- **Direct imports:** 2 (only `getRootInterface` - system-level function)
- `features/featuresLifecycle.py`: 1
- `features/automation/mainAutomation.py`: 1
- **Services-based access:** All user-specific interface access now goes through services layer
- Pattern: `services = getServices(user, None)` then `services.interfaceDbChat` or `services.interfaceDbApp`
---
## Architectural Notes
### ✅ Correct Dependency Direction
All imports follow the correct architectural direction:
- **features/****interfaces/**
This is compliant with the dependency rules:
- Features can import from interfaces (correct)
- Features can import from services (correct)
- Features do NOT import from other features (except internal feature-to-feature imports)
### Import Patterns
1. **Direct Interface Access:**
- `features/automation/mainAutomation.py` - Directly imports interfaces for automation management
- `features/featuresLifecycle.py` - Directly imports `getRootInterface` for feature initialization
2. **Indirect Access via Services:**
- `features/chatPlayground/mainChatPlayground.py`
- `features/chatAlthaus/mainChatAlthaus.py`
- `features/syncDelta/mainSyncDelta.py`
- `features/neutralizePlayground/mainNeutralizePlayground.py`
These features use `modules.services.getInterface` which provides a service layer abstraction.
### Usage Context
**`interfaceDbAppObjects`:**
- Used for user management (`getRootInterface`, `getAppInterface`)
- Primarily for getting event user and creator user in automation context
**`interfaceDbChatObjects`:**
- Used for chat/automation data access (`getChatInterface`)
- Used for automation execution, event syncing, and workflow management
---
## Summary Table
| Feature Module | Interface Imports | Import Type | Purpose |
|---------------|-------------------|-------------|---------|
| `featuresLifecycle.py` | `interfaceDbAppObjects.getRootInterface` | Direct | Feature initialization (system-level) |
| `featuresLifecycle.py` | ✅ Via `services.interfaceDbChat` | Services | Automation event sync |
| `automation/mainAutomation.py` | `interfaceDbAppObjects.getRootInterface` | Direct | Event user access (system-level) |
| `automation/mainAutomation.py` | ✅ Via `services.interfaceDbChat` | Services | Automation execution |
| `automation/mainAutomation.py` | ✅ Via `services.interfaceDbApp` | Services | User management |
| `chatPlayground/mainChatPlayground.py` | None | - | Uses services layer |
| `chatAlthaus/mainChatAlthaus.py` | None | - | Uses services layer |
| `syncDelta/mainSyncDelta.py` | None | - | Uses services layer |
| `neutralizePlayground/mainNeutralizePlayground.py` | None | - | Uses services layer |
---
## Conclusion
**Total Interface Imports:** 2 (only `getRootInterface` - system-level function)
- 2 direct imports (both for `getRootInterface`)
**Modules Using Direct Interface Imports:** 2 out of 6 (only for `getRootInterface`)
- `features/featuresLifecycle.py`
- `features/automation/mainAutomation.py`
**✅ REFACTORED:** All user-specific interface access now goes through services layer
- Pattern: `services = getServices(user, None)` then `services.interfaceDbChat` or `services.interfaceDbApp`
- Consistent with other feature modules (`chatPlayground`, `chatAlthaus`, `syncDelta`, `neutralizePlayground`)
**Architectural Compliance:** ✅ **PERFECT**
- All imports follow correct direction (features → interfaces)
- Only system-level function (`getRootInterface`) remains as direct import
- All user-specific interface access goes through services layer
- Clean separation maintained
- Consistent pattern across all feature modules

View file

@ -0,0 +1,12 @@
"""
Automation feature - handles automated workflow execution and scheduling.
Moved from interfaces/interfaceDbChatObjects.py to follow proper architectural separation:
- Interface layer: Data access only (getAutomationDefinition, etc.)
- Feature layer: Business logic and orchestration (executeAutomation, syncAutomationEvents)
"""
from .mainAutomation import executeAutomation, syncAutomationEvents, createAutomationEventHandler
__all__ = ['executeAutomation', 'syncAutomationEvents', 'createAutomationEventHandler']

View file

@ -0,0 +1,287 @@
"""
Main automation service - handles automation workflow execution and scheduling.
Moved from interfaces/interfaceDbChatObjects.py to follow proper architectural separation.
"""
import logging
import json
from typing import Dict, Any
from modules.datamodels.datamodelChat import ChatWorkflow, UserInputRequest, WorkflowModeEnum, AutomationDefinition
from modules.shared.timeUtils import getUtcTimestamp
from modules.shared.eventManagement import eventManager
from modules.services import getInterface as getServices
from modules.features.chatPlayground.mainChatPlayground import chatStart
from .subAutomationUtils import parseScheduleToCron, planToPrompt, replacePlaceholders
logger = logging.getLogger(__name__)
async def executeAutomation(automationId: str, chatInterface) -> ChatWorkflow:
"""Execute automation workflow immediately (test mode) with placeholder replacement.
Args:
automationId: ID of automation to execute
chatInterface: ChatObjects interface instance for data access
Returns:
ChatWorkflow instance created by automation execution
"""
executionStartTime = getUtcTimestamp()
executionLog = {
"timestamp": executionStartTime,
"workflowId": None,
"status": "running",
"messages": []
}
try:
# 1. Load automation definition
automation = chatInterface.getAutomationDefinition(automationId)
if not automation:
raise ValueError(f"Automation {automationId} not found")
executionLog["messages"].append(f"Started execution at {executionStartTime}")
# 2. Replace placeholders in template to generate plan
template = automation.get("template", "")
placeholders = automation.get("placeholders", {})
planJson = replacePlaceholders(template, placeholders)
try:
plan = json.loads(planJson)
except json.JSONDecodeError as e:
logger.error(f"Failed to parse plan JSON after placeholder replacement: {str(e)}")
logger.error(f"Template: {template[:500]}...")
logger.error(f"Placeholders: {placeholders}")
logger.error(f"Generated planJson (first 1000 chars): {planJson[:1000]}")
logger.error(f"Error position: line {e.lineno}, column {e.colno}, char {e.pos}")
if e.pos:
start = max(0, e.pos - 100)
end = min(len(planJson), e.pos + 100)
logger.error(f"Context around error: ...{planJson[start:end]}...")
raise ValueError(f"Invalid JSON after placeholder replacement: {str(e)}")
executionLog["messages"].append("Template placeholders replaced successfully")
# 3. Get user who created automation
creatorUserId = automation.get("_createdBy")
# CRITICAL: Automation MUST run as creator user only, or fail
if not creatorUserId:
errorMsg = f"Automation {automationId} has no creator user (_createdBy field missing). Cannot execute automation."
logger.error(errorMsg)
executionLog["messages"].append(errorMsg)
raise ValueError(errorMsg)
# Get user from database using services
services = getServices(chatInterface.currentUser, None)
creatorUser = services.interfaceDbApp.getUser(creatorUserId)
if not creatorUser:
raise ValueError(f"Creator user {creatorUserId} not found")
executionLog["messages"].append(f"Using creator user: {creatorUserId}")
# 4. Create UserInputRequest from plan
# Embed plan JSON in prompt for TemplateMode to extract
promptText = planToPrompt(plan)
planJsonStr = json.dumps(plan)
# Embed plan as JSON comment so TemplateMode can extract it
promptWithPlan = f"{promptText}\n\n<!--TEMPLATE_PLAN_START-->\n{planJsonStr}\n<!--TEMPLATE_PLAN_END-->"
userInput = UserInputRequest(
prompt=promptWithPlan,
listFileId=[],
userLanguage=creatorUser.language or "en"
)
executionLog["messages"].append("Starting workflow execution")
# 5. Start workflow using chatStart
workflow = await chatStart(
currentUser=creatorUser,
userInput=userInput,
workflowMode=WorkflowModeEnum.WORKFLOW_AUTOMATION,
workflowId=None
)
executionLog["workflowId"] = workflow.id
executionLog["status"] = "completed"
executionLog["messages"].append(f"Workflow {workflow.id} started successfully")
logger.info(f"Started workflow {workflow.id} with plan containing {len(plan.get('tasks', []))} tasks (plan embedded in userInput)")
# Set workflow name with "automated" prefix
automationLabel = automation.get("label", "Unknown Automation")
workflowName = f"automated: {automationLabel}"
workflow = chatInterface.updateWorkflow(workflow.id, {"name": workflowName})
logger.info(f"Set workflow {workflow.id} name to: {workflowName}")
# Update automation with execution log
executionLogs = automation.get("executionLogs", [])
executionLogs.append(executionLog)
# Keep only last 50 executions
if len(executionLogs) > 50:
executionLogs = executionLogs[-50:]
chatInterface.db.recordModify(
AutomationDefinition,
automationId,
{"executionLogs": executionLogs}
)
return workflow
except Exception as e:
# Log error to execution log
executionLog["status"] = "error"
executionLog["messages"].append(f"Error: {str(e)}")
# Update automation with execution log even on error
try:
automation = chatInterface.getAutomationDefinition(automationId)
if automation:
executionLogs = automation.get("executionLogs", [])
executionLogs.append(executionLog)
if len(executionLogs) > 50:
executionLogs = executionLogs[-50:]
chatInterface.db.recordModify(
AutomationDefinition,
automationId,
{"executionLogs": executionLogs}
)
except Exception as logError:
logger.error(f"Error saving execution log: {str(logError)}")
raise
async def syncAutomationEvents(chatInterface, eventUser) -> Dict[str, Any]:
"""Automation event handler - syncs scheduler with all active automations.
Args:
chatInterface: ChatObjects interface instance for data access
eventUser: System-level event user for accessing automations
Returns:
Dictionary with sync results (synced count and event IDs)
"""
# Get all automation definitions (for current mandate)
allAutomations = chatInterface.db.getRecordset(AutomationDefinition)
filtered = chatInterface._uam(AutomationDefinition, allAutomations)
registeredEvents = {}
for automation in filtered:
automationId = automation.get("id")
isActive = automation.get("active", False)
currentEventId = automation.get("eventId")
schedule = automation.get("schedule")
if not schedule:
logger.warning(f"Automation {automationId} has no schedule, skipping")
continue
try:
# Parse schedule to cron kwargs
cronKwargs = parseScheduleToCron(schedule)
if isActive:
# Remove existing event if present (handles schedule changes)
if currentEventId:
try:
eventManager.remove(currentEventId)
except Exception as e:
logger.warning(f"Error removing old event {currentEventId}: {str(e)}")
# Register new event
newEventId = f"automation.{automationId}"
# Create event handler function
handler = createAutomationEventHandler(automationId, eventUser)
# Register cron job
eventManager.registerCron(
jobId=newEventId,
func=handler,
cronKwargs=cronKwargs,
replaceExisting=True
)
# Update automation with new eventId
if currentEventId != newEventId:
chatInterface.db.recordModify(
AutomationDefinition,
automationId,
{"eventId": newEventId}
)
registeredEvents[automationId] = newEventId
else:
# Remove event if exists
if currentEventId:
try:
eventManager.remove(currentEventId)
chatInterface.db.recordModify(
AutomationDefinition,
automationId,
{"eventId": None}
)
except Exception as e:
logger.warning(f"Error removing event {currentEventId}: {str(e)}")
except Exception as e:
logger.error(f"Error syncing automation {automationId}: {str(e)}")
return {
"synced": len(registeredEvents),
"events": registeredEvents
}
def createAutomationEventHandler(automationId: str, eventUser):
"""Create event handler function for a specific automation.
Args:
automationId: ID of automation to create handler for
eventUser: System-level event user for accessing automations (captured in closure)
Returns:
Async handler function for scheduled automation execution
"""
async def handler():
try:
if not eventUser:
logger.error("Event user not available for automation execution")
return
# Get services for event user (provides access to interfaces)
eventServices = getServices(eventUser, None)
# Load automation using event user context
automation = eventServices.interfaceDbChat.getAutomationDefinition(automationId)
if not automation or not automation.get("active"):
logger.warning(f"Automation {automationId} not found or not active, skipping execution")
return
# Get creator user
creatorUserId = automation.get("_createdBy")
if not creatorUserId:
logger.error(f"Automation {automationId} has no creator user")
return
# Get creator user from database using services
eventServices = getServices(eventUser, None)
creatorUser = eventServices.interfaceDbApp.getUser(creatorUserId)
if not creatorUser:
logger.error(f"Creator user {creatorUserId} not found for automation {automationId}")
return
# Get services for creator user (provides access to interfaces)
creatorServices = getServices(creatorUser, None)
# Execute automation with creator user's context
# executeAutomation is in same module, so we can call it directly
await executeAutomation(automationId, creatorServices.interfaceDbChat)
logger.info(f"Successfully executed automation {automationId} as user {creatorUserId}")
except Exception as e:
logger.error(f"Error executing automation {automationId}: {str(e)}")
return handler

View file

@ -0,0 +1,108 @@
"""
Utility functions for automation feature.
Moved from interfaces/interfaceDbChatObjects.py.
"""
import json
from typing import Dict, Any
def parseScheduleToCron(schedule: str) -> Dict[str, Any]:
"""Parse schedule string to cron kwargs for APScheduler"""
parts = schedule.split()
if len(parts) != 5:
raise ValueError(f"Invalid schedule format: {schedule}")
return {
"minute": parts[0],
"hour": parts[1],
"day": parts[2],
"month": parts[3],
"day_of_week": parts[4]
}
def planToPrompt(plan: Dict) -> str:
"""Convert plan structure to prompt string for workflow execution"""
return plan.get("userMessage", plan.get("overview", "Execute automation workflow"))
def replacePlaceholders(template: str, placeholders: Dict[str, str]) -> str:
"""Replace placeholders in template with actual values. Placeholder format: {{KEY:PLACEHOLDER_NAME}}"""
result = template
for placeholderName, value in placeholders.items():
pattern = f"{{{{KEY:{placeholderName}}}}}"
# Check if placeholder is in an array context like ["{{KEY:...}}"]
# If value is a JSON array/dict, we should replace the entire ["{{KEY:...}}"] with the array
arrayPattern = f'["{pattern}"]'
if arrayPattern in result:
# Check if value is a JSON array/dict
isArrayValue = False
arrayValue = None
if isinstance(value, (list, dict)):
isArrayValue = True
arrayValue = json.dumps(value)
elif isinstance(value, str):
try:
parsed = json.loads(value)
if isinstance(parsed, (list, dict)):
isArrayValue = True
arrayValue = value # Already valid JSON string
except (json.JSONDecodeError, ValueError):
pass
if isArrayValue:
# Replace ["{{KEY:...}}"] with the array value
result = result.replace(arrayPattern, arrayValue)
continue # Skip the regular replacement below
# Regular replacement - check if in quoted context
patternStart = result.find(pattern)
isQuoted = False
if patternStart > 0:
charBefore = result[patternStart - 1] if patternStart > 0 else None
patternEnd = patternStart + len(pattern)
charAfter = result[patternEnd] if patternEnd < len(result) else None
if charBefore == '"' and charAfter == '"':
isQuoted = True
# Handle different value types
if isinstance(value, (list, dict)):
# Python list/dict - convert to JSON
replacement = json.dumps(value)
elif isinstance(value, str):
# String value - check if it's a JSON string representing list/dict
try:
parsed = json.loads(value)
if isinstance(parsed, (list, dict)):
# It's a JSON string of a list/dict
if isQuoted:
# In quoted context, escape the JSON string
escaped = json.dumps(value)
replacement = escaped[1:-1] # Remove outer quotes
else:
# In unquoted context, use JSON directly
replacement = value
else:
# It's a JSON string of a primitive
if isQuoted:
escaped = json.dumps(value)
replacement = escaped[1:-1]
else:
replacement = value
except (json.JSONDecodeError, ValueError):
# Not valid JSON - treat as plain string
if isQuoted:
escaped = json.dumps(value)
replacement = escaped[1:-1]
else:
replacement = value
else:
# Numbers, booleans, None - convert to string
replacement = str(value)
result = result.replace(pattern, replacement)
return result

View file

@ -1,211 +0,0 @@
# Komponentendiagramm: Kunden-Chatbot Althaus
## Übersicht
Dieses Diagramm zeigt die High-Level-Architektur der Althaus Chatbot-Anwendung mit allen beteiligten Komponenten, Datenflüssen und Kommunikationswegen.
## Komponentendiagramm
```mermaid
graph TB
subgraph "PowerOn Chat UI"
ChatUI[Chat Interface]
end
subgraph "PowerOn Platform"
Gateway[Gateway Backend<br/>Event Scheduler & Data Query API]
GatewayDB[(PostgreSQL)]
AIServices[Dynamic AI, Tavily]
end
subgraph "Tenant althaus-ag.ch"
subgraph "PowerOn PreProcessing"
PreProcessing[Pre-Processing Service]
PreProcessingDB[(PostgreSQL<br/>Memory DB)]
end
subgraph "MSFT Services"
PowerBI[Power BI]
TenantServices[Azure DC, DNA Center]
end
end
%% Hauptkommunikation
ChatUI -->|"Data Queries<br/>User/Password Auth"| Gateway
Gateway -->|"SQL Queries<br/>X-PP-API-Key"| PreProcessing
Gateway -->|"Config Update<br/>Daily 01:00 UTC"| PreProcessing
%% Datenfluss
PowerBI -->|"Rohdaten"| PreProcessing
PreProcessing --> PreProcessingDB
PreProcessingDB -->|"Query Results"| Gateway
Gateway --> ChatUI
Gateway --> GatewayDB
%% Styling
classDef platform fill:#e1f5ff,stroke:#01579b,stroke-width:2px
classDef frontend fill:#f3e5f5,stroke:#4a148c,stroke-width:2px
classDef preprocessing fill:#fff3e0,stroke:#e65100,stroke-width:2px
classDef customer fill:#e8f5e9,stroke:#1b5e20,stroke-width:2px
classDef database fill:#fce4ec,stroke:#880e4f,stroke-width:2px
class Gateway,AIServices platform
class ChatUI frontend
class PreProcessing preprocessing
class PowerBI,TenantServices customer
class GatewayDB,PreProcessingDB database
```
## Komponentenbeschreibungen
### 1. Gateway Backend (gateway.poweron-center.net)
**Hauptkomponenten:**
- **FastAPI Application**: Zentrale Backend-Anwendung der PowerOn Platform
- **Event Scheduler (chatAlthaus)**:
- Täglicher Scheduler um 01:00 UTC
- Sendet Konfigurations-Updates an Pre-Processing Service
- Verwendet `X-PP-API-Key` Header für Authentifizierung
- **Configuration Management**:
- Verwaltung von Secrets und Environment-Variablen
- Verschlüsselung/Entschlüsselung von Secrets
- Unterstützt verschiedene Umgebungen (dev, int, prod)
- **Data Query API**:
- `POST /api/v1/dataquery/query` - SQL Query ausführen
- `GET /api/v1/dataquery/schema` - Datenbankschema abrufen
- `GET /api/v1/dataquery/schema/{table_name}` - Tabellenschema abrufen
- **PostgreSQL Database**: Zentrale Datenbank für Gateway-Daten
**Technologie:**
- Python/FastAPI
- PostgreSQL
- APScheduler für Event-Management
**Externe AI-Services:**
- **Dynamic AI**: LLM Service für AI-Anfragen
- **Tavily**: Web-Such-Service für Web-Recherchen
### 2. PowerOn Chat UI (althaus-chat.poweron-center.net)
**Hauptkomponenten:**
- **React Application**: Frontend-Interface für den Chatbot
- **Authentication**: User/Password-basierte Authentifizierung mit JWT-Token
**Kommunikation:**
- Nutzt 3 Data Query Endpunkte vom Gateway
- Authentifiziert sich mit User/Password beim Gateway
- Erhält Antworten über Gateway API
**Technologie:**
- React
- REST API Calls
### 3. Tenant althaus-ag.ch
#### 3.1 PowerOn PreProcessing
**Hauptkomponenten:**
- **FastAPI Application**: Pre-Processing Service im Azure-Tenant des Kunden
- **Pre-Processing API**:
- `POST /api/v1/dataprocessor/update-db-with-config` - Datenbank mit Konfiguration aktualisieren
- Authentifizierung: `X-PP-API-Key` Header
- **PostgreSQL Memory Database**:
- Speichert verarbeitete Daten
- Wird vom Chat für Queries genutzt
**Datenfluss:**
- Empfängt Rohdaten aus Power BI Semantikmodell
- Verarbeitet Daten nach konfigurierten Schritten (keep, fillna, to_numeric, dropna, etc.)
- Speichert verarbeitete Daten in Memory Database
- Beantwortet SQL-Queries vom Gateway
**Technologie:**
- Python/FastAPI
- PostgreSQL
- Azure App Service (im Kunden-Tenant althaus-ag.ch)
#### 3.2 MSFT Services
**Power BI Semantikmodell:**
- Datenquelle für Rohdaten
- Wird vom Pre-Processing Service gelesen
**Azure Domänen-Controller:**
- Authentifizierungs-Service
- Wird vom Gateway für Authentifizierung genutzt
**DNA Center:**
- Netzwerk-Management-Service
- Wird vom Gateway genutzt
## Datenfluss
### 1. Datenaktualisierung (Scheduled)
```
Power BI Semantikmodell (Tenant althaus-ag.ch)
→ PowerOn PreProcessing (verarbeitet Daten)
→ PostgreSQL Memory DB (speichert verarbeitete Daten)
Gateway Event Scheduler (01:00 UTC täglich)
→ POST /api/v1/dataprocessor/update-db-with-config
→ PowerOn PreProcessing (aktualisiert Konfiguration)
```
### 2. Chat-Interaktion (User Request)
```
PowerOn Chat UI
→ POST /api/v1/dataquery/query (mit User/Password Auth)
→ Gateway Data Query API
→ POST /api/v1/dataquery/query (mit X-PP-API-Key)
→ PowerOn PreProcessing
→ PostgreSQL Memory DB (führt Query aus)
→ PowerOn PreProcessing (gibt Ergebnisse zurück)
→ Gateway Data Query API
→ PowerOn Chat UI (zeigt Antwort)
```
### 3. AI-Integration
```
PowerOn Chat UI
→ Gateway (vermittelt AI-Anfragen)
→ Dynamic AI & Tavily (in PowerOn Platform)
→ Gateway (kombiniert Ergebnisse)
→ PowerOn Chat UI (zeigt Antwort)
```
## Authentifizierung
### Gateway → PowerOn PreProcessing
- **Header**: `X-PP-API-Key`
- **Wert**: Aus Gateway Config (`PREPROCESS_ALTHAUS_CHAT_SECRET`)
- **Verwendung**: Event Scheduler und Data Query API
### PowerOn Chat UI → Gateway
- **Methode**: User/Password
- **Token**: JWT Token (nach erfolgreicher Authentifizierung)
- **Verwendung**: Alle API-Calls vom Chat Frontend
### Weitere Authentifizierung
- Gateway nutzt Azure Domänen-Controller für zusätzliche Authentifizierung
- Verschiedene API-Endpunkte können unterschiedliche Authentifizierungsmechanismen haben
## Deployment
- **PowerOn Platform**: gateway.poweron-center.net
- **PowerOn Chat UI**: althaus-chat.poweron-center.net
- **PowerOn PreProcessing**: Azure App Service im Kunden-Tenant (althaus-ag.ch)
- URL: `poweron-althaus-preprocess-prod-e3fegaatc7faency.switzerlandnorth-01.azurewebsites.net`
- **Tenant althaus-ag.ch**: Enthält PowerOn PreProcessing und MSFT Services (Power BI, Azure DC, DNA Center) im Azure-Tenant von Althaus AG
## Konfiguration
### Gateway Config Keys
- `PREPROCESS_ALTHAUS_CHAT_SECRET`: API-Key für Pre-Processing Service
- `APP_ENV_TYPE`: Umgebung (dev, int, prod)
- Weitere Gateway-spezifische Konfigurationen
### Pre-Processing Config
- Konfiguration wird als JSON im Gateway Code definiert
- Wird täglich um 01:00 UTC an Pre-Processing Service gesendet
- Definiert Tabellen, Spalten, Verarbeitungsschritte

View file

@ -1,24 +1,37 @@
import logging
from modules.interfaces.interfaceDbAppObjects import getRootInterface
from modules.services import getInterface as getServices
logger = logging.getLogger(__name__)
async def start() -> None:
""" Start feature triggers and background managers """
# Provide Event User
rootInterface = getRootInterface()
eventUser = rootInterface.getUserByUsername("event")
async def start(eventUser) -> None:
""" Start feature triggers and background managers
Args:
eventUser: System-level event user for background operations (provided by app.py)
"""
# Feature Automation Events
if eventUser:
try:
from modules.interfaces.interfaceDbChatObjects import getInterface as getChatInterface
chatInterface = getChatInterface(eventUser)
await chatInterface.syncAutomationEvents()
from modules.features.automation import syncAutomationEvents
from modules.shared.callbackRegistry import callbackRegistry
# Get services for event user (provides access to interfaces)
services = getServices(eventUser, None)
# Register callback for automation changes
async def onAutomationChanged(chatInterface):
"""Callback triggered when automations are created/updated/deleted."""
await syncAutomationEvents(chatInterface, eventUser)
callbackRegistry.register('automation.changed', onAutomationChanged)
logger.info("Registered automation change callback")
# Initial sync on startup - use interface from services
await syncAutomationEvents(services.interfaceDbChat, eventUser)
logger.info("Automation events synced on startup")
except Exception as e:
logger.error(f"Error syncing automation events on startup: {str(e)}")
logger.error(f"Error setting up automation events on startup: {str(e)}")
# Don't fail startup if automation sync fails
# Feature SyncDelta
@ -36,8 +49,21 @@ async def start() -> None:
async def stop() -> None:
""" Stop feature triggers and background managers """
async def stop(eventUser) -> None:
""" Stop feature triggers and background managers
Args:
eventUser: System-level event user (provided by app.py)
"""
# Unregister automation callback
try:
from modules.shared.callbackRegistry import callbackRegistry
# Note: We'd need to store the callback reference to unregister it properly
# For now, callbacks will remain registered (acceptable for shutdown)
logger.info("Automation callbacks remain registered (will be cleaned up on process exit)")
except Exception as e:
logger.warning(f"Error during automation callback cleanup: {str(e)}")
# Feature ...

View file

@ -75,15 +75,7 @@ class AiObjects:
# AI for Extraction, Processing, Generation
async def call(self, request: AiCallRequest, progressCallback=None) -> AiCallResponse:
"""Call AI model for text generation with model-aware chunking."""
# Handle content parts (unified path)
if hasattr(request, 'contentParts') and request.contentParts:
return await self._callWithContentParts(request, progressCallback)
# Handle traditional text/context calls
return await self._callWithTextContext(request)
async def _callWithTextContext(self, request: AiCallRequest) -> AiCallResponse:
async def callWithTextContext(self, request: AiCallRequest) -> AiCallResponse:
"""Call AI model for traditional text/context calls with fallback mechanism."""
prompt = request.prompt
context = request.context or ""
@ -148,412 +140,6 @@ class AiObjects:
errorCount=1
)
async def _callWithContentParts(self, request: AiCallRequest, progressCallback=None) -> AiCallResponse:
"""Process content parts with model-aware chunking (unified for single and multiple parts)."""
prompt = request.prompt
options = request.options
contentParts = request.contentParts
# Get failover models
availableModels = modelRegistry.getAvailableModels()
failoverModelList = modelSelector.getFailoverModelList(prompt, "", options, availableModels)
if not failoverModelList:
return self._createErrorResponse("No suitable models found", 0, 0)
# Process each content part
allResults = []
for contentPart in contentParts:
partResult = await self._processContentPartWithFallback(contentPart, prompt, options, failoverModelList, progressCallback)
allResults.append(partResult)
# Merge all results
mergedContent = self._mergePartResults(allResults)
return AiCallResponse(
content=mergedContent,
modelName="multiple",
priceUsd=sum(r.priceUsd for r in allResults),
processingTime=sum(r.processingTime for r in allResults),
bytesSent=sum(r.bytesSent for r in allResults),
bytesReceived=sum(r.bytesReceived for r in allResults),
errorCount=sum(r.errorCount for r in allResults)
)
async def _processContentPartWithFallback(self, contentPart, prompt: str, options, failoverModelList, progressCallback=None) -> AiCallResponse:
"""Process a single content part with model-aware chunking and fallback."""
lastError = None
# Check if this is an image - Vision models need special handling
isImage = (contentPart.typeGroup == "image") or (contentPart.mimeType and contentPart.mimeType.startswith("image/"))
# Determine the correct operation type based on content type
# Images should use IMAGE_ANALYSE, not the generic operation type
actualOperationType = options.operationType
if isImage:
actualOperationType = OperationTypeEnum.IMAGE_ANALYSE
# Get vision-capable models for images
availableModels = modelRegistry.getAvailableModels()
visionFailoverList = modelSelector.getFailoverModelList(prompt, "", AiCallOptions(operationType=actualOperationType), availableModels)
if visionFailoverList:
logger.debug(f"Using {len(visionFailoverList)} vision-capable models for image processing")
failoverModelList = visionFailoverList
for attempt, model in enumerate(failoverModelList):
try:
logger.info(f"Processing content part with model: {model.name} (attempt {attempt + 1}/{len(failoverModelList)})")
# Special handling for images with Vision models
if isImage and hasattr(model, 'functionCall'):
# Call model's functionCall directly (for Vision models this is callAiImage)
from modules.datamodels.datamodelAi import AiModelCall, AiCallOptions as AiCallOpts
try:
# Validate and prepare image data
if not contentPart.data:
raise ValueError("Image content part has no data")
# Ensure mimeType is valid
mimeType = contentPart.mimeType or "image/jpeg"
if not mimeType.startswith("image/"):
raise ValueError(f"Invalid mimeType for image: {mimeType}")
# Prepare base64 data
if isinstance(contentPart.data, str):
# Already base64 encoded - validate it
try:
base64.b64decode(contentPart.data, validate=True)
base64Data = contentPart.data
except Exception as e:
raise ValueError(f"Invalid base64 data in contentPart: {str(e)}")
elif isinstance(contentPart.data, bytes):
# Binary data - encode to base64
base64Data = base64.b64encode(contentPart.data).decode('utf-8')
else:
raise ValueError(f"Unsupported data type for image: {type(contentPart.data)}")
# Create data URL
imageDataUrl = f"data:{mimeType};base64,{base64Data}"
modelCall = AiModelCall(
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": prompt or ""},
{
"type": "image_url",
"image_url": {
"url": imageDataUrl
}
}
]
}
],
model=model,
options=AiCallOpts(operationType=actualOperationType)
)
modelResponse = await model.functionCall(modelCall)
if not modelResponse.success:
raise ValueError(f"Model call failed: {modelResponse.error}")
logger.info(f"✅ Image content part processed successfully with model: {model.name}")
# Convert to AiCallResponse format
# Note: AiModelResponse doesn't have priceUsd, and processingTime can be None
# Calculate processing time if not provided (fallback to 0.0)
processingTime = getattr(modelResponse, 'processingTime', None)
if processingTime is None:
processingTime = 0.0
return AiCallResponse(
content=modelResponse.content,
modelName=model.name,
priceUsd=0.0, # Price will be calculated elsewhere if needed
processingTime=processingTime,
bytesSent=0, # Will be calculated elsewhere
bytesReceived=0, # Will be calculated elsewhere
errorCount=0
)
except Exception as e:
# Image processing failed with this model
lastError = e
logger.warning(f"❌ Image processing failed with model {model.name}: {str(e)}")
# If this is not the last model, try the next one
if attempt < len(failoverModelList) - 1:
logger.info(f"🔄 Trying next fallback model for image processing...")
continue
else:
# All models failed
logger.error(f"💥 All {len(failoverModelList)} models failed for image processing")
raise
# For non-image parts, check if part fits in model context
# Calculate available space accounting for prompt, system message, and output reservation
partSize = len(contentPart.data.encode('utf-8')) if contentPart.data else 0
# Use same calculation as _chunkContentPart to determine actual available space
modelContextTokens = model.contextLength
modelMaxOutputTokens = model.maxTokens
# Reserve tokens for prompt, system message, output, and message overhead
promptTokens = len(prompt.encode('utf-8')) / 4 if prompt else 0
systemMessageTokens = 10 # ~40 bytes = 10 tokens
outputTokens = modelMaxOutputTokens
messageOverheadTokens = 100
totalReservedTokens = promptTokens + systemMessageTokens + messageOverheadTokens + outputTokens
# Available tokens for content (with 80% safety margin)
availableContentTokens = int((modelContextTokens - totalReservedTokens) * 0.8)
if availableContentTokens < 100:
availableContentTokens = max(100, int(modelContextTokens * 0.1))
# Convert to bytes (1 token ≈ 4 bytes)
availableContentBytes = availableContentTokens * 4
logger.debug(f"Size check for {model.name}: partSize={partSize} bytes, availableContentBytes={availableContentBytes} bytes (contextLength={modelContextTokens} tokens, reserved={totalReservedTokens:.0f} tokens)")
if partSize <= availableContentBytes:
# Part fits - call AI directly
response = await self._callWithModel(model, prompt, contentPart.data, options)
logger.info(f"✅ Content part processed successfully with model: {model.name}")
return response
else:
# Part too large - chunk it (pass prompt to account for it in chunk size calculation)
chunks = await self._chunkContentPart(contentPart, model, options, prompt)
if not chunks:
raise ValueError(f"Failed to chunk content part for model {model.name}")
logger.info(f"Starting to process {len(chunks)} chunks with model {model.name}")
# Log progress if callback provided
if progressCallback:
progressCallback(0.0, f"Starting to process {len(chunks)} chunks")
# Process each chunk
chunkResults = []
for idx, chunk in enumerate(chunks):
chunkNum = idx + 1
chunkData = chunk.get('data', '')
chunkSize = len(chunkData.encode('utf-8')) if chunkData else 0
logger.info(f"Processing chunk {chunkNum}/{len(chunks)} with model {model.name}, chunk size: {chunkSize} bytes")
# Calculate and log progress
if progressCallback:
progress = chunkNum / len(chunks)
progressCallback(progress, f"Processing chunk {chunkNum}/{len(chunks)}")
try:
chunkResponse = await self._callWithModel(model, prompt, chunkData, options)
chunkResults.append(chunkResponse)
logger.info(f"✅ Chunk {chunkNum}/{len(chunks)} processed successfully")
# Log completion progress
if progressCallback:
progressCallback(chunkNum / len(chunks), f"Chunk {chunkNum}/{len(chunks)} processed")
except Exception as e:
logger.error(f"❌ Error processing chunk {chunkNum}/{len(chunks)}: {str(e)}")
raise
# Merge chunk results
mergedContent = self._mergeChunkResults(chunkResults)
totalPrice = sum(r.priceUsd for r in chunkResults)
totalTime = sum(r.processingTime for r in chunkResults)
totalBytesSent = sum(r.bytesSent for r in chunkResults)
totalBytesReceived = sum(r.bytesReceived for r in chunkResults)
totalErrors = sum(r.errorCount for r in chunkResults)
logger.info(f"✅ Content part chunked and processed with model: {model.name} ({len(chunks)} chunks)")
return AiCallResponse(
content=mergedContent,
modelName=model.name,
priceUsd=totalPrice,
processingTime=totalTime,
bytesSent=totalBytesSent,
bytesReceived=totalBytesReceived,
errorCount=totalErrors
)
except Exception as e:
lastError = e
error_msg = str(e) if str(e) else f"{type(e).__name__}"
error_detail = f"❌ Model {model.name} failed for content part: {error_msg}"
if hasattr(e, 'detail') and e.detail:
error_detail += f" | Detail: {e.detail}"
if hasattr(e, 'status_code'):
error_detail += f" | Status: {e.status_code}"
logger.warning(error_detail, exc_info=True)
if attempt < len(failoverModelList) - 1:
logger.info(f"🔄 Trying next failover model...")
continue
else:
logger.error(f"💥 All {len(failoverModelList)} models failed for content part")
break
# All models failed
return self._createErrorResponse(f"All models failed: {str(lastError)}", 0, 0)
async def _chunkContentPart(self, contentPart, model, options, prompt: str = "") -> List[Dict[str, Any]]:
"""Chunk a content part based on model capabilities, accounting for prompt, system message overhead, and maxTokens output."""
# Calculate model-specific chunk sizes
modelContextTokens = model.contextLength # Total context in tokens
modelMaxOutputTokens = model.maxTokens # Maximum output tokens
# Reserve tokens for:
# 1. Prompt (user message)
promptTokens = len(prompt.encode('utf-8')) / 4 if prompt else 0
# 2. System message wrapper ("Context from documents:\n")
systemMessageTokens = 10 # ~40 bytes = 10 tokens
# 3. Max output tokens (model will reserve space for completion)
outputTokens = modelMaxOutputTokens
# 4. JSON structure and message overhead (~100 tokens)
messageOverheadTokens = 100
# Total reserved tokens = input overhead + output reservation
totalReservedTokens = promptTokens + systemMessageTokens + messageOverheadTokens + outputTokens
# Available tokens for content = context length - reserved tokens
# Use 80% of available for safety margin
availableContentTokens = int((modelContextTokens - totalReservedTokens) * 0.8)
# Ensure we have at least some space
if availableContentTokens < 100:
logger.warning(f"Very limited space for content: {availableContentTokens} tokens available. Model: {model.name}, contextLength: {modelContextTokens}, maxTokens: {modelMaxOutputTokens}, prompt: {promptTokens:.0f} tokens")
availableContentTokens = max(100, int(modelContextTokens * 0.1)) # Fallback to 10% of context
# Convert tokens to bytes (1 token ≈ 4 bytes)
availableContentBytes = availableContentTokens * 4
logger.debug(f"Chunking calculation for {model.name}: contextLength={modelContextTokens} tokens, maxTokens={modelMaxOutputTokens} tokens, prompt={promptTokens:.0f} tokens, reserved={totalReservedTokens:.0f} tokens, available={availableContentTokens} tokens ({availableContentBytes} bytes)")
# Use 70% of available content bytes for text chunks (conservative)
textChunkSize = int(availableContentBytes * 0.7)
imageChunkSize = int(availableContentBytes * 0.8) # 80% for image chunks
# Build chunking options
chunkingOptions = {
"textChunkSize": textChunkSize,
"imageChunkSize": imageChunkSize,
"maxSize": availableContentBytes,
"chunkAllowed": True
}
# Get appropriate chunker
from modules.services.serviceExtraction.subRegistry import ChunkerRegistry
chunkerRegistry = ChunkerRegistry()
chunker = chunkerRegistry.resolve(contentPart.typeGroup)
if not chunker:
logger.warning(f"No chunker found for typeGroup: {contentPart.typeGroup}")
return []
# Chunk the content part
try:
chunks = chunker.chunk(contentPart, chunkingOptions)
logger.debug(f"Created {len(chunks)} chunks for {contentPart.typeGroup} part")
return chunks
except Exception as e:
logger.error(f"Chunking failed for {contentPart.typeGroup}: {str(e)}")
return []
def _mergePartResults(self, partResults: List[AiCallResponse]) -> str:
"""Merge part results using the existing sophisticated merging system."""
if not partResults:
return ""
# Convert AiCallResponse results to ContentParts for merging
from modules.datamodels.datamodelExtraction import ContentPart
from modules.services.serviceExtraction.subUtils import makeId
content_parts = []
for i, result in enumerate(partResults):
if result.content:
content_part = ContentPart(
id=str(uuid.uuid4()),
parentId=None,
label=f"ai_result_{i}",
typeGroup="text", # Default to text for AI results
mimeType="text/plain",
data=result.content,
metadata={
"aiResult": True,
"modelName": result.modelName,
"priceUsd": result.priceUsd,
"processingTime": result.processingTime,
"bytesSent": result.bytesSent,
"bytesReceived": result.bytesReceived
}
)
content_parts.append(content_part)
# Use existing merging system
merge_strategy = MergeStrategy(
useIntelligentMerging=True,
groupBy="typeGroup",
orderBy="id",
mergeType="concatenate"
)
merged_parts = applyMerging(content_parts, merge_strategy)
# Convert merged parts back to final string
final_content = "\n\n".join([part.data for part in merged_parts])
logger.info(f"Merged {len(partResults)} AI results using existing merging system")
return final_content.strip()
def _mergeChunkResults(self, chunkResults: List[AiCallResponse]) -> str:
"""Merge chunk results using the existing sophisticated merging system."""
if not chunkResults:
return ""
# Convert AiCallResponse results to ContentParts for merging
content_parts = []
for i, result in enumerate(chunkResults):
if result.content:
content_part = ContentPart(
id=str(uuid.uuid4()),
parentId=None,
label=f"chunk_result_{i}",
typeGroup="text", # Default to text for AI results
mimeType="text/plain",
data=result.content,
metadata={
"aiResult": True,
"chunk": True,
"modelName": result.modelName,
"priceUsd": result.priceUsd,
"processingTime": result.processingTime,
"bytesSent": result.bytesSent,
"bytesReceived": result.bytesReceived
}
)
content_parts.append(content_part)
# Use existing merging system
merge_strategy = MergeStrategy(
useIntelligentMerging=True,
groupBy="typeGroup",
orderBy="id",
mergeType="concatenate"
)
merged_parts = applyMerging(content_parts, merge_strategy)
# Convert merged parts back to final string
final_content = "\n\n".join([part.data for part in merged_parts])
logger.info(f"Merged {len(chunkResults)} chunk results using existing merging system")
return final_content.strip()
def _createErrorResponse(self, errorMsg: str, inputBytes: int, outputBytes: int) -> AiCallResponse:
"""Create an error response."""
return AiCallResponse(
@ -659,64 +245,4 @@ class AiObjects:
return [model.displayName for model in models]
def applyMerging(parts: List[ContentPart], strategy: MergeStrategy) -> List[ContentPart]:
"""Apply merging strategy to parts with intelligent token-aware merging."""
logger.debug(f"applyMerging called with {len(parts)} parts")
# Import merging dependencies
from modules.services.serviceExtraction.merging.mergerText import TextMerger
from modules.services.serviceExtraction.merging.mergerTable import TableMerger
from modules.services.serviceExtraction.merging.mergerDefault import DefaultMerger
from modules.services.serviceExtraction.subMerger import IntelligentTokenAwareMerger
# Check if intelligent merging is enabled
if strategy.useIntelligentMerging:
modelCapabilities = strategy.capabilities or {}
subMerger = IntelligentTokenAwareMerger(modelCapabilities)
# Use intelligent merging for all parts
merged = subMerger.mergeChunksIntelligently(parts, strategy.prompt or "")
# Calculate and log optimization stats
stats = subMerger.calculateOptimizationStats(parts, merged)
logger.info(f"🧠 Intelligent merging stats: {stats}")
logger.debug(f"Intelligent merging: {stats['original_ai_calls']}{stats['optimized_ai_calls']} calls ({stats['reduction_percent']}% reduction)")
return merged
# Fallback to traditional merging
textMerger = TextMerger()
tableMerger = TableMerger()
defaultMerger = DefaultMerger()
# Group by typeGroup
textParts = [p for p in parts if p.typeGroup == "text"]
tableParts = [p for p in parts if p.typeGroup == "table"]
structureParts = [p for p in parts if p.typeGroup == "structure"]
otherParts = [p for p in parts if p.typeGroup not in ("text", "table", "structure")]
logger.debug(f"Grouped - text: {len(textParts)}, table: {len(tableParts)}, structure: {len(structureParts)}, other: {len(otherParts)}")
merged: List[ContentPart] = []
if textParts:
textMerged = textMerger.merge(textParts, strategy)
logger.debug(f"TextMerger merged {len(textParts)} parts into {len(textMerged)} parts")
merged.extend(textMerged)
if tableParts:
tableMerged = tableMerger.merge(tableParts, strategy)
logger.debug(f"TableMerger merged {len(tableParts)} parts into {len(tableMerged)} parts")
merged.extend(tableMerged)
if structureParts:
# For now, treat structure like text
structureMerged = textMerger.merge(structureParts, strategy)
logger.debug(f"StructureMerger merged {len(structureParts)} parts into {len(structureMerged)} parts")
merged.extend(structureMerged)
if otherParts:
otherMerged = defaultMerger.merge(otherParts, strategy)
logger.debug(f"DefaultMerger merged {len(otherParts)} parts into {len(otherMerged)} parts")
merged.extend(otherMerged)
logger.debug(f"applyMerging returning {len(merged)} parts")
return merged

View file

@ -37,6 +37,136 @@ logger = logging.getLogger(__name__)
# Singleton factory for Chat instances
_chatInterfaces = {}
def storeDebugMessageAndDocuments(message, currentUser) -> None:
"""
Store message and documents (metadata and file bytes) for debugging purposes.
Structure: {log_dir}/debug/messages/m_round_task_action_timestamp/documentlist_label/
- message.json, message_text.txt
- document_###_metadata.json
- document_###_<original_filename> (actual file bytes)
Args:
message: ChatMessage object to store
currentUser: Current user for component interface access
"""
try:
import os
from datetime import datetime, UTC
from modules.shared.debugLogger import _getBaseDebugDir, _ensureDir
from modules.interfaces.interfaceDbComponentObjects import getInterface
# Create base debug directory (use base debug dir, not prompts subdirectory)
baseDebugDir = _getBaseDebugDir()
debug_root = os.path.join(baseDebugDir, 'messages')
_ensureDir(debug_root)
# Generate timestamp
timestamp = datetime.now(UTC).strftime('%Y%m%d-%H%M%S-%f')[:-3]
# Create message folder name: m_round_task_action_timestamp
# Use actual values from message, not defaults
round_str = str(message.roundNumber) if message.roundNumber is not None else "0"
task_str = str(message.taskNumber) if message.taskNumber is not None else "0"
action_str = str(message.actionNumber) if message.actionNumber is not None else "0"
message_folder = f"{timestamp}_m_{round_str}_{task_str}_{action_str}"
message_path = os.path.join(debug_root, message_folder)
os.makedirs(message_path, exist_ok=True)
# Store message data - use dict() instead of model_dump() for compatibility
message_file = os.path.join(message_path, "message.json")
with open(message_file, "w", encoding="utf-8") as f:
# Convert message to dict manually to avoid model_dump() issues
message_dict = {
"id": message.id,
"workflowId": message.workflowId,
"parentMessageId": message.parentMessageId,
"message": message.message,
"role": message.role,
"status": message.status,
"sequenceNr": message.sequenceNr,
"publishedAt": message.publishedAt,
"roundNumber": message.roundNumber,
"taskNumber": message.taskNumber,
"actionNumber": message.actionNumber,
"documentsLabel": message.documentsLabel,
"actionId": message.actionId,
"actionMethod": message.actionMethod,
"actionName": message.actionName,
"success": message.success,
"documents": []
}
json.dump(message_dict, f, indent=2, ensure_ascii=False, default=str)
# Store message content as text
if message.message:
message_text_file = os.path.join(message_path, "message_text.txt")
with open(message_text_file, "w", encoding="utf-8") as f:
f.write(str(message.message))
# Store documents if provided
if message.documents and len(message.documents) > 0:
# Group documents by documentsLabel
documents_by_label = {}
for doc in message.documents:
label = message.documentsLabel or 'default'
if label not in documents_by_label:
documents_by_label[label] = []
documents_by_label[label].append(doc)
# Create subfolder for each document label
for label, docs in documents_by_label.items():
# Sanitize label for filesystem
safe_label = "".join(c for c in str(label) if c.isalnum() or c in (' ', '-', '_')).rstrip()
safe_label = safe_label.replace(' ', '_')
if not safe_label:
safe_label = "default"
label_folder = os.path.join(message_path, safe_label)
_ensureDir(label_folder)
# Store each document
for i, doc in enumerate(docs):
# Create document metadata file
doc_meta = {
"id": doc.id,
"messageId": doc.messageId,
"fileId": doc.fileId,
"fileName": doc.fileName,
"fileSize": doc.fileSize,
"mimeType": doc.mimeType,
"roundNumber": doc.roundNumber,
"taskNumber": doc.taskNumber,
"actionNumber": doc.actionNumber,
"actionId": doc.actionId
}
doc_meta_file = os.path.join(label_folder, f"document_{i+1:03d}_metadata.json")
with open(doc_meta_file, "w", encoding="utf-8") as f:
json.dump(doc_meta, f, indent=2, ensure_ascii=False, default=str)
# Also store the actual file bytes next to metadata for debugging
try:
componentInterface = getInterface(currentUser)
file_bytes = componentInterface.getFileData(doc.fileId)
if file_bytes:
# Build a safe filename preserving original name
safe_name = doc.fileName or f"document_{i+1:03d}"
# Avoid path traversal
safe_name = os.path.basename(safe_name)
doc_file_path = os.path.join(label_folder, f"document_{i+1:03d}_" + safe_name)
with open(doc_file_path, "wb") as df:
df.write(file_bytes)
else:
pass
except Exception as e:
pass
except Exception as e:
# Silent fail - don't break main flow
pass
class ChatObjects:
"""
Interface to Chat database and AI Connectors.
@ -893,7 +1023,6 @@ class ChatObjects:
)
# Debug: Store message and documents for debugging - only if debug enabled
from modules.shared.debugLogger import storeDebugMessageAndDocuments
storeDebugMessageAndDocuments(chat_message, self.currentUser)
return chat_message
@ -1550,8 +1679,8 @@ class ChatObjects:
if createdAutomation.get("executionLogs") is None:
createdAutomation["executionLogs"] = []
# Trigger sync (async, don't wait)
asyncio.create_task(self.syncAutomationEvents())
# Trigger automation change callback (async, don't wait)
asyncio.create_task(self._notifyAutomationChanged())
return createdAutomation
except Exception as e:
@ -1581,8 +1710,8 @@ class ChatObjects:
if updatedAutomation.get("executionLogs") is None:
updatedAutomation["executionLogs"] = []
# Trigger sync (async, don't wait)
asyncio.create_task(self.syncAutomationEvents())
# Trigger automation change callback (async, don't wait)
asyncio.create_task(self._notifyAutomationChanged())
return updatedAutomation
except Exception as e:
@ -1611,374 +1740,22 @@ class ChatObjects:
# Delete automation from database
self.db.recordDelete(AutomationDefinition, automationId)
# Trigger sync (async, don't wait)
asyncio.create_task(self.syncAutomationEvents())
# Trigger automation change callback (async, don't wait)
asyncio.create_task(self._notifyAutomationChanged())
return True
except Exception as e:
logger.error(f"Error deleting automation definition: {str(e)}")
raise
def _replacePlaceholders(self, template: str, placeholders: Dict[str, str]) -> str:
"""Replace placeholders in template with actual values. Placeholder format: {{KEY:PLACEHOLDER_NAME}}"""
result = template
for placeholderName, value in placeholders.items():
pattern = f"{{{{KEY:{placeholderName}}}}}"
# Check if placeholder is in an array context like ["{{KEY:...}}"]
# If value is a JSON array/dict, we should replace the entire ["{{KEY:...}}"] with the array
arrayPattern = f'["{pattern}"]'
if arrayPattern in result:
# Check if value is a JSON array/dict
isArrayValue = False
arrayValue = None
if isinstance(value, (list, dict)):
isArrayValue = True
arrayValue = json.dumps(value)
elif isinstance(value, str):
try:
parsed = json.loads(value)
if isinstance(parsed, (list, dict)):
isArrayValue = True
arrayValue = value # Already valid JSON string
except (json.JSONDecodeError, ValueError):
pass
if isArrayValue:
# Replace ["{{KEY:...}}"] with the array value
result = result.replace(arrayPattern, arrayValue)
continue # Skip the regular replacement below
# Regular replacement - check if in quoted context
patternStart = result.find(pattern)
isQuoted = False
if patternStart > 0:
charBefore = result[patternStart - 1] if patternStart > 0 else None
patternEnd = patternStart + len(pattern)
charAfter = result[patternEnd] if patternEnd < len(result) else None
if charBefore == '"' and charAfter == '"':
isQuoted = True
# Handle different value types
if isinstance(value, (list, dict)):
# Python list/dict - convert to JSON
replacement = json.dumps(value)
elif isinstance(value, str):
# String value - check if it's a JSON string representing list/dict
try:
parsed = json.loads(value)
if isinstance(parsed, (list, dict)):
# It's a JSON string of a list/dict
if isQuoted:
# In quoted context, escape the JSON string
escaped = json.dumps(value)
replacement = escaped[1:-1] # Remove outer quotes
else:
# In unquoted context, use JSON directly
replacement = value
else:
# It's a JSON string of a primitive
if isQuoted:
escaped = json.dumps(value)
replacement = escaped[1:-1]
else:
replacement = value
except (json.JSONDecodeError, ValueError):
# Not valid JSON - treat as plain string
if isQuoted:
escaped = json.dumps(value)
replacement = escaped[1:-1]
else:
replacement = value
else:
# Numbers, booleans, None - convert to string
replacement = str(value)
result = result.replace(pattern, replacement)
return result
def _parseScheduleToCron(self, schedule: str) -> Dict[str, Any]:
"""Parse schedule string to cron kwargs for APScheduler"""
parts = schedule.split()
if len(parts) != 5:
raise ValueError(f"Invalid schedule format: {schedule}")
return {
"minute": parts[0],
"hour": parts[1],
"day": parts[2],
"month": parts[3],
"day_of_week": parts[4]
}
async def executeAutomation(self, automationId: str) -> ChatWorkflow:
"""Execute automation workflow immediately (test mode) with placeholder replacement"""
executionStartTime = getUtcTimestamp()
executionLog = {
"timestamp": executionStartTime,
"workflowId": None,
"status": "running",
"messages": []
}
async def _notifyAutomationChanged(self):
"""Notify registered callbacks about automation changes (decoupled from features)."""
try:
# 1. Load automation definition
automation = self.getAutomationDefinition(automationId)
if not automation:
raise ValueError(f"Automation {automationId} not found")
executionLog["messages"].append(f"Started execution at {executionStartTime}")
# 2. Replace placeholders in template to generate plan
template = automation.get("template", "")
placeholders = automation.get("placeholders", {})
planJson = self._replacePlaceholders(template, placeholders)
try:
plan = json.loads(planJson)
except json.JSONDecodeError as e:
logger.error(f"Failed to parse plan JSON after placeholder replacement: {str(e)}")
logger.error(f"Template: {template[:500]}...")
logger.error(f"Placeholders: {placeholders}")
logger.error(f"Generated planJson (first 1000 chars): {planJson[:1000]}")
logger.error(f"Error position: line {e.lineno}, column {e.colno}, char {e.pos}")
if e.pos:
start = max(0, e.pos - 100)
end = min(len(planJson), e.pos + 100)
logger.error(f"Context around error: ...{planJson[start:end]}...")
raise ValueError(f"Invalid JSON after placeholder replacement: {str(e)}")
executionLog["messages"].append("Template placeholders replaced successfully")
# 3. Get user who created automation
creator_user_id = automation.get("_createdBy")
# If _createdBy is missing, try to fix it by setting it to current user
# This handles automations created before _createdBy was required
if not creator_user_id:
logger.warning(f"Automation {automationId} has no creator user, setting to current user {self.userId}")
try:
# Update the automation to set _createdBy
self.db.recordModify(
AutomationDefinition,
automationId,
{"_createdBy": self.userId}
)
creator_user_id = self.userId
automation["_createdBy"] = self.userId
logger.info(f"Fixed automation {automationId} by setting _createdBy to {self.userId}")
executionLog["messages"].append(f"Fixed missing _createdBy field, set to user {self.userId}")
except Exception as e:
logger.error(f"Error fixing automation {automationId}: {str(e)}")
raise ValueError(f"Automation {automationId} has no creator user and could not be fixed")
# Get user from database
from modules.interfaces.interfaceDbAppObjects import getInterface as getAppInterface
appInterface = getAppInterface(self.currentUser)
creator_user = appInterface.getUser(creator_user_id)
if not creator_user:
raise ValueError(f"Creator user {creator_user_id} not found")
executionLog["messages"].append(f"Using creator user: {creator_user_id}")
# 4. Create UserInputRequest from plan
# Embed plan JSON in prompt for TemplateMode to extract
promptText = self._planToPrompt(plan)
planJson = json.dumps(plan)
# Embed plan as JSON comment so TemplateMode can extract it
promptWithPlan = f"{promptText}\n\n<!--TEMPLATE_PLAN_START-->\n{planJson}\n<!--TEMPLATE_PLAN_END-->"
userInput = UserInputRequest(
prompt=promptWithPlan,
listFileId=[],
userLanguage=creator_user.language or "en"
)
executionLog["messages"].append("Starting workflow execution")
# 5. Start workflow using chatStart
from modules.features.chatPlayground.mainChatPlayground import chatStart
workflow = await chatStart(
currentUser=creator_user,
userInput=userInput,
workflowMode=WorkflowModeEnum.WORKFLOW_AUTOMATION,
workflowId=None
)
executionLog["workflowId"] = workflow.id
executionLog["status"] = "completed"
executionLog["messages"].append(f"Workflow {workflow.id} started successfully")
logger.info(f"Started workflow {workflow.id} with plan containing {len(plan.get('tasks', []))} tasks (plan embedded in userInput)")
# Set workflow name with "automated" prefix
automationLabel = automation.get("label", "Unknown Automation")
workflowName = f"automated: {automationLabel}"
workflow = self.updateWorkflow(workflow.id, {"name": workflowName})
logger.info(f"Set workflow {workflow.id} name to: {workflowName}")
# Update automation with execution log
executionLogs = automation.get("executionLogs", [])
executionLogs.append(executionLog)
# Keep only last 50 executions
if len(executionLogs) > 50:
executionLogs = executionLogs[-50:]
self.db.recordModify(
AutomationDefinition,
automationId,
{"executionLogs": executionLogs}
)
return workflow
from modules.shared.callbackRegistry import callbackRegistry
# Trigger callbacks without knowing which features are listening
await callbackRegistry.trigger('automation.changed', self)
except Exception as e:
# Log error to execution log
executionLog["status"] = "error"
executionLog["messages"].append(f"Error: {str(e)}")
# Update automation with execution log even on error
try:
automation = self.getAutomationDefinition(automationId)
if automation:
executionLogs = automation.get("executionLogs", [])
executionLogs.append(executionLog)
if len(executionLogs) > 50:
executionLogs = executionLogs[-50:]
self.db.recordModify(
AutomationDefinition,
automationId,
{"executionLogs": executionLogs}
)
except Exception as logError:
logger.error(f"Error saving execution log: {str(logError)}")
raise
def _planToPrompt(self, plan: Dict) -> str:
"""Convert plan structure to prompt string for workflow execution"""
return plan.get("userMessage", plan.get("overview", "Execute automation workflow"))
async def syncAutomationEvents(self) -> Dict[str, Any]:
"""Automation event handler - syncs scheduler with all active automations."""
from modules.shared.eventManagement import eventManager
# Get all automation definitions (for current mandate)
allAutomations = self.db.getRecordset(AutomationDefinition)
filtered = self._uam(AutomationDefinition, allAutomations)
registered_events = {}
for automation in filtered:
automation_id = automation.get("id")
is_active = automation.get("active", False)
current_event_id = automation.get("eventId")
schedule = automation.get("schedule")
if not schedule:
logger.warning(f"Automation {automation_id} has no schedule, skipping")
continue
try:
# Parse schedule to cron kwargs
cron_kwargs = self._parseScheduleToCron(schedule)
if is_active:
# Remove existing event if present (handles schedule changes)
if current_event_id:
try:
eventManager.remove(current_event_id)
except Exception as e:
logger.warning(f"Error removing old event {current_event_id}: {str(e)}")
# Register new event
new_event_id = f"automation.{automation_id}"
# Create event handler function
handler = self._createAutomationEventHandler(automation_id)
# Register cron job
eventManager.registerCron(
jobId=new_event_id,
func=handler,
cronKwargs=cron_kwargs,
replaceExisting=True
)
# Update automation with new eventId
if current_event_id != new_event_id:
self.db.recordModify(
AutomationDefinition,
automation_id,
{"eventId": new_event_id}
)
registered_events[automation_id] = new_event_id
else:
# Remove event if exists
if current_event_id:
try:
eventManager.remove(current_event_id)
self.db.recordModify(
AutomationDefinition,
automation_id,
{"eventId": None}
)
except Exception as e:
logger.warning(f"Error removing event {current_event_id}: {str(e)}")
except Exception as e:
logger.error(f"Error syncing automation {automation_id}: {str(e)}")
return {
"synced": len(registered_events),
"events": registered_events
}
def _createAutomationEventHandler(self, automationId: str):
"""Create event handler function for a specific automation"""
async def handler():
try:
# Get event user to access automation (event user can access all automations)
from modules.interfaces.interfaceDbAppObjects import getRootInterface
from modules.interfaces.interfaceDbAppObjects import getInterface as getAppInterface
from modules.interfaces.interfaceDbChatObjects import getInterface as getChatInterface
rootInterface = getRootInterface()
eventUser = rootInterface.getUserByUsername("event")
if not eventUser:
logger.error("Could not get event user for automation execution")
return
# Create ChatObjects interface for event user (to access automation)
eventInterface = getChatInterface(eventUser)
# Load automation using event user context
automation = eventInterface.getAutomationDefinition(automationId)
if not automation or not automation.get("active"):
logger.warning(f"Automation {automationId} not found or not active, skipping execution")
return
# Get creator user
creator_user_id = automation.get("_createdBy")
if not creator_user_id:
logger.error(f"Automation {automationId} has no creator user")
return
# Get creator user from database
appInterface = getAppInterface(eventUser)
creator_user = appInterface.getUser(creator_user_id)
if not creator_user:
logger.error(f"Creator user {creator_user_id} not found for automation {automationId}")
return
# Create ChatObjects interface for creator user
creatorInterface = getChatInterface(creator_user)
# Execute automation with creator user's context
await creatorInterface.executeAutomation(automationId)
logger.info(f"Successfully executed automation {automationId} as user {creator_user_id}")
except Exception as e:
logger.error(f"Error executing automation {automationId}: {str(e)}")
return handler
logger.error(f"Error notifying automation change: {str(e)}")
def getInterface(currentUser: Optional[User] = None) -> 'ChatObjects':

View file

@ -86,15 +86,21 @@ async def sync_all_automation_events(
requireSysadmin(currentUser)
try:
chatInterface = interfaceDbChatObjects.getInterface(currentUser)
from modules.interfaces.interfaceDbChatObjects import getInterface as getChatInterface
from modules.interfaces.interfaceDbAppObjects import getRootInterface
from modules.features.automation import syncAutomationEvents
if not hasattr(chatInterface, 'syncAutomationEvents'):
chatInterface = getChatInterface(currentUser)
# Get event user for sync operation (routes can import from interfaces)
rootInterface = getRootInterface()
eventUser = rootInterface.getUserByUsername("event")
if not eventUser:
raise HTTPException(
status_code=501,
detail="Automation methods not available"
status_code=500,
detail="Event user not available"
)
result = await chatInterface.syncAutomationEvents()
result = await syncAutomationEvents(chatInterface, eventUser)
return {
"success": True,
"synced": result.get("synced", 0),

View file

@ -84,6 +84,9 @@ class Services:
from .serviceWeb.mainServiceWeb import WebService
self.web = PublicService(WebService(self))
from .serviceSecurity.mainServiceSecurity import SecurityService
self.security = PublicService(SecurityService(self))
def getInterface(user: User, workflow: ChatWorkflow) -> Services:
return Services(user, workflow)

View file

@ -48,6 +48,18 @@ class AiService:
logger.info("Initializing ExtractionService...")
self.extractionService = ExtractionService(self.services)
async def callAi(self, request: AiCallRequest, progressCallback=None):
"""Router: handles content parts via extractionService, text context via interface.
Replaces direct calls to self.aiObjects.call() to route content parts processing
through serviceExtraction layer.
"""
if hasattr(request, 'contentParts') and request.contentParts:
return await self.extractionService.processContentPartsWithAi(
request, self.aiObjects, progressCallback
)
return await self.aiObjects.callWithTextContext(request)
async def ensureAiObjectsInitialized(self):
"""Ensure aiObjects is initialized and submodules are ready."""
if self.aiObjects is None:
@ -141,7 +153,7 @@ Respond with ONLY a JSON object in this exact format:
)
)
response = await self.aiObjects.call(request)
response = await self.callAi(request)
# Parse AI response using structured parsing with AiCallOptions model
try:
@ -251,7 +263,7 @@ Respond with ONLY a JSON object in this exact format:
else:
self.services.utils.writeDebugFile(iterationPrompt, f"{debugPrefix}_prompt_iteration_{iteration}")
response = await self.aiObjects.call(request)
response = await self.callAi(request)
result = response.content
# Update progress after AI call
@ -582,7 +594,7 @@ If no trackable items can be identified, return: {{"kpis": []}}
# Write KPI definition prompt to debug file
self.services.utils.writeDebugFile(kpiDefinitionPrompt, f"{debugPrefix}_kpi_definition_prompt")
response = await self.aiObjects.call(request)
response = await self.callAi(request)
# Write KPI definition response to debug file
self.services.utils.writeDebugFile(response.content, f"{debugPrefix}_kpi_definition_response")
@ -966,7 +978,7 @@ If no trackable items can be identified, return: {{"kpis": []}}
options=options
)
response = await self.aiObjects.call(request)
response = await self.callAi(request)
if response.content:
# Build document data for image
@ -1011,7 +1023,7 @@ If no trackable items can be identified, return: {{"kpis": []}}
options=options
)
response = await self.aiObjects.call(request)
response = await self.callAi(request)
if response.content:
metadata = AiResponseMetadata(
@ -1046,7 +1058,7 @@ If no trackable items can be identified, return: {{"kpis": []}}
options.compressContext = False
# Process contentParts for generation prompt (if provided)
# Use generic _callWithContentParts() which handles all content types (images, text, etc.)
# Use generic callWithContentParts() which handles all content types (images, text, etc.)
# This automatically processes images with vision models and merges all results
if contentParts:
# Filter out binary/other parts that shouldn't be processed

View file

@ -3,7 +3,6 @@ from typing import Dict, Any, List, Optional
from modules.datamodels.datamodelUam import User, UserConnection
from modules.datamodels.datamodelChat import ChatDocument, ChatMessage, ChatStat, ChatLog
from modules.datamodels.datamodelAi import AiCallOptions, OperationTypeEnum, PriorityEnum, ProcessingModeEnum
from modules.security.tokenManager import TokenManager
from modules.shared.progressLogger import ProgressLogger
logger = logging.getLogger(__name__)
@ -306,9 +305,9 @@ class ChatService:
token = None
token_status = "unknown"
try:
# Get a fresh token via TokenManager convenience method
# Get a fresh token via security service
logger.debug(f"Getting fresh token for connection {connection.id}")
token = TokenManager().getFreshToken(connection.id)
token = self.services.security.getFreshToken(connection.id)
if token:
if hasattr(token, 'expiresAt') and token.expiresAt:
current_time = self.services.utils.timestampGetUtc()
@ -389,7 +388,7 @@ class ChatService:
Token object or None if not found/expired
"""
try:
return TokenManager().getFreshToken(connectionId)
return self.services.security.getFreshToken(connectionId)
except Exception as e:
logger.error(f"Error getting fresh token for connection {connectionId}: {str(e)}")
return None

View file

@ -3,13 +3,15 @@ import uuid
import logging
import time
import asyncio
import base64
from .subRegistry import ExtractorRegistry, ChunkerRegistry
from .subPipeline import runExtraction
from modules.datamodels.datamodelExtraction import ContentExtracted, ContentPart, MergeStrategy, ExtractionOptions, PartResult
from modules.datamodels.datamodelChat import ChatDocument
from modules.datamodels.datamodelAi import AiCallResponse, AiCallRequest, AiCallOptions
from modules.datamodels.datamodelAi import AiCallResponse, AiCallRequest, AiCallOptions, OperationTypeEnum, AiModelCall
from modules.aicore.aicoreModelRegistry import modelRegistry
from modules.aicore.aicoreModelSelector import modelSelector
logger = logging.getLogger(__name__)
@ -498,7 +500,7 @@ class ExtractionService:
# Merge results using existing merging system
if operationId:
self.services.chat.progressLogUpdate(operationId, 0.9, f"Merging {len(partResults)} part results")
mergedContent = self._mergePartResults(partResults, options)
mergedContent = self.mergePartResults(partResults, options)
# Save merged extraction content to debug
self.services.utils.writeDebugFile(mergedContent or '', "extraction_merged_text")
@ -660,54 +662,473 @@ class ExtractionService:
logger.info(f"Completed processing {len(processedResults)} parts")
return processedResults
def _mergePartResults(
def _convertToContentParts(
self, partResults: Union[List[PartResult], List[AiCallResponse]]
) -> List[ContentPart]:
"""Convert part results to ContentParts (internal helper for consolidation).
Handles both PartResult (from extraction workflow) and AiCallResponse (from content parts processing).
"""
content_parts = []
if not partResults:
return content_parts
# Detect input type and convert accordingly
if isinstance(partResults[0], PartResult):
# Existing logic for PartResult (from processDocumentsPerChunk)
for part_result in partResults:
content_part = ContentPart(
id=part_result.originalPart.id,
parentId=part_result.originalPart.parentId,
label=part_result.originalPart.label,
typeGroup=part_result.originalPart.typeGroup, # Use original typeGroup
mimeType=part_result.originalPart.mimeType,
data=part_result.aiResult, # Use AI result as data
metadata={
**part_result.originalPart.metadata,
"aiResult": True,
"partIndex": part_result.partIndex,
"documentId": part_result.documentId,
"processingTime": part_result.processingTime,
"success": part_result.metadata.get("success", False)
}
)
content_parts.append(content_part)
elif isinstance(partResults[0], AiCallResponse):
# Logic from interfaceAiObjects (from content parts processing)
for i, result in enumerate(partResults):
if result.content:
content_part = ContentPart(
id=str(uuid.uuid4()),
parentId=None,
label=f"ai_result_{i}",
typeGroup="text", # Default to text for AI results
mimeType="text/plain",
data=result.content,
metadata={
"aiResult": True,
"modelName": result.modelName,
"priceUsd": result.priceUsd,
"processingTime": result.processingTime,
"bytesSent": result.bytesSent,
"bytesReceived": result.bytesReceived
}
)
content_parts.append(content_part)
return content_parts
def mergePartResults(
self,
partResults: List[PartResult],
partResults: Union[List[PartResult], List[AiCallResponse]],
options: Optional[AiCallOptions] = None
) -> str:
"""Merge part results using existing sophisticated merging system."""
) -> str:
"""Unified merge for both PartResult and AiCallResponse.
Consolidated from both interfaceAiObjects.py and existing serviceExtraction method.
"""
if not partResults:
return ""
# Convert PartResults back to ContentParts for existing merger system
content_parts = []
for part_result in partResults:
# Create ContentPart from PartResult with proper typeGroup
content_part = ContentPart(
id=part_result.originalPart.id,
parentId=part_result.originalPart.parentId,
label=part_result.originalPart.label,
typeGroup=part_result.originalPart.typeGroup, # Use original typeGroup
mimeType=part_result.originalPart.mimeType,
data=part_result.aiResult, # Use AI result as data
metadata={
**part_result.originalPart.metadata,
"aiResult": True,
"partIndex": part_result.partIndex,
"documentId": part_result.documentId,
"processingTime": part_result.processingTime,
"success": part_result.metadata.get("success", False)
}
# Convert to ContentParts using unified helper
content_parts = self._convertToContentParts(partResults)
# Determine merge strategy based on input type
if isinstance(partResults[0], PartResult):
# Use strategy for extraction workflow (group by document, order by part index)
merge_strategy = MergeStrategy(
useIntelligentMerging=True,
groupBy="documentId", # Group by document
orderBy="partIndex", # Order by part index
mergeType="concatenate"
)
else:
# Default strategy for content parts workflow
merge_strategy = MergeStrategy(
useIntelligentMerging=True,
groupBy="typeGroup",
orderBy="id",
mergeType="concatenate"
)
content_parts.append(content_part)
# Use existing merging strategy from options
merge_strategy = MergeStrategy(
useIntelligentMerging=True,
groupBy="documentId", # Group by document
orderBy="partIndex", # Order by part index
mergeType="concatenate"
)
# Apply existing merging logic using the sophisticated merging system
from modules.interfaces.interfaceAiObjects import applyMerging
# Apply merging
merged_parts = applyMerging(content_parts, merge_strategy)
# Convert merged parts back to final string
# Convert back to string
final_content = "\n\n".join([part.data for part in merged_parts])
logger.info(f"Merged {len(partResults)} parts using existing sophisticated merging system")
logger.info(f"Merged {len(partResults)} parts using unified merging system")
return final_content.strip()
async def chunkContentPartForAi(self, contentPart, model, options, prompt: str = "") -> List[Dict[str, Any]]:
"""Chunk a content part based on model capabilities, accounting for prompt, system message overhead, and maxTokens output.
Moved from interfaceAiObjects.py - model-aware chunking for AI processing.
Complementary to existing size-based chunking in extraction pipeline.
"""
# Calculate model-specific chunk sizes
modelContextTokens = model.contextLength # Total context in tokens
modelMaxOutputTokens = model.maxTokens # Maximum output tokens
# Reserve tokens for:
# 1. Prompt (user message)
promptTokens = len(prompt.encode('utf-8')) / 4 if prompt else 0
# 2. System message wrapper ("Context from documents:\n")
systemMessageTokens = 10 # ~40 bytes = 10 tokens
# 3. Max output tokens (model will reserve space for completion)
outputTokens = modelMaxOutputTokens
# 4. JSON structure and message overhead (~100 tokens)
messageOverheadTokens = 100
# Total reserved tokens = input overhead + output reservation
totalReservedTokens = promptTokens + systemMessageTokens + messageOverheadTokens + outputTokens
# Available tokens for content = context length - reserved tokens
# Use 80% of available for safety margin
availableContentTokens = int((modelContextTokens - totalReservedTokens) * 0.8)
# Ensure we have at least some space
if availableContentTokens < 100:
logger.warning(f"Very limited space for content: {availableContentTokens} tokens available. Model: {model.name}, contextLength: {modelContextTokens}, maxTokens: {modelMaxOutputTokens}, prompt: {promptTokens:.0f} tokens")
availableContentTokens = max(100, int(modelContextTokens * 0.1)) # Fallback to 10% of context
# Convert tokens to bytes (1 token ≈ 4 bytes)
availableContentBytes = availableContentTokens * 4
logger.debug(f"Chunking calculation for {model.name}: contextLength={modelContextTokens} tokens, maxTokens={modelMaxOutputTokens} tokens, prompt={promptTokens:.0f} tokens, reserved={totalReservedTokens:.0f} tokens, available={availableContentTokens} tokens ({availableContentBytes} bytes)")
# Use 70% of available content bytes for text chunks (conservative)
textChunkSize = int(availableContentBytes * 0.7)
imageChunkSize = int(availableContentBytes * 0.8) # 80% for image chunks
# Build chunking options
chunkingOptions = {
"textChunkSize": textChunkSize,
"imageChunkSize": imageChunkSize,
"maxSize": availableContentBytes,
"chunkAllowed": True
}
# Get appropriate chunker (uses existing ChunkerRegistry ✅)
chunker = self._chunkerRegistry.resolve(contentPart.typeGroup)
if not chunker:
logger.warning(f"No chunker found for typeGroup: {contentPart.typeGroup}")
return []
# Chunk the content part
try:
chunks = chunker.chunk(contentPart, chunkingOptions)
logger.debug(f"Created {len(chunks)} chunks for {contentPart.typeGroup} part")
return chunks
except Exception as e:
logger.error(f"Chunking failed for {contentPart.typeGroup}: {str(e)}")
return []
async def processContentPartWithFallback(self, contentPart, prompt: str, options, failoverModelList, aiObjects, progressCallback=None) -> AiCallResponse:
"""Process a single content part with model-aware chunking and fallback.
Moved from interfaceAiObjects.py - orchestrates chunking and merging.
Calls aiObjects._callWithModel() for actual AI calls.
"""
lastError = None
# Check if this is an image - Vision models need special handling
isImage = (contentPart.typeGroup == "image") or (contentPart.mimeType and contentPart.mimeType.startswith("image/"))
# Determine the correct operation type based on content type
actualOperationType = options.operationType
if isImage:
actualOperationType = OperationTypeEnum.IMAGE_ANALYSE
# Get vision-capable models for images
availableModels = modelRegistry.getAvailableModels()
visionFailoverList = modelSelector.getFailoverModelList(prompt, "", AiCallOptions(operationType=actualOperationType), availableModels)
if visionFailoverList:
logger.debug(f"Using {len(visionFailoverList)} vision-capable models for image processing")
failoverModelList = visionFailoverList
for attempt, model in enumerate(failoverModelList):
try:
logger.info(f"Processing content part with model: {model.name} (attempt {attempt + 1}/{len(failoverModelList)})")
# Special handling for images with Vision models
if isImage and hasattr(model, 'functionCall'):
try:
if not contentPart.data:
raise ValueError("Image content part has no data")
mimeType = contentPart.mimeType or "image/jpeg"
if not mimeType.startswith("image/"):
raise ValueError(f"Invalid mimeType for image: {mimeType}")
# Prepare base64 data
if isinstance(contentPart.data, str):
try:
base64.b64decode(contentPart.data, validate=True)
base64Data = contentPart.data
except Exception as e:
raise ValueError(f"Invalid base64 data in contentPart: {str(e)}")
elif isinstance(contentPart.data, bytes):
base64Data = base64.b64encode(contentPart.data).decode('utf-8')
else:
raise ValueError(f"Unsupported data type for image: {type(contentPart.data)}")
imageDataUrl = f"data:{mimeType};base64,{base64Data}"
modelCall = AiModelCall(
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": prompt or ""},
{
"type": "image_url",
"image_url": {"url": imageDataUrl}
}
]
}
],
model=model,
options=AiCallOptions(operationType=actualOperationType)
)
modelResponse = await model.functionCall(modelCall)
if not modelResponse.success:
raise ValueError(f"Model call failed: {modelResponse.error}")
logger.info(f"✅ Image content part processed successfully with model: {model.name}")
processingTime = getattr(modelResponse, 'processingTime', None) or 0.0
return AiCallResponse(
content=modelResponse.content,
modelName=model.name,
priceUsd=0.0,
processingTime=processingTime,
bytesSent=0,
bytesReceived=0,
errorCount=0
)
except Exception as e:
lastError = e
logger.warning(f"❌ Image processing failed with model {model.name}: {str(e)}")
if attempt < len(failoverModelList) - 1:
logger.info(f"🔄 Trying next fallback model for image processing...")
continue
else:
logger.error(f"💥 All {len(failoverModelList)} models failed for image processing")
raise
# For non-image parts, check if part fits in model context
partSize = len(contentPart.data.encode('utf-8')) if contentPart.data else 0
modelContextTokens = model.contextLength
modelMaxOutputTokens = model.maxTokens
promptTokens = len(prompt.encode('utf-8')) / 4 if prompt else 0
systemMessageTokens = 10
outputTokens = modelMaxOutputTokens
messageOverheadTokens = 100
totalReservedTokens = promptTokens + systemMessageTokens + messageOverheadTokens + outputTokens
availableContentTokens = int((modelContextTokens - totalReservedTokens) * 0.8)
if availableContentTokens < 100:
availableContentTokens = max(100, int(modelContextTokens * 0.1))
availableContentBytes = availableContentTokens * 4
logger.debug(f"Size check for {model.name}: partSize={partSize} bytes, availableContentBytes={availableContentBytes} bytes")
if partSize <= availableContentBytes:
# Part fits - call AI directly via aiObjects interface
response = await aiObjects._callWithModel(model, prompt, contentPart.data, options)
logger.info(f"✅ Content part processed successfully with model: {model.name}")
return response
else:
# Part too large - chunk it
chunks = await self.chunkContentPartForAi(contentPart, model, options, prompt)
if not chunks:
raise ValueError(f"Failed to chunk content part for model {model.name}")
logger.info(f"Starting to process {len(chunks)} chunks with model {model.name}")
if progressCallback:
progressCallback(0.0, f"Starting to process {len(chunks)} chunks")
chunkResults = []
for idx, chunk in enumerate(chunks):
chunkNum = idx + 1
chunkData = chunk.get('data', '')
logger.info(f"Processing chunk {chunkNum}/{len(chunks)} with model {model.name}")
if progressCallback:
progressCallback(chunkNum / len(chunks), f"Processing chunk {chunkNum}/{len(chunks)}")
try:
chunkResponse = await aiObjects._callWithModel(model, prompt, chunkData, options)
chunkResults.append(chunkResponse)
logger.info(f"✅ Chunk {chunkNum}/{len(chunks)} processed successfully")
if progressCallback:
progressCallback(chunkNum / len(chunks), f"Chunk {chunkNum}/{len(chunks)} processed")
except Exception as e:
logger.error(f"❌ Error processing chunk {chunkNum}/{len(chunks)}: {str(e)}")
raise
# Merge chunk results
mergedContent = self.mergeChunkResults(chunkResults)
logger.info(f"✅ Content part chunked and processed with model: {model.name} ({len(chunks)} chunks)")
return AiCallResponse(
content=mergedContent,
modelName=model.name,
priceUsd=sum(r.priceUsd for r in chunkResults),
processingTime=sum(r.processingTime for r in chunkResults),
bytesSent=sum(r.bytesSent for r in chunkResults),
bytesReceived=sum(r.bytesReceived for r in chunkResults),
errorCount=sum(r.errorCount for r in chunkResults)
)
except Exception as e:
lastError = e
error_msg = str(e) if str(e) else f"{type(e).__name__}"
logger.warning(f"❌ Model {model.name} failed for content part: {error_msg}", exc_info=True)
if attempt < len(failoverModelList) - 1:
logger.info(f"🔄 Trying next failover model...")
continue
else:
logger.error(f"💥 All {len(failoverModelList)} models failed for content part")
break
# All models failed
return self._createErrorResponse(f"All models failed: {str(lastError)}", 0, 0)
def _createErrorResponse(self, errorMsg: str, inputBytes: int, outputBytes: int) -> AiCallResponse:
"""Create an error response."""
return AiCallResponse(
content=errorMsg,
modelName="error",
priceUsd=0.0,
processingTime=0.0,
bytesSent=inputBytes,
bytesReceived=outputBytes,
errorCount=1
)
async def processContentPartsWithAi(
self,
request: AiCallRequest,
aiObjects, # Pass interface for AI calls
progressCallback=None
) -> AiCallResponse:
"""Process content parts with model-aware chunking and AI calls.
Moved from interfaceAiObjects.callWithContentParts() - entry point for content parts processing.
"""
prompt = request.prompt
options = request.options
contentParts = request.contentParts
# Get failover models
availableModels = modelRegistry.getAvailableModels()
failoverModelList = modelSelector.getFailoverModelList(prompt, "", options, availableModels)
if not failoverModelList:
return self._createErrorResponse("No suitable models found", 0, 0)
# Process each content part
allResults = []
for contentPart in contentParts:
partResult = await self.processContentPartWithFallback(
contentPart, prompt, options, failoverModelList, aiObjects, progressCallback
)
allResults.append(partResult)
# Merge all results using unified mergePartResults
mergedContent = self.mergePartResults(allResults)
return AiCallResponse(
content=mergedContent,
modelName="multiple",
priceUsd=sum(r.priceUsd for r in allResults),
processingTime=sum(r.processingTime for r in allResults),
bytesSent=sum(r.bytesSent for r in allResults),
bytesReceived=sum(r.bytesReceived for r in allResults),
errorCount=sum(r.errorCount for r in allResults)
)
# Module-level function for use by subPipeline and ExtractionService
def applyMerging(parts: List[ContentPart], strategy: MergeStrategy) -> List[ContentPart]:
"""Apply merging strategy to parts with intelligent token-aware merging.
Moved from interfaceAiObjects.py to resolve dependency violations.
Can be used as module-level function or called from ExtractionService methods.
"""
logger.debug(f"applyMerging called with {len(parts)} parts")
# Import merging dependencies (now local imports ✅)
from .merging.mergerText import TextMerger
from .merging.mergerTable import TableMerger
from .merging.mergerDefault import DefaultMerger
from .subMerger import IntelligentTokenAwareMerger
# Check if intelligent merging is enabled
if strategy.useIntelligentMerging:
modelCapabilities = strategy.capabilities or {}
subMerger = IntelligentTokenAwareMerger(modelCapabilities)
# Use intelligent merging for all parts
merged = subMerger.mergeChunksIntelligently(parts, strategy.prompt or "")
# Calculate and log optimization stats
stats = subMerger.calculateOptimizationStats(parts, merged)
logger.info(f"🧠 Intelligent merging stats: {stats}")
logger.debug(f"Intelligent merging: {stats['original_ai_calls']}{stats['optimized_ai_calls']} calls ({stats['reduction_percent']}% reduction)")
return merged
# Fallback to traditional merging
textMerger = TextMerger()
tableMerger = TableMerger()
defaultMerger = DefaultMerger()
# Group by typeGroup
textParts = [p for p in parts if p.typeGroup == "text"]
tableParts = [p for p in parts if p.typeGroup == "table"]
structureParts = [p for p in parts if p.typeGroup == "structure"]
otherParts = [p for p in parts if p.typeGroup not in ("text", "table", "structure")]
logger.debug(f"Grouped - text: {len(textParts)}, table: {len(tableParts)}, structure: {len(structureParts)}, other: {len(otherParts)}")
merged: List[ContentPart] = []
if textParts:
textMerged = textMerger.merge(textParts, strategy)
logger.debug(f"TextMerger merged {len(textParts)} parts into {len(textMerged)} parts")
merged.extend(textMerged)
if tableParts:
tableMerged = tableMerger.merge(tableParts, strategy)
logger.debug(f"TableMerger merged {len(tableParts)} parts into {len(tableMerged)} parts")
merged.extend(tableMerged)
if structureParts:
# For now, treat structure like text
structureMerged = textMerger.merge(structureParts, strategy)
logger.debug(f"StructureMerger merged {len(structureParts)} parts into {len(structureMerged)} parts")
merged.extend(structureMerged)
if otherParts:
otherMerged = defaultMerger.merge(otherParts, strategy)
logger.debug(f"DefaultMerger merged {len(otherParts)} parts into {len(otherMerged)} parts")
merged.extend(otherMerged)
logger.debug(f"applyMerging returning {len(merged)} parts")
return merged

View file

@ -34,7 +34,8 @@ def runExtraction(extractorRegistry: ExtractorRegistry, chunkerRegistry: Chunker
# Apply merging strategy if provided (preserve existing logic)
if options.mergeStrategy:
from modules.interfaces.interfaceAiObjects import applyMerging
# Use module-level applyMerging function
from .mainServiceExtraction import applyMerging
parts = applyMerging(parts, options.mergeStrategy)
return ContentExtracted(id=makeId(), parts=parts)

View file

@ -0,0 +1,128 @@
"""
Security service for token management operations.
Provides centralized access to token refresh and management functionality.
"""
import logging
from typing import Optional, Callable
from modules.datamodels.datamodelSecurity import Token
from modules.security.tokenManager import TokenManager
logger = logging.getLogger(__name__)
class SecurityService:
"""Security service providing token management operations."""
def __init__(self, services):
"""Initialize security service with service center access.
Args:
services: Service center instance providing access to interfaces
"""
self.services = services
self._tokenManager = TokenManager()
def getFreshToken(self, connectionId: str, secondsBeforeExpiry: int = 30 * 60) -> Optional[Token]:
"""Get a fresh token for a connection, refreshing when expiring soon.
Reads the latest stored token via interface layer, then
uses ensureFreshToken to refresh if needed and persists the refreshed
token via interface layer.
Args:
connectionId: ID of the connection to get token for
secondsBeforeExpiry: Threshold window to proactively refresh (default: 30 minutes)
Returns:
Token object or None if not found/expired
"""
try:
# Use interface from services instead of getRootInterface()
interfaceDbApp = self.services.interfaceDbApp
token = interfaceDbApp.getConnectionToken(connectionId)
if not token:
return None
return self._tokenManager.ensureFreshToken(
token,
secondsBeforeExpiry=secondsBeforeExpiry,
saveCallback=lambda t: interfaceDbApp.saveConnectionToken(t)
)
except Exception as e:
logger.error(f"getFreshToken: Error fetching or refreshing token for connection {connectionId}: {e}")
return None
def refreshToken(self, oldToken: Token) -> Optional[Token]:
"""Refresh an expired token using the appropriate OAuth service.
Args:
oldToken: Token object to refresh
Returns:
Refreshed Token object or None if refresh failed
"""
try:
return self._tokenManager.refreshToken(oldToken)
except Exception as e:
logger.error(f"refreshToken: Error refreshing token: {e}")
return None
def ensureFreshToken(self, token: Token, *, secondsBeforeExpiry: int = 30 * 60,
saveCallback: Optional[Callable[[Token], None]] = None) -> Optional[Token]:
"""Ensure a token is fresh; refresh if expiring within threshold.
Args:
token: Existing token to validate/refresh
secondsBeforeExpiry: Threshold window to proactively refresh (default: 30 minutes)
saveCallback: Optional function to persist a refreshed token
Returns:
A fresh token (refreshed or original) or None if refresh failed
"""
try:
return self._tokenManager.ensureFreshToken(
token,
secondsBeforeExpiry=secondsBeforeExpiry,
saveCallback=saveCallback
)
except Exception as e:
logger.error(f"ensureFreshToken: Error ensuring fresh token: {e}")
return None
def refreshMicrosoftToken(self, refreshToken: str, userId: str, oldToken: Token) -> Optional[Token]:
"""Refresh Microsoft OAuth token using refresh token.
Args:
refreshToken: Microsoft refresh token
userId: User ID owning the token
oldToken: Previous token object to preserve connection ID
Returns:
New Token object or None if refresh failed
"""
try:
return self._tokenManager.refreshMicrosoftToken(refreshToken, userId, oldToken)
except Exception as e:
logger.error(f"refreshMicrosoftToken: Error refreshing Microsoft token: {e}")
return None
def refreshGoogleToken(self, refreshToken: str, userId: str, oldToken: Token) -> Optional[Token]:
"""Refresh Google OAuth token using refresh token.
Args:
refreshToken: Google refresh token
userId: User ID owning the token
oldToken: Previous token object to preserve connection ID
Returns:
New Token object or None if refresh failed
"""
try:
return self._tokenManager.refreshGoogleToken(refreshToken, userId, oldToken)
except Exception as e:
logger.error(f"refreshGoogleToken: Error refreshing Google token: {e}")
return None

View file

@ -47,9 +47,12 @@ class SharepointService:
logger.error("UserConnection must have an 'id' field")
return False
# Get a fresh token for this specific connection
from modules.security.tokenManager import TokenManager
token = TokenManager().getFreshToken(connectionId)
# Get a fresh token for this specific connection via security service
if not self.services:
logger.error("Service center not available for token access")
return False
token = self.services.security.getFreshToken(connectionId)
if not token:
logger.error(f"No token found for connection {connectionId}")
return False

View file

@ -155,11 +155,11 @@ class UtilsService:
def storeDebugMessageAndDocuments(self, message, currentUser):
"""
Wrapper to store debug messages and documents via shared debugLogger.
Mirrors storeDebugMessageAndDocuments() in modules.shared.debugLogger.
Wrapper to store debug messages and documents via interfaceDbChatObjects.
Mirrors storeDebugMessageAndDocuments() in modules.interfaces.interfaceDbChatObjects.
"""
try:
from modules.shared.debugLogger import storeDebugMessageAndDocuments as _storeDebugMessageAndDocuments
from modules.interfaces.interfaceDbChatObjects import storeDebugMessageAndDocuments as _storeDebugMessageAndDocuments
_storeDebugMessageAndDocuments(message, currentUser)
except Exception:
# Silent fail to never break main flow

View file

@ -0,0 +1,70 @@
"""
Callback registry for decoupled event notifications.
Allows interfaces to notify about changes without knowing about features.
Features can register callbacks to be notified when automations change.
"""
import logging
from typing import Callable, List, Dict, Any
import asyncio
logger = logging.getLogger(__name__)
class CallbackRegistry:
"""Registry for callbacks that can be triggered by interfaces without knowing about features."""
def __init__(self):
self._callbacks: Dict[str, List[Callable]] = {}
def register(self, event_type: str, callback: Callable):
"""Register a callback for a specific event type.
Args:
event_type: Type of event (e.g., 'automation.changed')
callback: Async or sync callback function
"""
if event_type not in self._callbacks:
self._callbacks[event_type] = []
self._callbacks[event_type].append(callback)
logger.debug(f"Registered callback for event type: {event_type}")
def unregister(self, event_type: str, callback: Callable):
"""Unregister a callback for a specific event type."""
if event_type in self._callbacks:
try:
self._callbacks[event_type].remove(callback)
logger.debug(f"Unregistered callback for event type: {event_type}")
except ValueError:
logger.warning(f"Callback not found for event type: {event_type}")
async def trigger(self, event_type: str, *args, **kwargs):
"""Trigger all callbacks registered for an event type.
Args:
event_type: Type of event to trigger
*args, **kwargs: Arguments to pass to callbacks
"""
if event_type not in self._callbacks:
return
callbacks = self._callbacks[event_type].copy() # Copy to avoid modification during iteration
for callback in callbacks:
try:
if asyncio.iscoroutinefunction(callback):
await callback(*args, **kwargs)
else:
callback(*args, **kwargs)
except Exception as e:
logger.error(f"Error executing callback for {event_type}: {str(e)}", exc_info=True)
def has_callbacks(self, event_type: str) -> bool:
"""Check if there are any callbacks registered for an event type."""
return event_type in self._callbacks and len(self._callbacks[event_type]) > 0
# Global singleton instance
callbackRegistry = CallbackRegistry()

View file

@ -145,131 +145,3 @@ def debugLogToFile(message: str, context: str = "DEBUG") -> None:
# Don't log debug errors to avoid recursion
pass
def storeDebugMessageAndDocuments(message, currentUser) -> None:
"""
Store message and documents (metadata and file bytes) for debugging purposes.
Structure: {log_dir}/debug/messages/m_round_task_action_timestamp/documentlist_label/
- message.json, message_text.txt
- document_###_metadata.json
- document_###_<original_filename> (actual file bytes)
Args:
message: ChatMessage object to store
currentUser: Current user for component interface access
"""
try:
import json
# Create base debug directory (use base debug dir, not prompts subdirectory)
baseDebugDir = _getBaseDebugDir()
debug_root = os.path.join(baseDebugDir, 'messages')
_ensureDir(debug_root)
# Generate timestamp
timestamp = datetime.now(UTC).strftime('%Y%m%d-%H%M%S-%f')[:-3]
# Create message folder name: m_round_task_action_timestamp
# Use actual values from message, not defaults
round_str = str(message.roundNumber) if message.roundNumber is not None else "0"
task_str = str(message.taskNumber) if message.taskNumber is not None else "0"
action_str = str(message.actionNumber) if message.actionNumber is not None else "0"
message_folder = f"{timestamp}_m_{round_str}_{task_str}_{action_str}"
message_path = os.path.join(debug_root, message_folder)
os.makedirs(message_path, exist_ok=True)
# Store message data - use dict() instead of model_dump() for compatibility
message_file = os.path.join(message_path, "message.json")
with open(message_file, "w", encoding="utf-8") as f:
# Convert message to dict manually to avoid model_dump() issues
message_dict = {
"id": message.id,
"workflowId": message.workflowId,
"parentMessageId": message.parentMessageId,
"message": message.message,
"role": message.role,
"status": message.status,
"sequenceNr": message.sequenceNr,
"publishedAt": message.publishedAt,
"roundNumber": message.roundNumber,
"taskNumber": message.taskNumber,
"actionNumber": message.actionNumber,
"documentsLabel": message.documentsLabel,
"actionId": message.actionId,
"actionMethod": message.actionMethod,
"actionName": message.actionName,
"success": message.success,
"documents": []
}
json.dump(message_dict, f, indent=2, ensure_ascii=False, default=str)
# Store message content as text
if message.message:
message_text_file = os.path.join(message_path, "message_text.txt")
with open(message_text_file, "w", encoding="utf-8") as f:
f.write(str(message.message))
# Store documents if provided
if message.documents and len(message.documents) > 0:
# Group documents by documentsLabel
documents_by_label = {}
for doc in message.documents:
label = message.documentsLabel or 'default'
if label not in documents_by_label:
documents_by_label[label] = []
documents_by_label[label].append(doc)
# Create subfolder for each document label
for label, docs in documents_by_label.items():
# Sanitize label for filesystem
safe_label = "".join(c for c in str(label) if c.isalnum() or c in (' ', '-', '_')).rstrip()
safe_label = safe_label.replace(' ', '_')
if not safe_label:
safe_label = "default"
label_folder = os.path.join(message_path, safe_label)
_ensureDir(label_folder)
# Store each document
for i, doc in enumerate(docs):
# Create document metadata file
doc_meta = {
"id": doc.id,
"messageId": doc.messageId,
"fileId": doc.fileId,
"fileName": doc.fileName,
"fileSize": doc.fileSize,
"mimeType": doc.mimeType,
"roundNumber": doc.roundNumber,
"taskNumber": doc.taskNumber,
"actionNumber": doc.actionNumber,
"actionId": doc.actionId
}
doc_meta_file = os.path.join(label_folder, f"document_{i+1:03d}_metadata.json")
with open(doc_meta_file, "w", encoding="utf-8") as f:
json.dump(doc_meta, f, indent=2, ensure_ascii=False, default=str)
# Also store the actual file bytes next to metadata for debugging
try:
# Lazy import to avoid circular deps at module load
from modules.interfaces import interfaceDbComponentObjects as comp
componentInterface = comp.getInterface(currentUser)
file_bytes = componentInterface.getFileData(doc.fileId)
if file_bytes:
# Build a safe filename preserving original name
safe_name = doc.fileName or f"document_{i+1:03d}"
# Avoid path traversal
safe_name = os.path.basename(safe_name)
doc_file_path = os.path.join(label_folder, f"document_{i+1:03d}_" + safe_name)
with open(doc_file_path, "wb") as df:
df.write(file_bytes)
else:
pass
except Exception as e:
pass
except Exception as e:
# Silent fail - don't break main flow
pass

View file

@ -214,138 +214,6 @@ class MethodAi(MethodBase):
)
@action
async def extractContent(self, parameters: Dict[str, Any]) -> ActionResult:
"""
Extract content from documents (separate from AI calls).
This action performs pure content extraction without AI processing.
The extracted ContentParts can then be used by subsequent AI processing actions.
Parameters:
- documentList (list, required): Document reference(s) to extract content from.
- extractionOptions (dict, optional): Extraction options (if not provided, defaults are used).
Returns:
- ActionResult with ActionDocument containing ContentExtracted objects
- ContentExtracted.parts contains List[ContentPart] (already chunked if needed)
"""
try:
# Init progress logger
workflowId = self.services.workflow.id if self.services.workflow else f"no-workflow-{int(time.time())}"
operationId = f"ai_extract_{workflowId}_{int(time.time())}"
# Extract documentList from parameters dict
from modules.datamodels.datamodelDocref import DocumentReferenceList
documentListParam = parameters.get("documentList")
if not documentListParam:
return ActionResult.isFailure(error="documentList is required")
# Convert to DocumentReferenceList if needed
if isinstance(documentListParam, DocumentReferenceList):
documentList = documentListParam
elif isinstance(documentListParam, str):
documentList = DocumentReferenceList.from_string_list([documentListParam])
elif isinstance(documentListParam, list):
documentList = DocumentReferenceList.from_string_list(documentListParam)
else:
return ActionResult.isFailure(error=f"Invalid documentList type: {type(documentListParam)}")
# Start progress tracking
self.services.chat.progressLogStart(
operationId,
"Extracting content from documents",
"Content Extraction",
f"Documents: {len(documentList.references)}"
)
# Get ChatDocuments from documentList
self.services.chat.progressLogUpdate(operationId, 0.2, "Loading documents")
chatDocuments = self.services.chat.getChatDocumentsFromDocumentList(documentList)
if not chatDocuments:
self.services.chat.progressLogFinish(operationId, False)
return ActionResult.isFailure(error="No documents found in documentList")
logger.info(f"Extracting content from {len(chatDocuments)} documents")
# Prepare extraction options
self.services.chat.progressLogUpdate(operationId, 0.3, "Preparing extraction options")
extractionOptionsParam = parameters.get("extractionOptions")
# Convert dict to ExtractionOptions object if needed, or create defaults
if extractionOptionsParam:
if isinstance(extractionOptionsParam, dict):
# Convert dict to ExtractionOptions object
extractionOptions = ExtractionOptions(**extractionOptionsParam)
elif isinstance(extractionOptionsParam, ExtractionOptions):
extractionOptions = extractionOptionsParam
else:
# Invalid type, use defaults
extractionOptions = None
else:
extractionOptions = None
# If extractionOptions not provided, create defaults
if not extractionOptions:
# Default extraction options for pure content extraction (no AI processing)
extractionOptions = ExtractionOptions(
prompt="Extract all content from the document",
mergeStrategy=MergeStrategy(
mergeType="concatenate",
groupBy="typeGroup",
orderBy="id"
),
processDocumentsIndividually=True
)
# Get parent log ID for document-level operations
parentLogId = self.services.chat.getOperationLogId(operationId)
# Call extraction service
self.services.chat.progressLogUpdate(operationId, 0.4, "Initiating")
self.services.chat.progressLogUpdate(operationId, 0.5, f"Extracting content from {len(chatDocuments)} documents")
extractedResults = self.services.extraction.extractContent(chatDocuments, extractionOptions)
# Build ActionDocuments from ContentExtracted results
self.services.chat.progressLogUpdate(operationId, 0.8, "Building result documents")
actionDocuments = []
# Map extracted results back to original documents by index (results are in same order)
for i, extracted in enumerate(extractedResults):
# Get original document name if available
originalDoc = chatDocuments[i] if i < len(chatDocuments) else None
if originalDoc and hasattr(originalDoc, 'fileName') and originalDoc.fileName:
# Use original filename with "extracted_" prefix
baseName = originalDoc.fileName.rsplit('.', 1)[0] if '.' in originalDoc.fileName else originalDoc.fileName
documentName = f"{baseName}_extracted_{extracted.id}.json"
else:
# Fallback to generic name with index
documentName = f"document_{i+1:03d}_extracted_{extracted.id}.json"
# Store ContentExtracted object in ActionDocument.documentData
actionDoc = ActionDocument(
documentName=documentName,
documentData=extracted, # ContentExtracted object
mimeType="application/json"
)
actionDocuments.append(actionDoc)
self.services.chat.progressLogFinish(operationId, True)
return ActionResult.isSuccess(documents=actionDocuments)
except Exception as e:
logger.error(f"Error in content extraction: {str(e)}")
# Complete progress tracking with failure
try:
self.services.chat.progressLogFinish(operationId, False)
except:
pass # Don't fail on progress logging errors
return ActionResult.isFailure(error=str(e))
@action
async def webResearch(self, parameters: Dict[str, Any]) -> ActionResult:
"""
@ -707,171 +575,6 @@ class MethodAi(MethodBase):
return output.getvalue()
@action
async def reformat(self, parameters: Dict[str, Any]) -> ActionResult:
"""
GENERAL:
- Purpose: Reformat/transform documents with specific transformation rules (e.g., extract arrays, reshape data, apply custom formatting).
- Input requirements: documentList (required); inputFormat and outputFormat (required); transformationRules (optional).
- Output format: Document in target format with applied transformation rules.
- CRITICAL: If input is already in standardized JSON format, uses automatic rendering system with transformation rules.
Parameters:
- documentList (list, required): Document reference(s) to reformat.
- inputFormat (str, required): Source format (json, csv, xlsx, txt, etc.).
- outputFormat (str, required): Target format (csv, json, xlsx, txt, etc.).
- transformationRules (str, optional): Specific transformation instructions (e.g., "Extract prime numbers array and format as CSV with 10 columns per row").
- columnsPerRow (int, optional): For CSV output, number of columns per row. Default: auto-detect.
- totalRows (int, optional): For CSV output, total number of rows to create. Default: auto-detect.
- delimiter (str, optional): For CSV output, delimiter character. Default: comma (,).
- includeHeader (bool, optional): For CSV output, whether to include header row. Default: True.
- language (str, optional): Language for output (e.g., 'de', 'en', 'fr'). Default: 'en'.
"""
documentList = parameters.get("documentList", [])
if not documentList:
return ActionResult.isFailure(error="documentList is required")
inputFormat = parameters.get("inputFormat")
outputFormat = parameters.get("outputFormat")
if not inputFormat or not outputFormat:
return ActionResult.isFailure(error="inputFormat and outputFormat are required")
transformationRules = parameters.get("transformationRules")
columnsPerRow = parameters.get("columnsPerRow")
totalRows = parameters.get("totalRows")
delimiter = parameters.get("delimiter", ",")
includeHeader = parameters.get("includeHeader", True)
language = parameters.get("language", "en")
# Normalize formats (remove leading dot if present)
normalizedInputFormat = inputFormat.strip().lstrip('.').lower()
normalizedOutputFormat = outputFormat.strip().lstrip('.').lower()
# Get documents
from modules.datamodels.datamodelDocref import DocumentReferenceList
if isinstance(documentList, DocumentReferenceList):
docRefList = documentList
elif isinstance(documentList, list):
docRefList = DocumentReferenceList.from_string_list(documentList)
else:
docRefList = DocumentReferenceList.from_string_list([documentList])
chatDocuments = self.services.chat.getChatDocumentsFromDocumentList(docRefList)
if not chatDocuments:
return ActionResult.isFailure(error="No documents found in documentList")
# Check if input is standardized JSON format - if so, use direct rendering with transformation
if normalizedInputFormat == "json" and len(chatDocuments) == 1:
try:
import json
doc = chatDocuments[0]
# ChatDocument doesn't have documentData - need to load file content using fileId
docBytes = self.services.chat.getFileData(doc.fileId)
if not docBytes:
raise ValueError(f"No file data found for fileId={doc.fileId}")
# Decode bytes to string
docData = docBytes.decode('utf-8')
# Try to parse as JSON
if isinstance(docData, str):
jsonData = json.loads(docData)
elif isinstance(docData, dict):
jsonData = docData
else:
jsonData = None
# Check if it's standardized JSON format (has "documents" or "sections")
if jsonData and (isinstance(jsonData, dict) and ("documents" in jsonData or "sections" in jsonData)):
# Apply transformation rules if provided
if transformationRules:
# Use AI to apply transformation rules to JSON
aiPrompt = f"Apply the following transformation rules to the JSON document: {transformationRules}"
if normalizedOutputFormat == "csv":
aiPrompt += f" Output format: CSV with delimiter '{delimiter}'"
if columnsPerRow:
aiPrompt += f", {columnsPerRow} columns per row"
if totalRows:
aiPrompt += f", {totalRows} total rows"
if not includeHeader:
aiPrompt += ", no header row"
# Use process to apply transformation
return await self.process({
"aiPrompt": aiPrompt,
"documentList": documentList,
"resultType": normalizedOutputFormat
})
else:
# No transformation rules - use direct rendering
from modules.services.serviceGeneration.mainServiceGeneration import GenerationService
generationService = GenerationService(self.services)
# Ensure format is "documents" array
if "documents" not in jsonData:
jsonData = {"documents": [{"sections": jsonData.get("sections", []), "metadata": jsonData.get("metadata", {})}]}
# Get title
title = jsonData.get("metadata", {}).get("title", doc.documentName or "Reformatted Document")
# Render with options
renderOptions = {}
if normalizedOutputFormat == "csv":
renderOptions["delimiter"] = delimiter
renderOptions["columnsPerRow"] = columnsPerRow
renderOptions["includeHeader"] = includeHeader
rendered_content, mime_type = await generationService.renderReport(
jsonData, normalizedOutputFormat, title, None, None
)
# Apply CSV options if needed
if normalizedOutputFormat == "csv" and renderOptions:
rendered_content = self._applyCsvOptions(rendered_content, renderOptions)
from modules.datamodels.datamodelChat import ActionDocument
actionDoc = ActionDocument(
documentName=f"{doc.documentName.rsplit('.', 1)[0] if '.' in doc.documentName else doc.documentName}.{normalizedOutputFormat}",
documentData=rendered_content,
mimeType=mime_type,
sourceJson=jsonData # Preserve source JSON for structure validation
)
return ActionResult.isSuccess(documents=[actionDoc])
except Exception as e:
logger.warning(f"Direct rendering failed, falling back to AI reformatting: {str(e)}")
# Fall through to AI-based reformatting
# Fallback: Use AI for reformatting with transformation rules
aiPrompt = f"Reformat the provided document(s) from {normalizedInputFormat.upper()} format to {normalizedOutputFormat.upper()} format."
if transformationRules:
aiPrompt += f" Apply the following transformation rules: {transformationRules}"
if normalizedOutputFormat == "csv":
aiPrompt += f" Use '{delimiter}' as the delimiter character."
if columnsPerRow:
aiPrompt += f" Format the output with {columnsPerRow} columns per row."
if totalRows:
aiPrompt += f" Create exactly {totalRows} rows total."
if not includeHeader:
aiPrompt += " Do not include a header row."
else:
aiPrompt += " Include a header row with column names."
if language and language != "en":
aiPrompt += f" Use language: {language}."
aiPrompt += " Preserve all data and ensure accurate transformation. Maintain data integrity."
return await self.process({
"aiPrompt": aiPrompt,
"documentList": documentList,
"resultType": normalizedOutputFormat
})
@action
async def convertDocument(self, parameters: Dict[str, Any]) -> ActionResult:
"""
@ -955,160 +658,10 @@ class MethodAi(MethodBase):
})
@action
async def extractTables(self, parameters: Dict[str, Any]) -> ActionResult:
"""
GENERAL:
- Purpose: Extract tables from documents, preserving structure and data.
- Input requirements: documentList (required); optional tableFormat.
- Output format: JSON by default (structured table data), or CSV/XLSX if specified.
Parameters:
- documentList (list, required): Document reference(s) to extract tables from.
- tableFormat (str, optional): Output format for tables - json, csv, or xlsx. Default: json.
- includeHeaders (bool, optional): Include table headers. Default: True.
"""
documentList = parameters.get("documentList", [])
if not documentList:
return ActionResult.isFailure(error="documentList is required")
tableFormat = parameters.get("tableFormat", "json")
includeHeaders = parameters.get("includeHeaders", True)
# Map tableFormat to resultType
formatMap = {
"json": "json",
"csv": "csv",
"xlsx": "xlsx",
"xls": "xlsx"
}
resultType = formatMap.get(tableFormat.lower(), "json")
aiPrompt = "Extract all tables from the provided document(s)."
if includeHeaders:
aiPrompt += " Include table headers and preserve the table structure."
else:
aiPrompt += " Extract table data without headers."
aiPrompt += " Maintain accurate data types (numbers as numbers, dates as dates, etc.) and preserve all table relationships."
if resultType == "json":
aiPrompt += " Structure each table as a JSON object with headers and rows as arrays."
elif resultType == "csv":
aiPrompt += " Output each table as CSV format with proper comma separation."
elif resultType == "xlsx":
aiPrompt += " Structure the output as an Excel spreadsheet with tables properly formatted."
return await self.process({
"aiPrompt": aiPrompt,
"documentList": documentList,
"resultType": resultType
})
# ============================================================================
# Content Generation Wrappers
# Content Generation Wrapper
# ============================================================================
@action
async def generateReport(self, parameters: Dict[str, Any]) -> ActionResult:
"""
GENERAL:
- Purpose: Generate comprehensive reports from input documents/data with analysis and insights.
- Input requirements: documentList (optional, can generate from scratch); optional reportType, sections.
- Output format: Document in specified format (default: docx).
Parameters:
- documentList (list, optional): Input documents/data to base the report on.
- reportType (str, optional): Type of report - summary, analysis, executive, detailed. Default: analysis.
- sections (list, optional): Specific sections to include (e.g., ["introduction", "findings", "recommendations"]).
- title (str, optional): Report title.
- resultType (str, optional): Output format (docx, pdf, md, etc.). Default: docx.
"""
documentList = parameters.get("documentList", [])
reportType = parameters.get("reportType", "analysis")
sections = parameters.get("sections", [])
title = parameters.get("title")
resultType = parameters.get("resultType", "docx")
reportTypeInstructions = {
"summary": "Create a summary report with key highlights and main points.",
"analysis": "Create an analytical report with insights, findings, and detailed examination.",
"executive": "Create an executive summary report suitable for senior management with key insights and recommendations.",
"detailed": "Create a comprehensive detailed report covering all aspects with in-depth analysis."
}
aiPrompt = f"Generate a {reportType} report."
if title:
aiPrompt += f" Title: {title}."
aiPrompt += f" {reportTypeInstructions.get(reportType.lower(), reportTypeInstructions['analysis'])}"
if sections:
sectionsStr = ", ".join(sections)
aiPrompt += f" Include the following sections: {sectionsStr}."
else:
aiPrompt += " Include standard report sections such as introduction, main content, analysis, findings, and conclusions."
if documentList:
aiPrompt += " Base the report on the provided input documents, analyzing and synthesizing the information."
else:
aiPrompt += " Create a professional, well-structured report."
processParams = {
"aiPrompt": aiPrompt,
"resultType": resultType
}
if documentList:
processParams["documentList"] = documentList
return await self.process(processParams)
@action
async def generateChart(self, parameters: Dict[str, Any]) -> ActionResult:
"""
GENERAL:
- Purpose: Generate charts/graphs from data in documents or structured data.
- Input requirements: documentList (required); optional chartType, title, labels.
- Output format: Image (png or jpg).
Parameters:
- documentList (list, required): Documents containing data to visualize (CSV, Excel, JSON, etc.).
- chartType (str, optional): Type of chart - bar, line, pie, scatter, area, etc. Default: bar.
- title (str, optional): Chart title.
- xAxisLabel (str, optional): X-axis label.
- yAxisLabel (str, optional): Y-axis label.
- resultType (str, optional): Image format (png or jpg). Default: png.
"""
documentList = parameters.get("documentList", [])
if not documentList:
return ActionResult.isFailure(error="documentList is required")
chartType = parameters.get("chartType", "bar")
title = parameters.get("title")
xAxisLabel = parameters.get("xAxisLabel")
yAxisLabel = parameters.get("yAxisLabel")
resultType = parameters.get("resultType", "png")
# Ensure resultType is an image format
if resultType.lower() not in ["png", "jpg", "jpeg"]:
resultType = "png"
aiPrompt = f"Generate a {chartType} chart from the provided data."
if title:
aiPrompt += f" Chart title: {title}."
if xAxisLabel:
aiPrompt += f" X-axis label: {xAxisLabel}."
if yAxisLabel:
aiPrompt += f" Y-axis label: {yAxisLabel}."
aiPrompt += " Create a clear, professional chart with appropriate labels, legends, and formatting. Ensure the chart is visually appealing and easy to read."
return await self.process({
"aiPrompt": aiPrompt,
"documentList": documentList,
"resultType": resultType
})
@action
async def generateDocument(self, parameters: Dict[str, Any]) -> ActionResult:
"""
@ -1146,137 +699,3 @@ class MethodAi(MethodBase):
processParams["documentList"] = documentList
return await self.process(processParams)
# ============================================================================
# Analysis & Comparison Wrappers
# ============================================================================
@action
async def analyzeDocuments(self, parameters: Dict[str, Any]) -> ActionResult:
"""
GENERAL:
- Purpose: Analyze documents and find insights, patterns, trends, and key information.
- Input requirements: documentList (required); optional analysisType, focus.
- Output format: Analysis report in specified format (default: txt).
Parameters:
- documentList (list, required): Document(s) to analyze.
- analysisType (str, optional): Type of analysis - general, financial, technical, sentiment, etc. Default: general.
- focus (str, optional): Specific aspect to focus on (e.g., "trends", "risks", "opportunities").
- resultType (str, optional): Output format (txt, md, docx, json, etc.). Default: txt.
"""
documentList = parameters.get("documentList", [])
if not documentList:
return ActionResult.isFailure(error="documentList is required")
analysisType = parameters.get("analysisType", "general")
focus = parameters.get("focus")
resultType = parameters.get("resultType", "txt")
aiPrompt = f"Analyze the provided document(s) and find insights, patterns, and key information."
aiPrompt += f" Perform a {analysisType} analysis."
if focus:
aiPrompt += f" Focus specifically on: {focus}."
aiPrompt += " Identify trends, important findings, relationships, and provide actionable insights. Present the analysis in a clear, structured format."
return await self.process({
"aiPrompt": aiPrompt,
"documentList": documentList,
"resultType": resultType
})
@action
async def compareDocuments(self, parameters: Dict[str, Any]) -> ActionResult:
"""
GENERAL:
- Purpose: Compare multiple documents and identify differences, similarities, and changes.
- Input requirements: documentList (required, should contain 2+ documents); optional comparisonType, focus.
- Output format: Comparison report in specified format (default: txt).
Parameters:
- documentList (list, required): Two or more documents to compare.
- comparisonType (str, optional): Type of comparison - differences, similarities, changes, full. Default: full.
- focus (str, optional): Specific aspect to focus on (e.g., "content", "structure", "data", "formatting").
- resultType (str, optional): Output format (txt, md, docx, json, etc.). Default: txt.
"""
documentList = parameters.get("documentList", [])
if not documentList:
return ActionResult.isFailure(error="documentList is required")
if isinstance(documentList, str):
documentList = [documentList]
if len(documentList) < 2:
return ActionResult.isFailure(error="At least 2 documents are required for comparison")
comparisonType = parameters.get("comparisonType", "full")
focus = parameters.get("focus")
resultType = parameters.get("resultType", "txt")
comparisonInstructions = {
"differences": "Focus on identifying and highlighting all differences between the documents.",
"similarities": "Focus on identifying commonalities, shared content, and similarities.",
"changes": "Identify what has changed between versions, what was added, removed, or modified.",
"full": "Provide a comprehensive comparison including both differences and similarities."
}
aiPrompt = f"Compare the provided documents."
aiPrompt += f" {comparisonInstructions.get(comparisonType.lower(), comparisonInstructions['full'])}"
if focus:
aiPrompt += f" Focus specifically on: {focus}."
aiPrompt += " Present the comparison in a clear, structured format that makes differences and similarities easy to understand."
return await self.process({
"aiPrompt": aiPrompt,
"documentList": documentList,
"resultType": resultType
})
@action
async def validateData(self, parameters: Dict[str, Any]) -> ActionResult:
"""
GENERAL:
- Purpose: Validate data quality, structure, completeness, and correctness in documents/data files.
- Input requirements: documentList (required); optional validationRules, schema.
- Output format: Validation report in JSON or text format (default: json).
Parameters:
- documentList (list, required): Documents/data files to validate.
- validationRules (list, optional): Specific validation rules to check (e.g., ["required_fields", "data_types", "ranges"]).
- schema (dict, optional): Expected data schema/structure to validate against.
- resultType (str, optional): Output format (json, txt, md, etc.). Default: json.
"""
documentList = parameters.get("documentList", [])
if not documentList:
return ActionResult.isFailure(error="documentList is required")
validationRules = parameters.get("validationRules", [])
schema = parameters.get("schema")
resultType = parameters.get("resultType", "json")
aiPrompt = "Validate the data quality, structure, completeness, and correctness in the provided documents."
if validationRules:
rulesStr = ", ".join(validationRules)
aiPrompt += f" Apply the following validation rules: {rulesStr}."
else:
aiPrompt += " Check for data completeness, correct data types, required fields, data consistency, and any anomalies or errors."
if schema:
import json
schemaStr = json.dumps(schema, indent=2)
aiPrompt += f" Validate against the following expected schema: {schemaStr}."
if resultType == "json":
aiPrompt += " Provide the validation results as structured JSON with validation status, errors, warnings, and details for each check."
else:
aiPrompt += " Provide a detailed validation report listing all findings, errors, warnings, and pass/fail status for each validation check."
return await self.process({
"aiPrompt": aiPrompt,
"documentList": documentList,
"resultType": resultType
})

View file

@ -0,0 +1,337 @@
"""
Context and workflow information method module.
Handles workflow context queries and document indexing.
"""
import time
import json
import logging
from typing import Dict, Any, List
from datetime import datetime, UTC
from modules.workflows.methods.methodBase import MethodBase, action
from modules.datamodels.datamodelChat import ActionResult, ActionDocument
from modules.datamodels.datamodelExtraction import ExtractionOptions, MergeStrategy
logger = logging.getLogger(__name__)
class MethodContext(MethodBase):
"""Context and workflow information methods."""
def __init__(self, services):
super().__init__(services)
self.name = "context"
self.description = "Context and workflow information methods"
@action
async def getDocumentIndex(self, parameters: Dict[str, Any]) -> ActionResult:
"""
GENERAL:
- Purpose: Generate a comprehensive index of all documents available in the current workflow, including documents from all rounds and tasks.
- Input requirements: No input documents required. Optional resultType parameter.
- Output format: Structured document index in JSON format (default) or text format, listing all documents with their references, metadata, and organization by rounds/tasks.
Parameters:
- resultType (str, optional): Output format (json, txt, md). Default: json.
"""
try:
workflow = self.services.workflow
if not workflow:
return ActionResult.isFailure(
error="No workflow available"
)
resultType = parameters.get("resultType", "json").lower().strip().lstrip('.')
# Get available documents index from chat service
documentsIndex = self.services.chat.getAvailableDocuments(workflow)
if not documentsIndex or documentsIndex == "No documents available" or documentsIndex == "NO DOCUMENTS AVAILABLE - This workflow has no documents to process.":
# Return empty index structure
if resultType == "json":
indexData = {
"workflowId": getattr(workflow, 'id', 'unknown'),
"totalDocuments": 0,
"rounds": [],
"documentReferences": []
}
indexContent = json.dumps(indexData, indent=2, ensure_ascii=False)
else:
indexContent = "Document Index\n==============\n\nNo documents available in this workflow.\n"
else:
# Parse the document index string to extract structured information
indexData = self._parseDocumentIndex(documentsIndex, workflow)
if resultType == "json":
indexContent = json.dumps(indexData, indent=2, ensure_ascii=False)
elif resultType == "md":
indexContent = self._formatAsMarkdown(indexData)
else: # txt
indexContent = self._formatAsText(indexData, documentsIndex)
# Generate meaningful filename
workflowContext = self.services.chat.getWorkflowContext()
filename = self._generateMeaningfulFileName(
"document_index",
resultType if resultType in ["json", "txt", "md"] else "json",
workflowContext,
"getDocumentIndex"
)
# Create ActionDocument
document = ActionDocument(
documentName=filename,
documentData=indexContent,
mimeType="application/json" if resultType == "json" else "text/plain"
)
return ActionResult.isSuccess(documents=[document])
except Exception as e:
logger.error(f"Error generating document index: {str(e)}")
return ActionResult.isFailure(
error=f"Failed to generate document index: {str(e)}"
)
def _parseDocumentIndex(self, documentsIndex: str, workflow: Any) -> Dict[str, Any]:
"""Parse the document index string into structured data."""
try:
indexData = {
"workflowId": getattr(workflow, 'id', 'unknown'),
"generatedAt": datetime.now(UTC).isoformat(),
"totalDocuments": 0,
"rounds": [],
"documentReferences": []
}
# Extract document references from the index string
lines = documentsIndex.split('\n')
currentRound = None
currentDocList = None
for line in lines:
line = line.strip()
if not line:
continue
# Check for round headers
if "Current round documents:" in line:
currentRound = "current"
continue
elif "Past rounds documents:" in line:
currentRound = "past"
continue
# Check for document list references (docList:...)
if line.startswith("- docList:"):
docListRef = line.replace("- docList:", "").strip()
currentDocList = {
"reference": docListRef,
"round": currentRound,
"documents": []
}
indexData["rounds"].append(currentDocList)
continue
# Check for individual document references (docItem:...)
if line.startswith(" - docItem:") or line.startswith("- docItem:"):
docItemRef = line.replace(" - docItem:", "").replace("- docItem:", "").strip()
indexData["documentReferences"].append({
"reference": docItemRef,
"round": currentRound,
"docList": currentDocList["reference"] if currentDocList else None
})
indexData["totalDocuments"] += 1
if currentDocList:
currentDocList["documents"].append(docItemRef)
return indexData
except Exception as e:
logger.error(f"Error parsing document index: {str(e)}")
return {
"workflowId": getattr(workflow, 'id', 'unknown'),
"error": f"Failed to parse document index: {str(e)}",
"rawIndex": documentsIndex
}
def _formatAsMarkdown(self, indexData: Dict[str, Any]) -> str:
"""Format document index as Markdown."""
try:
md = f"# Document Index\n\n"
md += f"**Workflow ID:** {indexData.get('workflowId', 'unknown')}\n\n"
md += f"**Generated At:** {indexData.get('generatedAt', 'unknown')}\n\n"
md += f"**Total Documents:** {indexData.get('totalDocuments', 0)}\n\n"
if indexData.get('rounds'):
md += "## Documents by Round\n\n"
for roundInfo in indexData['rounds']:
roundLabel = roundInfo.get('round', 'unknown').title()
md += f"### {roundLabel} Round\n\n"
md += f"**Document List:** `{roundInfo.get('reference', 'unknown')}`\n\n"
if roundInfo.get('documents'):
md += "**Documents:**\n\n"
for docRef in roundInfo['documents']:
md += f"- `{docRef}`\n"
md += "\n"
if indexData.get('documentReferences'):
md += "## All Document References\n\n"
for docRef in indexData['documentReferences']:
md += f"- `{docRef.get('reference', 'unknown')}`\n"
return md
except Exception as e:
logger.error(f"Error formatting as Markdown: {str(e)}")
return f"# Document Index\n\nError formatting index: {str(e)}\n"
def _formatAsText(self, indexData: Dict[str, Any], rawIndex: str) -> str:
"""Format document index as plain text."""
try:
text = "Document Index\n"
text += "=" * 50 + "\n\n"
text += f"Workflow ID: {indexData.get('workflowId', 'unknown')}\n"
text += f"Generated At: {indexData.get('generatedAt', 'unknown')}\n"
text += f"Total Documents: {indexData.get('totalDocuments', 0)}\n\n"
# Include the raw formatted index for readability
text += rawIndex
return text
except Exception as e:
logger.error(f"Error formatting as text: {str(e)}")
return f"Document Index\n\nError formatting index: {str(e)}\n\nRaw index:\n{rawIndex}\n"
@action
async def extractContent(self, parameters: Dict[str, Any]) -> ActionResult:
"""
Extract content from documents (separate from AI calls).
This action performs pure content extraction without AI processing.
The extracted ContentParts can then be used by subsequent AI processing actions.
Parameters:
- documentList (list, required): Document reference(s) to extract content from.
- extractionOptions (dict, optional): Extraction options (if not provided, defaults are used).
Returns:
- ActionResult with ActionDocument containing ContentExtracted objects
- ContentExtracted.parts contains List[ContentPart] (already chunked if needed)
"""
try:
# Init progress logger
workflowId = self.services.workflow.id if self.services.workflow else f"no-workflow-{int(time.time())}"
operationId = f"context_extract_{workflowId}_{int(time.time())}"
# Extract documentList from parameters dict
from modules.datamodels.datamodelDocref import DocumentReferenceList
documentListParam = parameters.get("documentList")
if not documentListParam:
return ActionResult.isFailure(error="documentList is required")
# Convert to DocumentReferenceList if needed
if isinstance(documentListParam, DocumentReferenceList):
documentList = documentListParam
elif isinstance(documentListParam, str):
documentList = DocumentReferenceList.from_string_list([documentListParam])
elif isinstance(documentListParam, list):
documentList = DocumentReferenceList.from_string_list(documentListParam)
else:
return ActionResult.isFailure(error=f"Invalid documentList type: {type(documentListParam)}")
# Start progress tracking
self.services.chat.progressLogStart(
operationId,
"Extracting content from documents",
"Content Extraction",
f"Documents: {len(documentList.references)}"
)
# Get ChatDocuments from documentList
self.services.chat.progressLogUpdate(operationId, 0.2, "Loading documents")
chatDocuments = self.services.chat.getChatDocumentsFromDocumentList(documentList)
if not chatDocuments:
self.services.chat.progressLogFinish(operationId, False)
return ActionResult.isFailure(error="No documents found in documentList")
logger.info(f"Extracting content from {len(chatDocuments)} documents")
# Prepare extraction options
self.services.chat.progressLogUpdate(operationId, 0.3, "Preparing extraction options")
extractionOptionsParam = parameters.get("extractionOptions")
# Convert dict to ExtractionOptions object if needed, or create defaults
if extractionOptionsParam:
if isinstance(extractionOptionsParam, dict):
# Convert dict to ExtractionOptions object
extractionOptions = ExtractionOptions(**extractionOptionsParam)
elif isinstance(extractionOptionsParam, ExtractionOptions):
extractionOptions = extractionOptionsParam
else:
# Invalid type, use defaults
extractionOptions = None
else:
extractionOptions = None
# If extractionOptions not provided, create defaults
if not extractionOptions:
# Default extraction options for pure content extraction (no AI processing)
extractionOptions = ExtractionOptions(
prompt="Extract all content from the document",
mergeStrategy=MergeStrategy(
mergeType="concatenate",
groupBy="typeGroup",
orderBy="id"
),
processDocumentsIndividually=True
)
# Get parent log ID for document-level operations
parentLogId = self.services.chat.getOperationLogId(operationId)
# Call extraction service
self.services.chat.progressLogUpdate(operationId, 0.4, "Initiating")
self.services.chat.progressLogUpdate(operationId, 0.5, f"Extracting content from {len(chatDocuments)} documents")
extractedResults = self.services.extraction.extractContent(chatDocuments, extractionOptions)
# Build ActionDocuments from ContentExtracted results
self.services.chat.progressLogUpdate(operationId, 0.8, "Building result documents")
actionDocuments = []
# Map extracted results back to original documents by index (results are in same order)
for i, extracted in enumerate(extractedResults):
# Get original document name if available
originalDoc = chatDocuments[i] if i < len(chatDocuments) else None
if originalDoc and hasattr(originalDoc, 'fileName') and originalDoc.fileName:
# Use original filename with "extracted_" prefix
baseName = originalDoc.fileName.rsplit('.', 1)[0] if '.' in originalDoc.fileName else originalDoc.fileName
documentName = f"{baseName}_extracted_{extracted.id}.json"
else:
# Fallback to generic name with index
documentName = f"document_{i+1:03d}_extracted_{extracted.id}.json"
# Store ContentExtracted object in ActionDocument.documentData
actionDoc = ActionDocument(
documentName=documentName,
documentData=extracted, # ContentExtracted object
mimeType="application/json"
)
actionDocuments.append(actionDoc)
self.services.chat.progressLogFinish(operationId, True)
return ActionResult.isSuccess(documents=actionDocuments)
except Exception as e:
logger.error(f"Error in content extraction: {str(e)}")
# Complete progress tracking with failure
try:
self.services.chat.progressLogFinish(operationId, False)
except:
pass # Don't fail on progress logging errors
return ActionResult.isFailure(error=str(e))

View file

@ -210,8 +210,14 @@ class MessageCreator:
taskProgress = str(taskIndex)
# Enhanced completion message with criteria details
if reviewResult and hasattr(reviewResult, 'reason'):
completionMessage = f"🎯 **Task {taskProgress}**\n\n{reviewResult.reason or 'Task completed successfully'}"
# Prefer userMessage (user-friendly in user's language), fallback to reason
if reviewResult:
if hasattr(reviewResult, 'userMessage') and reviewResult.userMessage:
completionMessage = f"🎯 **Task {taskProgress}**\n\n{reviewResult.userMessage}"
elif hasattr(reviewResult, 'reason') and reviewResult.reason:
completionMessage = f"🎯 **Task {taskProgress}**\n\n{reviewResult.reason}"
else:
completionMessage = f"🎯 **Task {taskProgress}**\n\n✅ Task completed successfully"
else:
completionMessage = f"🎯 **Task {taskProgress}**\n\n✅ Task completed successfully"

View file

@ -350,34 +350,36 @@ Return ONLY JSON (no markdown, no explanations). The decision MUST:
- Match parameter names exactly as defined in AVAILABLE_METHODS
{{
"status": "continue",
"reason": "Brief reason explaining why continuing",
"nextAction": "Selected_action_from_ACTIONS",
"status": "continue" | "success",
"reason": "Brief reason explaining why continuing or why task is complete",
"userMessage": "User-friendly message in language '{{KEY:USER_LANGUAGE}}' explaining the task status (1 sentence, first person, friendly tone)",
"nextAction": "Selected_action_from_ACTIONS" | null,
"nextActionParameters": {{
"documentList": ["docItem:<documentId>:<filename>", "docList:<label>"],
"connectionReference": "connection:reference_from_AVAILABLE_CONNECTIONS_INDEX",
"parameter1": "value1",
"parameter2": "value2"
}},
"nextActionObjective": "Clear description of what this action will achieve based on improvement suggestions"
}} | null,
"nextActionObjective": "Clear description of what this action will achieve based on improvement suggestions" | null
}}
=== RULES ===
1. Return ONLY JSON - no markdown, no explanations
2. If "continue": MUST provide nextAction and nextActionParameters
3. nextAction: SPECIFIC action from AVAILABLE_METHODS (do not invent)
4. nextActionParameters: concrete parameters (check AVAILABLE_METHODS for valid names)
5. documentList: ONLY exact references from AVAILABLE_DOCUMENTS_INDEX (do not invent/modify)
2. userMessage: REQUIRED - Provide a user-friendly message in language '{{KEY:USER_LANGUAGE}}' explaining the task status (for "continue": explain what's being done next; for "success": explain what was accomplished)
3. If "continue": MUST provide nextAction and nextActionParameters
4. nextAction: SPECIFIC action from AVAILABLE_METHODS (do not invent)
5. nextActionParameters: concrete parameters (check AVAILABLE_METHODS for valid names)
6. documentList: ONLY exact references from AVAILABLE_DOCUMENTS_INDEX (do not invent/modify)
- For individual documents: ALWAYS use docItem:<documentId>:<filename> format (include filename)
- For document lists: use docList:<label> format
- Copy references EXACTLY as shown in AVAILABLE_DOCUMENTS_INDEX (including filename)
6. connectionReference: ONLY exact label from AVAILABLE_CONNECTIONS_INDEX (required if action needs connection)
7. nextActionObjective: describe what this action will achieve based on the FIRST improvement suggestion from CONTENT VALIDATION
8. CRITICAL: Use structureComparison.gap to specify the missing part in nextActionParameters
9. Do NOT repeat failed actions - suggest DIFFERENT approach
10. If ACTION HISTORY shows repeated actions, suggest a fundamentally different approach
11. nextActionObjective must directly address the highest priority improvement suggestion from CONTENT VALIDATION
12. If validation shows partial data delivered, next action should CONTINUE from where it stopped, not restart
7. connectionReference: ONLY exact label from AVAILABLE_CONNECTIONS_INDEX (required if action needs connection)
8. nextActionObjective: describe what this action will achieve based on the FIRST improvement suggestion from CONTENT VALIDATION
9. CRITICAL: Use structureComparison.gap to specify the missing part in nextActionParameters
10. Do NOT repeat failed actions - suggest DIFFERENT approach
11. If ACTION HISTORY shows repeated actions, suggest a fundamentally different approach
12. nextActionObjective must directly address the highest priority improvement suggestion from CONTENT VALIDATION
13. If validation shows partial data delivered, next action should CONTINUE from where it stopped, not restart
"""

View file

@ -18,7 +18,14 @@ logger = logging.getLogger(__name__)
def generateTaskPlanningPrompt(services, context: Any) -> PromptBundle:
"""Define placeholders first, then the template; return PromptBundle."""
# Extract user language from services
userLanguage = getattr(services, 'currentUserLanguage', None) or 'en'
# Prefer currentUserLanguage (set from user intention analysis), fallback to user.language, then 'en'
userLanguage = getattr(services, 'currentUserLanguage', None)
if not userLanguage:
userLanguage = getattr(services.user, 'language', None) if hasattr(services, 'user') and services.user else None
if not userLanguage:
userLanguage = 'en'
logger.debug(f"Task planning prompt using user language: {userLanguage}")
# Extract workflowIntent from workflow object if available
workflowIntent = {}

View file

@ -55,10 +55,14 @@ class WorkflowProcessor:
f"Mode: {workflow.workflowMode.value if hasattr(workflow.workflowMode, 'value') else workflow.workflowMode}"
)
# Initialize currentUserLanguage to empty at workflow start
self.services.currentUserLanguage = ""
# currentUserLanguage should already be set from user intention analysis in _sendFirstMessage
# Do NOT reset it here, as it contains the detected language from the user's input
# Only initialize if not already set (should not happen in normal flow)
if not hasattr(self.services, 'currentUserLanguage') or not self.services.currentUserLanguage:
self.services.currentUserLanguage = getattr(self.services.user, 'language', None) or 'en'
logger.info(f"=== STARTING TASK PLAN GENERATION ===")
logger.info(f"Using user language: {self.services.currentUserLanguage}")
logger.info(f"Workflow ID: {workflow.id}")
logger.info(f"User Input: {userInput}")
modeValue = workflow.workflowMode.value if hasattr(workflow.workflowMode, 'value') else workflow.workflowMode

View file

@ -186,11 +186,18 @@ class WorkflowManager:
# Now send the first message (which will also process the documents again, but that's fine)
await self._sendFirstMessage(userInput)
# Route to fast path for simple requests (skip for automation mode)
if not skipComplexityDetection and complexity == "simple":
# Check if workflow history is needed before deciding on fast path
# Use AI intention analysis result only (no keyword matching)
needsHistory = getattr(self.services, '_needsWorkflowHistory', False)
hasHistory = self._checkIfHistoryAvailable()
# Route to fast path for simple requests (skip for automation mode and if history is needed)
if not skipComplexityDetection and complexity == "simple" and not (needsHistory and hasHistory):
logger.info("Routing to fast path for simple request")
await self._executeFastPath(userInput, documents)
return # Fast path completes the workflow
elif needsHistory and hasHistory:
logger.info(f"Skipping fast path - workflow history is needed (from AI intention analysis) and available")
# Route to full workflow for moderate/complex requests or automation mode
logger.info(f"Routing to full workflow for {complexity} request" + (" (automation mode)" if skipComplexityDetection else ""))
@ -369,7 +376,8 @@ class WorkflowManager:
"6) dataType: What type of data/content they want (numbers|text|documents|analysis|code|unknown).\n"
"7) expectedFormats: What file format(s) they expect - provide matching file format extensions list (e.g., [\"xlsx\", \"pdf\"]). If format is unclear or not specified, use empty list [].\n"
"8) qualityRequirements: Quality requirements they have (accuracy, completeness) as {accuracyThreshold: 0.0-1.0, completenessThreshold: 0.0-1.0}.\n"
"9) successCriteria: Specific success criteria that define completion (array of strings).\n\n"
"9) successCriteria: Specific success criteria that define completion (array of strings).\n"
"10) needsWorkflowHistory: Boolean indicating if this request needs previous workflow rounds/history to be understood or completed (e.g., 'continue', 'retry', 'fix', 'improve', 'update', 'modify', 'based on previous', 'build on', references to earlier work). Return true if the request is a continuation, retry, modification, or builds upon previous work.\n\n"
"Rules:\n"
"- If total content (intent + data) is < 10% of model max tokens, do not extract; return empty contextItems and keep intent compact and self-contained.\n"
"- If content exceeds that threshold, move bulky parts into contextItems; keep intent short and clear.\n"
@ -394,7 +402,8 @@ class WorkflowManager:
" \"accuracyThreshold\": 0.0-1.0,\n"
" \"completenessThreshold\": 0.0-1.0\n"
" },\n"
" \"successCriteria\": [\"specific criterion 1\", \"specific criterion 2\"]\n"
" \"successCriteria\": [\"specific criterion 1\", \"specific criterion 2\"],\n"
" \"needsWorkflowHistory\": true|false\n"
"}\n\n"
f"User message:\n{self.services.utils.sanitizePromptContent(userInput.prompt, 'userinput')}"
)
@ -431,9 +440,15 @@ class WorkflowManager:
'expectedFormats': parsed.get('expectedFormats', []),
'qualityRequirements': parsed.get('qualityRequirements', {}),
'successCriteria': parsed.get('successCriteria', []),
'languageUserDetected': detectedLanguage
'languageUserDetected': detectedLanguage,
'needsWorkflowHistory': parsed.get('needsWorkflowHistory', False)
}
# Store needsWorkflowHistory in services for fast path decision
needsHistoryFromIntention = parsed.get('needsWorkflowHistory', False)
if isinstance(needsHistoryFromIntention, bool):
setattr(self.services, '_needsWorkflowHistory', needsHistoryFromIntention)
# Store workflowIntent in workflow object for reuse
if hasattr(self.services, 'workflow') and self.services.workflow:
self.services.workflow._workflowIntent = workflowIntent
@ -1089,3 +1104,22 @@ class WorkflowManager:
logger.error(f"Error during content neutralization: {str(e)}")
# Return original content on error
return contentBytes
def _checkIfHistoryAvailable(self) -> bool:
"""Check if workflow history is available (previous rounds exist).
Returns True if there are previous workflow rounds with messages.
"""
try:
from modules.workflows.processing.shared.placeholderFactory import getPreviousRoundContext
history = getPreviousRoundContext(self.services)
# Check if history contains actual content (not just "No previous round context available")
if history and history != "No previous round context available":
return True
return False
except Exception as e:
logger.error(f"Error checking if history is available: {str(e)}")
return False