fixes for chat workflow

This commit is contained in:
ValueOn AG 2025-12-03 23:02:33 +01:00
parent 6d393f9cf3
commit a48bf06778
31 changed files with 963 additions and 1626 deletions

49
how --stat HEAD Normal file
View file

@ -0,0 +1,49 @@
M app.py
A modules/.$DEPENDENCY_DIAGRAM.drawio.bkp
A modules/AUTOMATION_FEATURE_ANALYSIS.md
A modules/BIDIRECTIONAL_IMPORTS.md
A modules/DEPENDENCY_DIAGRAM.drawio
A modules/FEATURES_TO_INTERFACES_IMPORTS.md
M modules/connectors/connectorVoiceGoogle.py
M modules/datamodels/datamodelChat.py
M modules/datamodels/datamodelPagination.py
A modules/features/automation/__init__.py
A modules/features/automation/mainAutomation.py
A modules/features/automation/subAutomationUtils.py
D modules/features/chatAlthaus/COMPONENT_DIAGRAM.md
M modules/features/featuresLifecycle.py
M modules/interfaces/interfaceAiObjects.py
M modules/interfaces/interfaceDbAppObjects.py
M modules/interfaces/interfaceDbChatObjects.py
M modules/interfaces/interfaceDbComponentObjects.py
M modules/interfaces/interfaceVoiceObjects.py
M modules/routes/routeAdminAutomationEvents.py
M modules/routes/routeVoiceGoogle.py
M modules/services/__init__.py
M modules/services/serviceAi/mainServiceAi.py
M modules/services/serviceAi/subJsonResponseHandling.py
M modules/services/serviceChat/mainServiceChat.py
M modules/services/serviceExtraction/mainServiceExtraction.py
M modules/services/serviceExtraction/subPipeline.py
M modules/services/serviceExtraction/subPromptBuilderExtraction.py
M modules/services/serviceGeneration/renderers/rendererXlsx.py
M modules/services/serviceGeneration/subPromptBuilderGeneration.py
A modules/services/serviceSecurity/mainServiceSecurity.py
M modules/services/serviceSharepoint/mainServiceSharepoint.py
M modules/services/serviceUtils/mainServiceUtils.py
A modules/shared/callbackRegistry.py
M modules/shared/debugLogger.py
M modules/shared/jsonUtils.py
M modules/workflows/methods/methodAi.py
M modules/workflows/methods/methodBase.py
A modules/workflows/methods/methodContext.py
M modules/workflows/methods/methodOutlook.py
M modules/workflows/methods/methodSharepoint.py
M modules/workflows/processing/adaptive/contentValidator.py
M modules/workflows/processing/core/messageCreator.py
M modules/workflows/processing/modes/modeAutomation.py
M modules/workflows/processing/modes/modeDynamic.py
M modules/workflows/processing/shared/promptGenerationActionsDynamic.py
M modules/workflows/processing/shared/promptGenerationTaskplan.py
M modules/workflows/processing/workflowProcessor.py
M modules/workflows/workflowManager.py

View file

@ -1,326 +0,0 @@
<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

@ -1,407 +0,0 @@
# 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

@ -1,406 +0,0 @@
# 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

@ -1 +0,0 @@
<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

@ -1,199 +0,0 @@
# 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

@ -62,7 +62,7 @@ class ChatLog(BaseModel):
None, description="Performance metrics" None, description="Performance metrics"
) )
parentId: Optional[str] = Field( parentId: Optional[str] = Field(
None, description="Parent log entry ID for hierarchical display" None, description="Parent operation ID (operationId of parent operation) for hierarchical display"
) )
operationId: Optional[str] = Field( operationId: Optional[str] = Field(
None, description="Operation ID to group related log entries" None, description="Operation ID to group related log entries"
@ -828,6 +828,7 @@ class TaskContext(BaseModel):
failurePatterns: Optional[list[str]] = Field(default_factory=list) failurePatterns: Optional[list[str]] = Field(default_factory=list)
failedActions: Optional[list] = Field(default_factory=list) failedActions: Optional[list] = Field(default_factory=list)
successfulActions: Optional[list] = Field(default_factory=list) successfulActions: Optional[list] = Field(default_factory=list)
executedActions: Optional[list] = Field(default_factory=list, description="List of executed actions with action name, parameters, and step number")
criteriaProgress: Optional[dict] = None criteriaProgress: Optional[dict] = None
# Stage 2 context fields (NEW) # Stage 2 context fields (NEW)

View file

@ -570,7 +570,7 @@ class ChatObjects:
allWorkflows = self.db.getRecordset(ChatWorkflow) allWorkflows = self.db.getRecordset(ChatWorkflow)
filteredWorkflows = self._uam(ChatWorkflow, allWorkflows) filteredWorkflows = self._uam(ChatWorkflow, allWorkflows)
# If no pagination requested, return all items # If no pagination requested, return all items (no sorting - frontend handles it)
if pagination is None: if pagination is None:
return filteredWorkflows return filteredWorkflows
@ -578,7 +578,7 @@ class ChatObjects:
if pagination.filters: if pagination.filters:
filteredWorkflows = self._applyFilters(filteredWorkflows, pagination.filters) filteredWorkflows = self._applyFilters(filteredWorkflows, pagination.filters)
# Apply sorting (in order of sortFields) # Apply sorting (in order of sortFields) - only if provided by frontend
if pagination.sort: if pagination.sort:
filteredWorkflows = self._applySorting(filteredWorkflows, pagination.sort) filteredWorkflows = self._applySorting(filteredWorkflows, pagination.sort)

View file

@ -205,10 +205,8 @@ Respond with ONLY a JSON object in this exact format:
documentMetadata = None # Store document metadata (title, filename) from first iteration documentMetadata = None # Store document metadata (title, filename) from first iteration
accumulationState = None # Track accumulation state for string accumulation accumulationState = None # Track accumulation state for string accumulation
# Get parent log ID for iteration operations # Get parent operation ID for iteration operations (parentId should be operationId, not log entry ID)
parentLogId = None parentOperationId = operationId # Use the parent's operationId directly
if operationId:
parentLogId = self.services.chat.getOperationLogId(operationId)
while iteration < maxIterations: while iteration < maxIterations:
iteration += 1 iteration += 1
@ -222,7 +220,7 @@ Respond with ONLY a JSON object in this exact format:
"AI Call", "AI Call",
f"Iteration {iteration}", f"Iteration {iteration}",
"", "",
parentId=parentLogId parentOperationId=parentOperationId
) )
# Build iteration prompt # Build iteration prompt
@ -235,11 +233,14 @@ Respond with ONLY a JSON object in this exact format:
logger.warning(f"Iteration {iteration}: No previous response available for continuation!") logger.warning(f"Iteration {iteration}: No previous response available for continuation!")
# Filter promptArgs to only include parameters that buildGenerationPrompt accepts # Filter promptArgs to only include parameters that buildGenerationPrompt accepts
# buildGenerationPrompt accepts: outputFormat, userPrompt, title, extracted_content, continuationContext # buildGenerationPrompt accepts: outputFormat, userPrompt, title, extracted_content, continuationContext, services
filteredPromptArgs = { filteredPromptArgs = {
k: v for k, v in promptArgs.items() k: v for k, v in promptArgs.items()
if k in ['outputFormat', 'userPrompt', 'title', 'extracted_content'] if k in ['outputFormat', 'userPrompt', 'title', 'extracted_content', 'services']
} }
# Always include services if available
if not filteredPromptArgs.get('services') and hasattr(self, 'services'):
filteredPromptArgs['services'] = self.services
# Rebuild prompt with continuation context using the provided prompt builder # Rebuild prompt with continuation context using the provided prompt builder
iterationPrompt = await promptBuilder(**filteredPromptArgs, continuationContext=continuationContext) iterationPrompt = await promptBuilder(**filteredPromptArgs, continuationContext=continuationContext)
@ -266,9 +267,20 @@ Respond with ONLY a JSON object in this exact format:
response = await self.callAi(request) response = await self.callAi(request)
result = response.content result = response.content
# Update progress after AI call # Track bytes for progress reporting
bytesReceived = len(result.encode('utf-8')) if result else 0
totalBytesSoFar = sum(len(section.get('content', '').encode('utf-8')) if isinstance(section.get('content'), str) else 0 for section in allSections) + bytesReceived
# Update progress after AI call with byte information
if iterationOperationId: if iterationOperationId:
self.services.chat.progressLogUpdate(iterationOperationId, 0.6, "AI response received") # Format bytes for display (kB or MB)
if totalBytesSoFar < 1024:
bytesDisplay = f"{totalBytesSoFar}B"
elif totalBytesSoFar < 1024 * 1024:
bytesDisplay = f"{totalBytesSoFar / 1024:.1f}kB"
else:
bytesDisplay = f"{totalBytesSoFar / (1024 * 1024):.1f}MB"
self.services.chat.progressLogUpdate(iterationOperationId, 0.6, f"AI response received ({bytesDisplay})")
# Write raw AI response to debug file # Write raw AI response to debug file
if iteration == 1: if iteration == 1:
@ -469,8 +481,24 @@ Respond with ONLY a JSON object in this exact format:
# The break can occur anywhere - in any section, at any depth # The break can occur anywhere - in any section, at any depth
allSections = JsonResponseHandler.mergeSectionsIntelligently(allSections, extractedSections, iteration) allSections = JsonResponseHandler.mergeSectionsIntelligently(allSections, extractedSections, iteration)
# Log merged sections for debugging # Calculate total bytes in merged content for progress display
merged_json_str = json.dumps(allSections, indent=2, ensure_ascii=False) merged_json_str = json.dumps(allSections, indent=2, ensure_ascii=False)
totalBytesGenerated = len(merged_json_str.encode('utf-8'))
# Update main operation with byte progress
if operationId:
# Format bytes for display
if totalBytesGenerated < 1024:
bytesDisplay = f"{totalBytesGenerated}B"
elif totalBytesGenerated < 1024 * 1024:
bytesDisplay = f"{totalBytesGenerated / 1024:.1f}kB"
else:
bytesDisplay = f"{totalBytesGenerated / (1024 * 1024):.1f}MB"
# Estimate progress based on iterations (rough estimate)
estimatedProgress = min(0.9, 0.4 + (iteration * 0.1))
self.services.chat.progressLogUpdate(operationId, estimatedProgress, f"Pipeline: {bytesDisplay} (iteration {iteration})")
# Log merged sections for debugging
self.services.utils.writeDebugFile(merged_json_str, f"{debugPrefix}_merged_sections_iteration_{iteration}") self.services.utils.writeDebugFile(merged_json_str, f"{debugPrefix}_merged_sections_iteration_{iteration}")
# Check if we should continue (completion detection) # Check if we should continue (completion detection)
@ -485,14 +513,40 @@ Respond with ONLY a JSON object in this exact format:
if shouldContinue: if shouldContinue:
# Finish iteration operation (will continue with next iteration) # Finish iteration operation (will continue with next iteration)
if iterationOperationId: if iterationOperationId:
# Show byte progress in iteration completion
iterBytes = len(result.encode('utf-8')) if result else 0
if iterBytes < 1024:
iterBytesDisplay = f"{iterBytes}B"
elif iterBytes < 1024 * 1024:
iterBytesDisplay = f"{iterBytes / 1024:.1f}kB"
else:
iterBytesDisplay = f"{iterBytes / (1024 * 1024):.1f}MB"
self.services.chat.progressLogUpdate(iterationOperationId, 0.95, f"Completed ({iterBytesDisplay})")
self.services.chat.progressLogFinish(iterationOperationId, True) self.services.chat.progressLogFinish(iterationOperationId, True)
continue continue
else: else:
# Done - finish iteration and update main operation # Done - finish iteration and update main operation
if iterationOperationId: if iterationOperationId:
# Show final byte count
finalBytes = len(merged_json_str.encode('utf-8'))
if finalBytes < 1024:
finalBytesDisplay = f"{finalBytes}B"
elif finalBytes < 1024 * 1024:
finalBytesDisplay = f"{finalBytes / 1024:.1f}kB"
else:
finalBytesDisplay = f"{finalBytes / (1024 * 1024):.1f}MB"
self.services.chat.progressLogUpdate(iterationOperationId, 0.95, f"Complete ({finalBytesDisplay})")
self.services.chat.progressLogFinish(iterationOperationId, True) self.services.chat.progressLogFinish(iterationOperationId, True)
if operationId: if operationId:
self.services.chat.progressLogUpdate(operationId, 0.95, f"Generation complete ({iteration} iterations, {len(allSections)} sections)") # Show final size in main operation
finalBytes = len(merged_json_str.encode('utf-8'))
if finalBytes < 1024:
finalBytesDisplay = f"{finalBytes}B"
elif finalBytes < 1024 * 1024:
finalBytesDisplay = f"{finalBytes / 1024:.1f}kB"
else:
finalBytesDisplay = f"{finalBytes / (1024 * 1024):.1f}MB"
self.services.chat.progressLogUpdate(operationId, 0.95, f"Generation complete: {finalBytesDisplay} ({iteration} iterations, {len(allSections)} sections)")
logger.info(f"Generation complete after {iteration} iterations: {len(allSections)} sections") logger.info(f"Generation complete after {iteration} iterations: {len(allSections)} sections")
break break
@ -907,7 +961,7 @@ If no trackable items can be identified, return: {{"kpis": []}}
# Debug: persist prompt/response for analysis with context-specific naming # Debug: persist prompt/response for analysis with context-specific naming
debugPrefix = debugType if debugType else "plan" debugPrefix = debugType if debugType else "plan"
self.services.utils.writeDebugFile(fullPrompt, f"{debugPrefix}_prompt") self.services.utils.writeDebugFile(fullPrompt, f"{debugPrefix}_prompt")
response = await self.aiObjects.call(request) response = await self.aiObjects.callWithTextContext(request)
result = response.content or "" result = response.content or ""
self.services.utils.writeDebugFile(result, f"{debugPrefix}_response") self.services.utils.writeDebugFile(result, f"{debugPrefix}_response")
return result return result
@ -941,10 +995,8 @@ If no trackable items can be identified, return: {{"kpis": []}}
workflowId = self.services.workflow.id if self.services.workflow else f"no-workflow-{int(time.time())}" workflowId = self.services.workflow.id if self.services.workflow else f"no-workflow-{int(time.time())}"
aiOperationId = f"ai_content_{workflowId}_{int(time.time())}" aiOperationId = f"ai_content_{workflowId}_{int(time.time())}"
# Get parent log ID if parent operation exists # Use parent operation ID directly (parentId should be operationId, not log entry ID)
parentLogId = None # parentOperationId is already the operationId of the parent
if parentOperationId:
parentLogId = self.services.chat.getOperationLogId(parentOperationId)
# Start progress tracking with parent reference # Start progress tracking with parent reference
self.services.chat.progressLogStart( self.services.chat.progressLogStart(
@ -952,7 +1004,7 @@ If no trackable items can be identified, return: {{"kpis": []}}
"AI content processing", "AI content processing",
"Content Processing", "Content Processing",
f"Format: {outputFormat or 'text'}", f"Format: {outputFormat or 'text'}",
parentId=parentLogId parentOperationId=parentOperationId
) )
try: try:
@ -1122,7 +1174,7 @@ If no trackable items can be identified, return: {{"kpis": []}}
self.services.utils.writeDebugFile(extractionPrompt, "content_extraction_prompt") self.services.utils.writeDebugFile(extractionPrompt, "content_extraction_prompt")
# Call generic content parts processor - handles images, text, chunking, merging # Call generic content parts processor - handles images, text, chunking, merging
extractionResponse = await self.aiObjects.call(extractionRequest) extractionResponse = await self.callAi(extractionRequest)
# Write debug file for extraction response # Write debug file for extraction response
if extractionResponse.content: if extractionResponse.content:
@ -1153,14 +1205,15 @@ If no trackable items can be identified, return: {{"kpis": []}}
from modules.services.serviceGeneration.subPromptBuilderGeneration import buildGenerationPrompt from modules.services.serviceGeneration.subPromptBuilderGeneration import buildGenerationPrompt
generation_prompt = await buildGenerationPrompt( generation_prompt = await buildGenerationPrompt(
outputFormat, prompt, title, content_for_generation, None outputFormat, prompt, title, content_for_generation, None, self.services
) )
promptArgs = { promptArgs = {
"outputFormat": outputFormat, "outputFormat": outputFormat,
"userPrompt": prompt, "userPrompt": prompt,
"title": title, "title": title,
"extracted_content": content_for_generation "extracted_content": content_for_generation,
"services": self.services
} }
self.services.chat.progressLogUpdate(aiOperationId, 0.4, "Calling AI for content generation") self.services.chat.progressLogUpdate(aiOperationId, 0.4, "Calling AI for content generation")
@ -1169,6 +1222,7 @@ If no trackable items can be identified, return: {{"kpis": []}}
if promptArgs: if promptArgs:
userPrompt = promptArgs.get("userPrompt") or promptArgs.get("user_prompt") userPrompt = promptArgs.get("userPrompt") or promptArgs.get("user_prompt")
# Track generation progress - the looping function will update with byte progress
generated_json = await self._callAiWithLooping( generated_json = await self._callAiWithLooping(
generation_prompt, generation_prompt,
options, options,
@ -1179,7 +1233,16 @@ If no trackable items can be identified, return: {{"kpis": []}}
userPrompt=userPrompt userPrompt=userPrompt
) )
self.services.chat.progressLogUpdate(aiOperationId, 0.7, "Parsing generated JSON") # Calculate final size for completion message
finalSize = len(generated_json.encode('utf-8')) if generated_json else 0
if finalSize < 1024:
finalSizeDisplay = f"{finalSize}B"
elif finalSize < 1024 * 1024:
finalSizeDisplay = f"{finalSize / 1024:.1f}kB"
else:
finalSizeDisplay = f"{finalSize / (1024 * 1024):.1f}MB"
self.services.chat.progressLogUpdate(aiOperationId, 0.7, f"Parsing generated JSON ({finalSizeDisplay})")
try: try:
extracted_json = self.services.utils.jsonExtractString(generated_json) extracted_json = self.services.utils.jsonExtractString(generated_json)
generated_data = json.loads(extracted_json) generated_data = json.loads(extracted_json)
@ -1210,13 +1273,13 @@ If no trackable items can be identified, return: {{"kpis": []}}
# Create separate operation for content rendering # Create separate operation for content rendering
renderOperationId = f"{aiOperationId}_render" renderOperationId = f"{aiOperationId}_render"
renderParentLogId = self.services.chat.getOperationLogId(aiOperationId) # Use aiOperationId directly as parentOperationId (operationId, not log entry ID)
self.services.chat.progressLogStart( self.services.chat.progressLogStart(
renderOperationId, renderOperationId,
"Content Rendering", "Content Rendering",
"Rendering", "Rendering",
f"Format: {outputFormat}", f"Format: {outputFormat}",
parentId=renderParentLogId parentOperationId=aiOperationId
) )
try: try:

View file

@ -1015,10 +1015,19 @@ class ChatService:
def createProgressLogger(self) -> ProgressLogger: def createProgressLogger(self) -> ProgressLogger:
return ProgressLogger(self.services) return ProgressLogger(self.services)
def progressLogStart(self, operationId: str, serviceName: str, actionName: str, context: str = "", parentId: Optional[str] = None): def progressLogStart(self, operationId: str, serviceName: str, actionName: str, context: str = "", parentOperationId: Optional[str] = None):
"""Wrapper for ProgressLogger.startOperation""" """Wrapper for ProgressLogger.startOperation
Args:
operationId: Unique identifier for the operation
serviceName: Name of the service
actionName: Name of the action
context: Additional context information
parentOperationId: Optional parent operation ID (operationId of parent operation)
The parentId in ChatLog will be set to this parentOperationId
"""
progressLogger = self._getProgressLogger() progressLogger = self._getProgressLogger()
return progressLogger.startOperation(operationId, serviceName, actionName, context, parentId) return progressLogger.startOperation(operationId, serviceName, actionName, context, parentOperationId)
def progressLogUpdate(self, operationId: str, progress: float, statusUpdate: str = ""): def progressLogUpdate(self, operationId: str, progress: float, statusUpdate: str = ""):
"""Wrapper for ProgressLogger.updateOperation""" """Wrapper for ProgressLogger.updateOperation"""

View file

@ -34,13 +34,21 @@ class ExtractionService:
if model is None or model.calculatePriceUsd is None: if model is None or model.calculatePriceUsd is None:
raise RuntimeError(f"FATAL: Required internal model '{modelDisplayName}' is not available. Check connector registration.") raise RuntimeError(f"FATAL: Required internal model '{modelDisplayName}' is not available. Check connector registration.")
def extractContent(self, documents: List[ChatDocument], options: ExtractionOptions) -> List[ContentExtracted]: def extractContent(
self,
documents: List[ChatDocument],
options: ExtractionOptions,
operationId: Optional[str] = None,
parentOperationId: Optional[str] = None
) -> List[ContentExtracted]:
""" """
Extract content from a list of ChatDocument objects. Extract content from a list of ChatDocument objects.
Args: Args:
documents: List of ChatDocument objects to extract content from documents: List of ChatDocument objects to extract content from
options: Extraction options including maxSize, chunkAllowed, mergeStrategy, etc. options: Extraction options including maxSize, chunkAllowed, mergeStrategy, etc.
operationId: Optional operation ID for progress logging (parent operation)
parentOperationId: Optional parent operation ID for hierarchical logging
Returns: Returns:
List of ContentExtracted objects, one per input document List of ContentExtracted objects, one per input document
@ -52,125 +60,172 @@ class ExtractionService:
from modules.interfaces.interfaceDbComponentObjects import getInterface from modules.interfaces.interfaceDbComponentObjects import getInterface
dbInterface = getInterface() dbInterface = getInterface()
totalDocs = len(documents)
for i, doc in enumerate(documents): for i, doc in enumerate(documents):
logger.info(f"=== DOCUMENT {i}: {doc.fileName} ===") logger.info(f"=== DOCUMENT {i + 1}/{totalDocs}: {doc.fileName} ===")
logger.info(f"Initial MIME type: {doc.mimeType}") logger.info(f"Initial MIME type: {doc.mimeType}")
# Create child operation for this document if parent operationId is provided
docOperationId = None
if operationId:
workflowId = self.services.workflow.id if self.services.workflow else f"no-workflow-{int(time.time())}"
docOperationId = f"{operationId}_doc_{i}"
self.services.chat.progressLogStart(
docOperationId,
"Extracting Document",
f"Document {i + 1}/{totalDocs}",
doc.fileName[:50] + "..." if len(doc.fileName) > 50 else doc.fileName,
parentOperationId=operationId # Use operationId as parent (not parentOperationId)
)
# Start timing for this document # Start timing for this document
startTime = time.time() startTime = time.time()
# Resolve raw bytes for this document using interface
documentBytes = dbInterface.getFileData(doc.fileId)
if not documentBytes:
raise ValueError(f"No file data found for fileId={doc.fileId}")
# Convert ChatDocument to the format expected by runExtraction
documentData = {
"id": doc.id,
"bytes": documentBytes,
"fileName": doc.fileName,
"mimeType": doc.mimeType
}
ec = runExtraction(
extractorRegistry=self._extractorRegistry,
chunkerRegistry=self._chunkerRegistry,
documentBytes=documentData["bytes"],
fileName=documentData["fileName"],
mimeType=documentData["mimeType"],
options=options
)
# Log content parts metadata
logger.debug(f"Content parts: {len(ec.parts)}")
for j, part in enumerate(ec.parts):
logger.debug(f" Part {j}: {part.typeGroup} ({part.mimeType}) - {len(part.data) if part.data else 0} chars")
if part.metadata:
logger.debug(f" Metadata: {part.metadata}")
# Attach document id and MIME type to parts if missing
for p in ec.parts:
if "documentId" not in p.metadata:
p.metadata["documentId"] = documentData["id"] or str(uuid.uuid4())
if "documentMimeType" not in p.metadata:
p.metadata["documentMimeType"] = documentData["mimeType"]
# Log chunking information
chunkedParts = [p for p in ec.parts if p.metadata.get("chunk", False)]
if chunkedParts:
logger.debug(f"=== CHUNKING RESULTS ===")
logger.debug(f"Total parts: {len(ec.parts)}")
logger.debug(f"Chunked parts: {len(chunkedParts)}")
for chunk in chunkedParts:
logger.debug(f" Chunk: {chunk.label} - {len(chunk.data)} chars (parent: {chunk.parentId})")
else:
logger.debug(f"No chunking needed - {len(ec.parts)} parts fit within size limits")
# Calculate timing and emit stats
endTime = time.time()
processingTime = endTime - startTime
bytesSent = len(documentBytes)
bytesReceived = sum(len(part.data) if part.data else 0 for part in ec.parts)
# Emit stats for extraction operation
# Use internal extraction model for pricing
modelDisplayName = "Internal Document Extractor"
model = modelRegistry.getModel(modelDisplayName)
# Hard fail if model is missing; caller must ensure connectors are registered
if model is None or model.calculatePriceUsd is None:
raise RuntimeError(f"Pricing model not available: {modelDisplayName}")
priceUsd = model.calculatePriceUsd(processingTime, bytesSent, bytesReceived)
# Create AiCallResponse with real calculation
# Use model.name for the response (API identifier), not displayName
aiResponse = AiCallResponse(
content="", # No content for extraction stats needed
modelName=model.name,
priceUsd=priceUsd,
processingTime=processingTime,
bytesSent=bytesSent,
bytesReceived=bytesReceived,
errorCount=0
)
self.services.chat.storeWorkflowStat(
self.services.workflow,
aiResponse,
f"extraction.process.{doc.mimeType}"
)
# Write extraction results to debug file
try: try:
from modules.shared.debugLogger import writeDebugFile if docOperationId:
import json self.services.chat.progressLogUpdate(docOperationId, 0.1, "Loading document data")
# Create summary of extraction results for debug
extractionSummary = {
"documentName": doc.fileName,
"documentMimeType": doc.mimeType,
"partsCount": len(ec.parts),
"parts": []
}
for part in ec.parts:
partSummary = {
"typeGroup": part.typeGroup,
"mimeType": part.mimeType,
"label": part.label,
"dataLength": len(part.data) if part.data else 0,
"metadata": part.metadata
}
# Include data preview for small parts (first 500 chars)
if part.data and len(part.data) <= 500:
partSummary["dataPreview"] = part.data[:500]
elif part.data:
partSummary["dataPreview"] = f"[Large data: {len(part.data)} chars - truncated]"
extractionSummary["parts"].append(partSummary)
writeDebugFile(json.dumps(extractionSummary, indent=2, ensure_ascii=False), f"extraction_result_{doc.fileName}") # Resolve raw bytes for this document using interface
except Exception as e: documentBytes = dbInterface.getFileData(doc.fileId)
logger.debug(f"Failed to write extraction debug file: {str(e)}") if not documentBytes:
if docOperationId:
self.services.chat.progressLogFinish(docOperationId, False)
raise ValueError(f"No file data found for fileId={doc.fileId}")
if docOperationId:
self.services.chat.progressLogUpdate(docOperationId, 0.2, "Running extraction pipeline")
# Convert ChatDocument to the format expected by runExtraction
documentData = {
"id": doc.id,
"bytes": documentBytes,
"fileName": doc.fileName,
"mimeType": doc.mimeType
}
ec = runExtraction(
extractorRegistry=self._extractorRegistry,
chunkerRegistry=self._chunkerRegistry,
documentBytes=documentData["bytes"],
fileName=documentData["fileName"],
mimeType=documentData["mimeType"],
options=options
)
if docOperationId:
self.services.chat.progressLogUpdate(docOperationId, 0.7, f"Extracted {len(ec.parts)} parts")
# Log content parts metadata
logger.debug(f"Content parts: {len(ec.parts)}")
for j, part in enumerate(ec.parts):
logger.debug(f" Part {j + 1}/{len(ec.parts)}: {part.typeGroup} ({part.mimeType}) - {len(part.data) if part.data else 0} chars")
if part.metadata:
logger.debug(f" Metadata: {part.metadata}")
results.append(ec) # Attach document id and MIME type to parts if missing
for p in ec.parts:
if "documentId" not in p.metadata:
p.metadata["documentId"] = documentData["id"] or str(uuid.uuid4())
if "documentMimeType" not in p.metadata:
p.metadata["documentMimeType"] = documentData["mimeType"]
# Log chunking information
chunkedParts = [p for p in ec.parts if p.metadata.get("chunk", False)]
if chunkedParts:
logger.debug(f"=== CHUNKING RESULTS ===")
logger.debug(f"Total parts: {len(ec.parts)}")
logger.debug(f"Chunked parts: {len(chunkedParts)}")
for chunk in chunkedParts:
logger.debug(f" Chunk: {chunk.label} - {len(chunk.data)} chars (parent: {chunk.parentId})")
else:
logger.debug(f"No chunking needed - {len(ec.parts)} parts fit within size limits")
if docOperationId:
self.services.chat.progressLogUpdate(docOperationId, 0.9, f"Processing complete: {len(ec.parts)} parts extracted")
# Calculate timing and emit stats
endTime = time.time()
processingTime = endTime - startTime
bytesSent = len(documentBytes)
bytesReceived = sum(len(part.data) if part.data else 0 for part in ec.parts)
# Emit stats for extraction operation
# Use internal extraction model for pricing
modelDisplayName = "Internal Document Extractor"
model = modelRegistry.getModel(modelDisplayName)
# Hard fail if model is missing; caller must ensure connectors are registered
if model is None or model.calculatePriceUsd is None:
if docOperationId:
self.services.chat.progressLogFinish(docOperationId, False)
raise RuntimeError(f"Pricing model not available: {modelDisplayName}")
priceUsd = model.calculatePriceUsd(processingTime, bytesSent, bytesReceived)
# Create AiCallResponse with real calculation
# Use model.name for the response (API identifier), not displayName
aiResponse = AiCallResponse(
content="", # No content for extraction stats needed
modelName=model.name,
priceUsd=priceUsd,
processingTime=processingTime,
bytesSent=bytesSent,
bytesReceived=bytesReceived,
errorCount=0
)
self.services.chat.storeWorkflowStat(
self.services.workflow,
aiResponse,
f"extraction.process.{doc.mimeType}"
)
# Write extraction results to debug file
try:
from modules.shared.debugLogger import writeDebugFile
import json
# Create summary of extraction results for debug
extractionSummary = {
"documentName": doc.fileName,
"documentMimeType": doc.mimeType,
"partsCount": len(ec.parts),
"parts": []
}
for part in ec.parts:
partSummary = {
"typeGroup": part.typeGroup,
"mimeType": part.mimeType,
"label": part.label,
"dataLength": len(part.data) if part.data else 0,
"metadata": part.metadata
}
# Include data preview for small parts (first 500 chars)
if part.data and len(part.data) <= 500:
partSummary["dataPreview"] = part.data[:500]
elif part.data:
partSummary["dataPreview"] = f"[Large data: {len(part.data)} chars - truncated]"
extractionSummary["parts"].append(partSummary)
writeDebugFile(json.dumps(extractionSummary, indent=2, ensure_ascii=False), f"extraction_result_{doc.fileName}")
except Exception as e:
logger.debug(f"Failed to write extraction debug file: {str(e)}")
results.append(ec)
# Finish document operation successfully
if docOperationId:
self.services.chat.progressLogFinish(docOperationId, True)
except Exception as e:
logger.error(f"Error extracting content from document {i + 1}/{totalDocs} ({doc.fileName}): {str(e)}")
if docOperationId:
try:
self.services.chat.progressLogFinish(docOperationId, False)
except:
pass # Don't fail on progress logging errors
# Continue with next document instead of failing completely
# This allows parallel processing to continue even if one document fails
continue
return results return results
@ -481,7 +536,8 @@ class ExtractionService:
# Extract content WITHOUT chunking # Extract content WITHOUT chunking
if operationId: if operationId:
self.services.chat.progressLogUpdate(operationId, 0.1, f"Extracting content from {len(documents)} documents") self.services.chat.progressLogUpdate(operationId, 0.1, f"Extracting content from {len(documents)} documents")
extractionResult = self.extractContent(documents, extractionOptions) # Pass operationId as parentOperationId for hierarchical logging
extractionResult = self.extractContent(documents, extractionOptions, operationId=operationId, parentOperationId=parentOperationId)
if not isinstance(extractionResult, list): if not isinstance(extractionResult, list):
if operationId: if operationId:
@ -491,11 +547,9 @@ class ExtractionService:
# Process parts (not chunks) with model-aware AI calls # Process parts (not chunks) with model-aware AI calls
if operationId: if operationId:
self.services.chat.progressLogUpdate(operationId, 0.3, f"Processing {len(extractionResult)} extracted content parts") self.services.chat.progressLogUpdate(operationId, 0.3, f"Processing {len(extractionResult)} extracted content parts")
# Get parent log ID for part operations # Use parent operation ID directly (parentId should be operationId, not log entry ID)
parentLogId = None parentOperationId = operationId # Use the parent's operationId directly
if operationId: partResults = await self._processPartsWithMapping(extractionResult, prompt, aiObjects, options, operationId, parentOperationId)
parentLogId = self.services.chat.getOperationLogId(operationId)
partResults = await self._processPartsWithMapping(extractionResult, prompt, aiObjects, options, operationId, parentLogId)
# Merge results using existing merging system # Merge results using existing merging system
if operationId: if operationId:
@ -522,7 +576,7 @@ class ExtractionService:
aiObjects: Any, aiObjects: Any,
options: Optional[AiCallOptions] = None, options: Optional[AiCallOptions] = None,
operationId: Optional[str] = None, operationId: Optional[str] = None,
parentLogId: Optional[str] = None parentOperationId: Optional[str] = None
) -> List[PartResult]: ) -> List[PartResult]:
"""Process content parts with model-aware chunking and proper mapping.""" """Process content parts with model-aware chunking and proper mapping."""
@ -569,7 +623,7 @@ class ExtractionService:
"Content Processing", "Content Processing",
f"Part {part_index + 1}", f"Part {part_index + 1}",
f"Type: {part.typeGroup}", f"Type: {part.typeGroup}",
parentId=parentLogId parentOperationId=parentOperationId
) )
try: try:

View file

@ -99,6 +99,18 @@ async def buildExtractionPrompt(
# Parse extraction intent if AI service is available # Parse extraction intent if AI service is available
extraction_intent = await _parseExtractionIntent(userPrompt, outputFormat, aiService, services) if aiService else userPrompt extraction_intent = await _parseExtractionIntent(userPrompt, outputFormat, aiService, services) if aiService else userPrompt
# Extract user language for document language instruction
userLanguage = 'en' # Default fallback
if services:
try:
# Prefer detected language if available
if hasattr(services, 'currentUserLanguage') and services.currentUserLanguage:
userLanguage = services.currentUserLanguage
elif hasattr(services, 'user') and services.user and hasattr(services.user, 'language'):
userLanguage = services.user.language
except Exception:
pass
# Build base prompt with clear user prompt markers # Build base prompt with clear user prompt markers
sanitized_user_prompt = services.utils.sanitizePromptContent(userPrompt, 'userinput') if services else userPrompt sanitized_user_prompt = services.utils.sanitizePromptContent(userPrompt, 'userinput') if services else userPrompt
adaptive_prompt = f""" adaptive_prompt = f"""
@ -114,6 +126,8 @@ You are a document processing assistant that extracts and structures content fro
TASK: Extract the actual content from the document and organize it into documents. For single documents, create one document entry. For multi-document requests, create multiple document entries. TASK: Extract the actual content from the document and organize it into documents. For single documents, create one document entry. For multi-document requests, create multiple document entries.
LANGUAGE REQUIREMENT: All extracted content must be in the language '{userLanguage}'. Extract and preserve content in this language.
{extraction_intent} {extraction_intent}
REQUIREMENTS: REQUIREMENTS:

View file

@ -362,7 +362,7 @@ class BaseRenderer(ABC):
self.logger.debug(f"AI Style Template Prompt:") self.logger.debug(f"AI Style Template Prompt:")
self.logger.debug(f"{styleTemplate}") self.logger.debug(f"{styleTemplate}")
response = await aiService.aiObjects.call(request) response = await aiService.callAi(request)
# Save styling prompt and response to debug # Save styling prompt and response to debug
self.services.utils.writeDebugFile(styleTemplate, "renderer_styling_prompt") self.services.utils.writeDebugFile(styleTemplate, "renderer_styling_prompt")

View file

@ -205,7 +205,7 @@ Return only the compressed prompt, no explanations.
) )
) )
response = await aiService.aiObjects.call(request) response = await aiService.callAi(request)
compressed = response.content.strip() compressed = response.content.strip()
# Validate the compressed prompt # Validate the compressed prompt

View file

@ -227,7 +227,7 @@ class RendererPdf(BaseRenderer):
self.logger.warning("AI service not properly configured, using defaults") self.logger.warning("AI service not properly configured, using defaults")
return default_styles return default_styles
response = await ai_service.aiObjects.call(request) response = await ai_service.callAi(request)
# Check if response is valid # Check if response is valid
if not response: if not response:

View file

@ -424,7 +424,7 @@ JSON ONLY. NO OTHER TEXT."""
self.logger.warning("AI service not properly configured, using defaults") self.logger.warning("AI service not properly configured, using defaults")
return default_styles return default_styles
response = await aiService.aiObjects.call(request) response = await aiService.callAi(request)
# Check if response is valid # Check if response is valid
if not response: if not response:

View file

@ -346,7 +346,7 @@ class RendererXlsx(BaseRenderer):
requestOptions.operationType = OperationTypeEnum.DATA_GENERATE requestOptions.operationType = OperationTypeEnum.DATA_GENERATE
request = AiCallRequest(prompt=styleTemplate, context="", options=requestOptions) request = AiCallRequest(prompt=styleTemplate, context="", options=requestOptions)
response = await aiService.aiObjects.call(request) response = await aiService.callAi(request)
import json import json
import re import re

View file

@ -16,7 +16,8 @@ async def buildGenerationPrompt(
userPrompt: str, userPrompt: str,
title: str, title: str,
extracted_content: str = None, extracted_content: str = None,
continuationContext: Dict[str, Any] = None continuationContext: Dict[str, Any] = None,
services: Any = None
) -> str: ) -> str:
""" """
Build the unified generation prompt using a single JSON template. Build the unified generation prompt using a single JSON template.
@ -28,10 +29,23 @@ async def buildGenerationPrompt(
title: Title for the document title: Title for the document
extracted_content: Optional extracted content from documents to prepend to prompt extracted_content: Optional extracted content from documents to prepend to prompt
continuationContext: Optional context from previous generation for continuation continuationContext: Optional context from previous generation for continuation
services: Optional services instance for accessing user language
Returns: Returns:
Complete generation prompt string Complete generation prompt string
""" """
# Extract user language for document language instruction
userLanguage = 'en' # Default fallback
if services:
try:
# Prefer detected language if available
if hasattr(services, 'currentUserLanguage') and services.currentUserLanguage:
userLanguage = services.currentUserLanguage
elif hasattr(services, 'user') and services.user and hasattr(services.user, 'language'):
userLanguage = services.user.language
except Exception:
pass
# Create a template - let AI generate title if not provided # Create a template - let AI generate title if not provided
titleValue = title if title else "Generated Document" titleValue = title if title else "Generated Document"
jsonTemplate = jsonTemplateDocument.replace("{{DOCUMENT_TITLE}}", titleValue) jsonTemplate = jsonTemplateDocument.replace("{{DOCUMENT_TITLE}}", titleValue)
@ -82,6 +96,8 @@ END OF USER REQUEST / USER PROMPT
CONTINUATION MODE: Response was incomplete. Generate ONLY the remaining content. CONTINUATION MODE: Response was incomplete. Generate ONLY the remaining content.
LANGUAGE REQUIREMENT: All generated content must be in the language '{userLanguage}'. Generate all text, headings, paragraphs, and content in this language.
{continuationText} {continuationText}
JSON structure template: JSON structure template:
@ -92,6 +108,7 @@ Rules:
- Reference elements shown above are ALREADY DELIVERED - DO NOT repeat them. - Reference elements shown above are ALREADY DELIVERED - DO NOT repeat them.
- Generate ONLY the remaining content that comes AFTER the reference elements. - Generate ONLY the remaining content that comes AFTER the reference elements.
- DO NOT regenerate the entire JSON structure - start directly with what comes next. - DO NOT regenerate the entire JSON structure - start directly with what comes next.
- All content must be in the language '{userLanguage}'.
- Output JSON only; no markdown fences or extra text. - Output JSON only; no markdown fences or extra text.
Continue generating the remaining content now. Continue generating the remaining content now.
@ -124,6 +141,8 @@ EXTRACTED CONTENT FROM DOCUMENTS:
END OF EXTRACTED CONTENT END OF EXTRACTED CONTENT
{'='*80} {'='*80}
LANGUAGE REQUIREMENT: All generated content must be in the language '{userLanguage}'. Generate all text, headings, paragraphs, and content in this language. If the extracted content is in a different language, translate it to '{userLanguage}' while preserving the structure and meaning.
Generate a VALID JSON response using the EXTRACTED CONTENT above as your data source. Generate a VALID JSON response using the EXTRACTED CONTENT above as your data source.
The JSON structure template below shows ONLY the structure pattern - the example values are NOT real data. The JSON structure template below shows ONLY the structure pattern - the example values are NOT real data.
You MUST use the actual data from EXTRACTED CONTENT above, NOT the example values from the template. You MUST use the actual data from EXTRACTED CONTENT above, NOT the example values from the template.
@ -136,6 +155,7 @@ Instructions:
- Do NOT reuse example section IDs; create your own. - Do NOT reuse example section IDs; create your own.
- CRITICAL: Use the ACTUAL DATA from EXTRACTED CONTENT above, NOT the example values from the template. - CRITICAL: Use the ACTUAL DATA from EXTRACTED CONTENT above, NOT the example values from the template.
- Generate complete content based on the user request and the extracted content. Do NOT just give an instruction or comments. Deliver the complete response. - Generate complete content based on the user request and the extracted content. Do NOT just give an instruction or comments. Deliver the complete response.
- All content must be in the language '{userLanguage}'.
- IMPORTANT: Set a meaningful "filename" in each document with appropriate file extension (e.g., "prime_numbers.txt", "report.docx", "data.json"). The filename should reflect the content and task objective. - IMPORTANT: Set a meaningful "filename" in each document with appropriate file extension (e.g., "prime_numbers.txt", "report.docx", "data.json"). The filename should reflect the content and task objective.
- Output JSON only; no markdown fences or extra text. - Output JSON only; no markdown fences or extra text.
@ -151,6 +171,8 @@ USER REQUEST / USER PROMPT:
END OF USER REQUEST / USER PROMPT END OF USER REQUEST / USER PROMPT
{'='*80} {'='*80}
LANGUAGE REQUIREMENT: All generated content must be in the language '{userLanguage}'. Generate all text, headings, paragraphs, and content in this language.
Generate a VALID JSON response for the user request. The template below shows ONLY the structure pattern - it is NOT existing content. Generate a VALID JSON response for the user request. The template below shows ONLY the structure pattern - it is NOT existing content.
JSON structure template: JSON structure template:
@ -160,6 +182,7 @@ Instructions:
- Return ONLY valid JSON (strict). No comments. No trailing commas. Use double quotes. - Return ONLY valid JSON (strict). No comments. No trailing commas. Use double quotes.
- Do NOT reuse example section IDs; create your own. - Do NOT reuse example section IDs; create your own.
- Generate complete content based on the user request. Do NOT just give an instruction or comments. Deliver the complete response. - Generate complete content based on the user request. Do NOT just give an instruction or comments. Deliver the complete response.
- All content must be in the language '{userLanguage}'.
- IMPORTANT: Set a meaningful "filename" in each document with appropriate file extension (e.g., "prime_numbers.txt", "report.docx", "data.json"). The filename should reflect the content and task objective. - IMPORTANT: Set a meaningful "filename" in each document with appropriate file extension (e.g., "prime_numbers.txt", "report.docx", "data.json"). The filename should reflect the content and task objective.
- Output JSON only; no markdown fences or extra text. - Output JSON only; no markdown fences or extra text.

View file

@ -114,16 +114,14 @@ class WebService:
self.services.chat.progressLogUpdate(operationId, 0.4, "Initiating") self.services.chat.progressLogUpdate(operationId, 0.4, "Initiating")
self.services.chat.progressLogUpdate(operationId, 0.6, f"Crawling {len(allUrls)} URLs") self.services.chat.progressLogUpdate(operationId, 0.6, f"Crawling {len(allUrls)} URLs")
# Get parent log ID for URL-level operations # Use parent operation ID directly (parentId should be operationId, not log entry ID)
parentLogId = None parentOperationId = operationId # Use the parent's operationId directly
if operationId:
parentLogId = self.services.chat.getOperationLogId(operationId)
crawlResult = await self._performWebCrawl( crawlResult = await self._performWebCrawl(
instruction=instruction, instruction=instruction,
urls=allUrls, urls=allUrls,
maxDepth=maxDepth, maxDepth=maxDepth,
parentLogId=parentLogId parentOperationId=parentOperationId
) )
if operationId: if operationId:
@ -131,18 +129,95 @@ class WebService:
self.services.chat.progressLogUpdate(operationId, 0.95, "Completed") self.services.chat.progressLogUpdate(operationId, 0.95, "Completed")
self.services.chat.progressLogFinish(operationId, True) self.services.chat.progressLogFinish(operationId, True)
# Return consolidated result # Calculate statistics about crawl results
totalResults = len(crawlResult) if isinstance(crawlResult, list) else 1
totalContentLength = 0
urlsWithContent = 0
# Analyze crawl results to gather statistics
if isinstance(crawlResult, list):
for item in crawlResult:
if isinstance(item, dict):
if item.get("url"):
urlsWithContent += 1
content = item.get("content", "")
if isinstance(content, str):
totalContentLength += len(content)
elif isinstance(content, dict):
# Estimate size from dict
totalContentLength += len(str(content))
elif isinstance(crawlResult, dict):
if crawlResult.get("url"):
urlsWithContent = 1
content = crawlResult.get("content", "")
if isinstance(content, str):
totalContentLength = len(content)
elif isinstance(content, dict):
totalContentLength = len(str(content))
# Convert crawl results into sections format for generic validator
sections = []
if isinstance(crawlResult, list):
for idx, item in enumerate(crawlResult):
if isinstance(item, dict):
section = {
"id": f"result_{idx}",
"content_type": "paragraph",
"title": item.get("url", f"Result {idx + 1}"),
"order": idx
}
# Add content preview
content = item.get("content", "")
if isinstance(content, str) and content:
section["textPreview"] = content[:200] + ("..." if len(content) > 200 else "")
sections.append(section)
elif isinstance(crawlResult, dict):
section = {
"id": "result_0",
"content_type": "paragraph",
"title": crawlResult.get("url", "Research Result"),
"order": 0
}
content = crawlResult.get("content", "")
if isinstance(content, str) and content:
section["textPreview"] = content[:200] + ("..." if len(content) > 200 else "")
sections.append(section)
# Return consolidated result with metadata in format that generic validator understands
result = { result = {
"metadata": {
"title": suggestedFilename or instruction[:100] if instruction else "Web Research Results",
"extraction_method": "web_crawl",
"research_depth": finalResearchDepth,
"max_depth": maxDepth,
"country": countryCode,
"language": languageCode,
"urls_crawled": allUrls[:20], # First 20 URLs for reference
"total_urls": len(allUrls),
"urls_with_content": urlsWithContent,
"total_content_length": totalContentLength,
"crawl_date": self.services.utils.timestampGetUtc() if hasattr(self.services, 'utils') else None
},
"sections": sections,
"statistics": {
"sectionCount": len(sections),
"total_urls": len(allUrls),
"results_count": totalResults,
"urls_with_content": urlsWithContent,
"total_content_length": totalContentLength
},
# Keep original structure for backward compatibility
"instruction": instruction, "instruction": instruction,
"urls_crawled": allUrls, "urls_crawled": allUrls,
"total_urls": len(allUrls), "total_urls": len(allUrls),
"results": crawlResult, "results": crawlResult,
"total_results": len(crawlResult) if isinstance(crawlResult, list) else 1 "total_results": totalResults
} }
# Add suggested filename if available # Add suggested filename if available
if suggestedFilename: if suggestedFilename:
result["suggested_filename"] = suggestedFilename result["suggested_filename"] = suggestedFilename
result["metadata"]["suggested_filename"] = suggestedFilename
return result return result
@ -311,7 +386,7 @@ Return ONLY valid JSON, no additional text:
instruction: str, instruction: str,
urls: List[str], urls: List[str],
maxDepth: int = 2, maxDepth: int = 2,
parentLogId: Optional[str] = None parentOperationId: Optional[str] = None
) -> List[Dict[str, Any]]: ) -> List[Dict[str, Any]]:
"""Perform web crawl on list of URLs - calls plugin for each URL individually.""" """Perform web crawl on list of URLs - calls plugin for each URL individually."""
crawlResults = [] crawlResults = []
@ -320,7 +395,7 @@ Return ONLY valid JSON, no additional text:
for urlIndex, url in enumerate(urls): for urlIndex, url in enumerate(urls):
# Create separate operation for each URL with parent reference # Create separate operation for each URL with parent reference
urlOperationId = None urlOperationId = None
if parentLogId: if parentOperationId:
workflowId = self.services.workflow.id if self.services.workflow else f"no-workflow-{int(time.time())}" workflowId = self.services.workflow.id if self.services.workflow else f"no-workflow-{int(time.time())}"
urlOperationId = f"web_crawl_url_{workflowId}_{urlIndex}_{int(time.time())}" urlOperationId = f"web_crawl_url_{workflowId}_{urlIndex}_{int(time.time())}"
self.services.chat.progressLogStart( self.services.chat.progressLogStart(
@ -328,21 +403,23 @@ Return ONLY valid JSON, no additional text:
"Web Crawl", "Web Crawl",
f"URL {urlIndex + 1}", f"URL {urlIndex + 1}",
url[:50] + "..." if len(url) > 50 else url, url[:50] + "..." if len(url) > 50 else url,
parentId=parentLogId parentOperationId=parentOperationId
) )
try: try:
logger.info(f"Crawling URL: {url}") logger.info(f"Crawling URL {urlIndex + 1}/{len(urls)}: {url}")
if urlOperationId: if urlOperationId:
self.services.chat.progressLogUpdate(urlOperationId, 0.3, "Initiating") displayUrl = url[:50] + "..." if len(url) > 50 else url
self.services.chat.progressLogUpdate(urlOperationId, 0.2, f"Crawling: {displayUrl}")
self.services.chat.progressLogUpdate(urlOperationId, 0.3, "Initiating crawl")
# Build crawl prompt model for single URL # Build crawl prompt model for single URL
crawlPromptModel = AiCallPromptWebCrawl( crawlPromptModel = AiCallPromptWebCrawl(
instruction=instruction, instruction=instruction,
url=url, # Single URL url=url, # Single URL
maxDepth=maxDepth, maxDepth=maxDepth,
maxWidth=50 maxWidth=5 # Default: 5 pages per level
) )
crawlPrompt = crawlPromptModel.model_dump_json(exclude_none=True, indent=2) crawlPrompt = crawlPromptModel.model_dump_json(exclude_none=True, indent=2)
@ -356,16 +433,19 @@ Return ONLY valid JSON, no additional text:
resultFormat="json" resultFormat="json"
) )
# Use unified callAiContent method if urlOperationId:
self.services.chat.progressLogUpdate(urlOperationId, 0.4, "Calling crawl connector")
# Use unified callAiContent method with parentOperationId for hierarchical logging
crawlResponse = await self.services.ai.callAiContent( crawlResponse = await self.services.ai.callAiContent(
prompt=crawlPrompt, prompt=crawlPrompt,
options=crawlOptions, options=crawlOptions,
outputFormat="json" outputFormat="json",
parentOperationId=urlOperationId # Pass URL operation ID as parent for sub-URL logging
) )
if urlOperationId: if urlOperationId:
self.services.chat.progressLogUpdate(urlOperationId, 0.8, "Completed") self.services.chat.progressLogUpdate(urlOperationId, 0.7, "Processing crawl results")
self.services.chat.progressLogFinish(urlOperationId, True)
# Extract content from AiResponse # Extract content from AiResponse
crawlResult = crawlResponse.content crawlResult = crawlResponse.content
@ -387,16 +467,30 @@ Return ONLY valid JSON, no additional text:
else: else:
crawlData = crawlResult crawlData = crawlResult
# Process crawl results and create hierarchical progress logging for sub-URLs
if urlOperationId:
self.services.chat.progressLogUpdate(urlOperationId, 0.8, "Processing crawl results")
# Recursively process crawl results to find nested URLs and create child operations
processedResults = self._processCrawlResultsWithHierarchy(crawlData, url, urlOperationId, maxDepth, 0)
# Count total URLs crawled (including sub-URLs) for progress message
totalUrlsCrawled = self._countUrlsInResults(processedResults)
# Ensure it's a list of results # Ensure it's a list of results
if isinstance(crawlData, list): if isinstance(processedResults, list):
crawlResults.extend(crawlData) crawlResults.extend(processedResults)
elif isinstance(crawlData, dict): elif isinstance(processedResults, dict):
if "results" in crawlData: crawlResults.append(processedResults)
crawlResults.extend(crawlData["results"])
else:
crawlResults.append(crawlData)
else: else:
crawlResults.append({"url": url, "content": str(crawlData)}) crawlResults.append({"url": url, "content": str(processedResults)})
if urlOperationId:
if totalUrlsCrawled > 1:
self.services.chat.progressLogUpdate(urlOperationId, 0.9, f"Crawled {totalUrlsCrawled} URLs (including sub-URLs)")
else:
self.services.chat.progressLogUpdate(urlOperationId, 0.9, "Crawl completed")
self.services.chat.progressLogFinish(urlOperationId, True)
except Exception as e: except Exception as e:
logger.error(f"Error crawling URL {url}: {str(e)}") logger.error(f"Error crawling URL {url}: {str(e)}")
@ -405,4 +499,145 @@ Return ONLY valid JSON, no additional text:
crawlResults.append({"url": url, "error": str(e)}) crawlResults.append({"url": url, "error": str(e)})
return crawlResults return crawlResults
def _processCrawlResultsWithHierarchy(
self,
crawlData: Any,
parentUrl: str,
parentOperationId: Optional[str],
maxDepth: int,
currentDepth: int
) -> List[Dict[str, Any]]:
"""
Recursively process crawl results to create hierarchical progress logging for sub-URLs.
Args:
crawlData: Crawl result data (dict, list, or other)
parentUrl: Parent URL being crawled
parentOperationId: Parent operation ID for hierarchical logging
maxDepth: Maximum crawl depth
currentDepth: Current depth in the crawl tree
Returns:
List of processed crawl results
"""
import time
results = []
# Handle list of results
if isinstance(crawlData, list):
for idx, item in enumerate(crawlData):
if isinstance(item, dict):
# Check if this item has sub-URLs or nested results
itemUrl = item.get("url") or item.get("source") or parentUrl
# Create child operation for sub-URL if we're not at max depth
if currentDepth < maxDepth and parentOperationId:
# Check if this item has nested results or children
hasNestedResults = "results" in item or "children" in item or "subUrls" in item
if hasNestedResults or (itemUrl != parentUrl and currentDepth > 0):
# This is a sub-URL - create child operation
workflowId = self.services.workflow.id if self.services.workflow else f"no-workflow-{int(time.time())}"
subUrlOperationId = f"{parentOperationId}_sub_{idx}_{int(time.time())}"
self.services.chat.progressLogStart(
subUrlOperationId,
"Crawling Sub-URL",
f"Depth {currentDepth + 1}",
itemUrl[:50] + "..." if len(itemUrl) > 50 else itemUrl,
parentOperationId=parentOperationId
)
try:
# Process nested results recursively
if "results" in item:
nestedResults = self._processCrawlResultsWithHierarchy(
item["results"], itemUrl, subUrlOperationId, maxDepth, currentDepth + 1
)
item["results"] = nestedResults
elif "children" in item:
nestedResults = self._processCrawlResultsWithHierarchy(
item["children"], itemUrl, subUrlOperationId, maxDepth, currentDepth + 1
)
item["children"] = nestedResults
elif "subUrls" in item:
nestedResults = self._processCrawlResultsWithHierarchy(
item["subUrls"], itemUrl, subUrlOperationId, maxDepth, currentDepth + 1
)
item["subUrls"] = nestedResults
self.services.chat.progressLogUpdate(subUrlOperationId, 0.9, "Completed")
self.services.chat.progressLogFinish(subUrlOperationId, True)
except Exception as e:
logger.error(f"Error processing sub-URL {itemUrl}: {str(e)}")
if subUrlOperationId:
self.services.chat.progressLogFinish(subUrlOperationId, False)
results.append(item)
else:
results.append(item)
# Handle dict with results array
elif isinstance(crawlData, dict):
if "results" in crawlData:
# Process nested results
nestedResults = self._processCrawlResultsWithHierarchy(
crawlData["results"], parentUrl, parentOperationId, maxDepth, currentDepth
)
crawlData["results"] = nestedResults
results.append(crawlData)
elif "children" in crawlData:
# Process children
nestedResults = self._processCrawlResultsWithHierarchy(
crawlData["children"], parentUrl, parentOperationId, maxDepth, currentDepth
)
crawlData["children"] = nestedResults
results.append(crawlData)
elif "subUrls" in crawlData:
# Process sub-URLs
nestedResults = self._processCrawlResultsWithHierarchy(
crawlData["subUrls"], parentUrl, parentOperationId, maxDepth, currentDepth
)
crawlData["subUrls"] = nestedResults
results.append(crawlData)
else:
# Single result dict
results.append(crawlData)
else:
# Other types - wrap in dict
results.append({"url": parentUrl, "content": str(crawlData)})
return results
def _countUrlsInResults(self, results: Any) -> int:
"""
Recursively count total URLs in crawl results (including nested sub-URLs).
Args:
results: Crawl results (dict, list, or other)
Returns:
Total count of URLs found
"""
count = 0
if isinstance(results, list):
for item in results:
count += self._countUrlsInResults(item)
elif isinstance(results, dict):
# Count this URL if it has a url field
if "url" in results or "source" in results:
count += 1
# Recursively count nested results
if "results" in results:
count += self._countUrlsInResults(results["results"])
if "children" in results:
count += self._countUrlsInResults(results["children"])
if "subUrls" in results:
count += self._countUrlsInResults(results["subUrls"])
elif isinstance(results, str):
# Single URL string
count = 1
return count

View file

@ -24,7 +24,7 @@ class ProgressLogger:
self.finishedOperations = set() # Track finished operations to avoid repeated warnings self.finishedOperations = set() # Track finished operations to avoid repeated warnings
self.operationLogIds = {} # Map operationId to the log entry ID for parent reference self.operationLogIds = {} # Map operationId to the log entry ID for parent reference
def startOperation(self, operationId: str, serviceName: str, actionName: str, context: str = "", parentId: Optional[str] = None): def startOperation(self, operationId: str, serviceName: str, actionName: str, context: str = "", parentOperationId: Optional[str] = None):
"""Start a new long-running operation. """Start a new long-running operation.
Args: Args:
@ -32,7 +32,8 @@ class ProgressLogger:
serviceName: Name of the service (e.g., "Extract", "AI", "Generate") serviceName: Name of the service (e.g., "Extract", "AI", "Generate")
actionName: Name of the action being performed actionName: Name of the action being performed
context: Additional context information context: Additional context information
parentId: Optional parent log entry ID for hierarchical display parentOperationId: Optional parent operation ID (operationId of parent operation) for hierarchical display
The parentId in ChatLog will be set to this parentOperationId
""" """
# Remove from finished operations if it was there (for restart scenarios) # Remove from finished operations if it was there (for restart scenarios)
self.finishedOperations.discard(operationId) self.finishedOperations.discard(operationId)
@ -42,9 +43,10 @@ class ProgressLogger:
'action': actionName, 'action': actionName,
'context': context, 'context': context,
'startTime': time.time(), 'startTime': time.time(),
'parentId': parentId 'parentOperationId': parentOperationId # Store parent's operationId, not log entry ID
} }
logId = self._logProgress(operationId, 0.0, f"Starting {actionName}", parentId=parentId) # Use parentOperationId as parentId in ChatLog (parentId should be the operationId of parent)
logId = self._logProgress(operationId, 0.0, f"Starting {actionName}", parentOperationId=parentOperationId)
if logId: if logId:
self.operationLogIds[operationId] = logId self.operationLogIds[operationId] = logId
logger.debug(f"Started operation {operationId}: {serviceName} - {actionName}") logger.debug(f"Started operation {operationId}: {serviceName} - {actionName}")
@ -70,9 +72,9 @@ class ProgressLogger:
op = self.activeOperations[operationId] op = self.activeOperations[operationId]
context = f"{op['context']} {statusUpdate}".strip() context = f"{op['context']} {statusUpdate}".strip()
# Use the same parentId as the start operation - all logs (start/update/finish) share the same parent # Use the same parentOperationId as the start operation - all logs (start/update/finish) share the same parent
parentId = op.get('parentId') parentOperationId = op.get('parentOperationId')
self._logProgress(operationId, progress, context, parentId=parentId) self._logProgress(operationId, progress, context, parentOperationId=parentOperationId)
logger.debug(f"Updated operation {operationId}: {progress:.2f} - {context}") logger.debug(f"Updated operation {operationId}: {progress:.2f} - {context}")
def finishOperation(self, operationId: str, success: bool = True): def finishOperation(self, operationId: str, success: bool = True):
@ -93,11 +95,11 @@ class ProgressLogger:
finalProgress = 1.0 if success else 0.0 finalProgress = 1.0 if success else 0.0
status = "Done" if success else "Failed" status = "Done" if success else "Failed"
# Use the same parentId as the start operation - all logs (start/update/finish) share the same parent # Use the same parentOperationId as the start operation - all logs (start/update/finish) share the same parent
parentId = op.get('parentId') parentOperationId = op.get('parentOperationId')
# Create completion log BEFORE removing from activeOperations # Create completion log BEFORE removing from activeOperations
self._logProgress(operationId, finalProgress, status, parentId=parentId) self._logProgress(operationId, finalProgress, status, parentOperationId=parentOperationId)
# Log completion time # Log completion time
duration = time.time() - op['startTime'] duration = time.time() - op['startTime']
@ -111,14 +113,15 @@ class ProgressLogger:
# Mark as finished to prevent repeated warnings from updateOperation calls # Mark as finished to prevent repeated warnings from updateOperation calls
self.finishedOperations.add(operationId) self.finishedOperations.add(operationId)
def _logProgress(self, operationId: str, progress: float, status: str, parentId: Optional[str] = None) -> Optional[str]: def _logProgress(self, operationId: str, progress: float, status: str, parentOperationId: Optional[str] = None) -> Optional[str]:
"""Create standardized ChatLog entry. """Create standardized ChatLog entry.
Args: Args:
operationId: Unique identifier for the operation operationId: Unique identifier for the operation
progress: Progress value between 0.0 and 1.0 progress: Progress value between 0.0 and 1.0
status: Status information for the log entry status: Status information for the log entry
parentId: Optional parent log entry ID for hierarchical display parentOperationId: Optional parent operation ID (operationId of parent operation) for hierarchical display
This will be set as parentId in ChatLog (parentId = operationId of parent)
Returns: Returns:
The created log entry ID, or None if creation failed The created log entry ID, or None if creation failed
@ -134,6 +137,7 @@ class ProgressLogger:
logger.warning(f"Cannot log progress: no workflow available") logger.warning(f"Cannot log progress: no workflow available")
return None return None
# parentId in ChatLog should be the operationId of the parent operation, not the log entry ID
logData = { logData = {
"workflowId": workflow.id, "workflowId": workflow.id,
"message": message, "message": message,
@ -141,7 +145,7 @@ class ProgressLogger:
"status": status, "status": status,
"progress": progress, "progress": progress,
"operationId": operationId, "operationId": operationId,
"parentId": parentId "parentId": parentOperationId # Set to parent's operationId, not log entry ID
} }
try: try:

View file

@ -130,8 +130,9 @@ class MethodAi(MethodBase):
processDocumentsIndividually=True processDocumentsIndividually=True
) )
# Extract content using extraction service # Extract content using extraction service with hierarchical progress logging
extractedResults = self.services.extraction.extractContent(chatDocuments, extractionOptions) # Pass operationId for per-document progress tracking
extractedResults = self.services.extraction.extractContent(chatDocuments, extractionOptions, operationId=operationId)
# Combine all ContentParts from all extracted results # Combine all ContentParts from all extracted results
contentParts = [] contentParts = []

View file

@ -297,13 +297,11 @@ class MethodContext(MethodBase):
processDocumentsIndividually=True processDocumentsIndividually=True
) )
# Get parent log ID for document-level operations # Call extraction service with hierarchical progress logging
parentLogId = self.services.chat.getOperationLogId(operationId)
# Call extraction service
self.services.chat.progressLogUpdate(operationId, 0.4, "Initiating") self.services.chat.progressLogUpdate(operationId, 0.4, "Initiating")
self.services.chat.progressLogUpdate(operationId, 0.5, f"Extracting content from {len(chatDocuments)} documents") self.services.chat.progressLogUpdate(operationId, 0.5, f"Extracting content from {len(chatDocuments)} documents")
extractedResults = self.services.extraction.extractContent(chatDocuments, extractionOptions) # Pass operationId for hierarchical per-document progress logging
extractedResults = self.services.extraction.extractContent(chatDocuments, extractionOptions, operationId=operationId)
# Build ActionDocuments from ContentExtracted results # Build ActionDocuments from ContentExtracted results
self.services.chat.progressLogUpdate(operationId, 0.8, "Building result documents") self.services.chat.progressLogUpdate(operationId, 0.8, "Building result documents")

View file

@ -326,7 +326,21 @@ class MethodOutlook(MethodBase):
- filter (str, optional): Sender, query operators, or subject text. - filter (str, optional): Sender, query operators, or subject text.
- outputMimeType (str, optional): MIME type for output file. Options: "application/json" (default), "text/plain", "text/csv". Default: "application/json". - outputMimeType (str, optional): MIME type for output file. Options: "application/json" (default), "text/plain", "text/csv". Default: "application/json".
""" """
import time
operationId = None
try: try:
# Init progress logger
workflowId = self.services.workflow.id if self.services.workflow else f"no-workflow-{int(time.time())}"
operationId = f"outlook_read_{workflowId}_{int(time.time())}"
# Start progress tracking
self.services.chat.progressLogStart(
operationId,
"Read Emails",
"Outlook Email Reading",
f"Folder: {parameters.get('folder', 'Inbox')}"
)
connectionReference = parameters.get("connectionReference") connectionReference = parameters.get("connectionReference")
folder = parameters.get("folder", "Inbox") folder = parameters.get("folder", "Inbox")
limit = parameters.get("limit", 10) limit = parameters.get("limit", 10)
@ -334,8 +348,12 @@ class MethodOutlook(MethodBase):
outputMimeType = parameters.get("outputMimeType", "application/json") outputMimeType = parameters.get("outputMimeType", "application/json")
if not connectionReference: if not connectionReference:
if operationId:
self.services.chat.progressLogFinish(operationId, False)
return ActionResult.isFailure(error="Connection reference is required") return ActionResult.isFailure(error="Connection reference is required")
self.services.chat.progressLogUpdate(operationId, 0.2, "Validating parameters")
# Validate limit parameter # Validate limit parameter
if limit <= 0: if limit <= 0:
limit = 1000 limit = 1000
@ -351,11 +369,14 @@ class MethodOutlook(MethodBase):
# Get Microsoft connection # Get Microsoft connection
self.services.chat.progressLogUpdate(operationId, 0.3, "Getting Microsoft connection")
connection = self._getMicrosoftConnection(connectionReference) connection = self._getMicrosoftConnection(connectionReference)
if not connection: if not connection:
self.services.chat.progressLogFinish(operationId, False)
return ActionResult.isFailure(error="No valid Microsoft connection found for the provided connection reference") return ActionResult.isFailure(error="No valid Microsoft connection found for the provided connection reference")
# Read emails using Microsoft Graph API # Read emails using Microsoft Graph API
self.services.chat.progressLogUpdate(operationId, 0.4, "Reading emails from Microsoft Graph API")
try: try:
# Microsoft Graph API endpoint for messages # Microsoft Graph API endpoint for messages
graph_url = "https://graph.microsoft.com/v1.0" graph_url = "https://graph.microsoft.com/v1.0"
@ -387,6 +408,11 @@ class MethodOutlook(MethodBase):
# If using $search, remove $orderby as they can't be combined # If using $search, remove $orderby as they can't be combined
if "$search" in params: if "$search" in params:
params.pop("$orderby", None) params.pop("$orderby", None)
# If using $filter with contains(), remove $orderby as they can't be combined
# Microsoft Graph API doesn't support contains() with orderby
if "$filter" in params and "contains(" in params["$filter"].lower():
params.pop("$orderby", None)
# Filter applied # Filter applied
@ -403,6 +429,7 @@ class MethodOutlook(MethodBase):
response.raise_for_status() response.raise_for_status()
self.services.chat.progressLogUpdate(operationId, 0.7, "Processing email data")
emails_data = response.json() emails_data = response.json()
email_data = { email_data = {
"emails": emails_data.get("value", []), "emails": emails_data.get("value", []),
@ -420,22 +447,34 @@ class MethodOutlook(MethodBase):
except ImportError: except ImportError:
logger.error("requests module not available") logger.error("requests module not available")
if operationId:
self.services.chat.progressLogFinish(operationId, False)
return ActionResult.isFailure(error="requests module not available") return ActionResult.isFailure(error="requests module not available")
except requests.exceptions.HTTPError as e: except requests.exceptions.HTTPError as e:
if e.response.status_code == 400: if e.response.status_code == 400:
logger.error(f"Bad Request (400) - Invalid filter or parameter: {e.response.text}") logger.error(f"Bad Request (400) - Invalid filter or parameter: {e.response.text}")
if operationId:
self.services.chat.progressLogFinish(operationId, False)
return ActionResult.isFailure(error=f"Invalid filter syntax. Please check your filter parameter. Error: {e.response.text}") return ActionResult.isFailure(error=f"Invalid filter syntax. Please check your filter parameter. Error: {e.response.text}")
elif e.response.status_code == 401: elif e.response.status_code == 401:
logger.error("Unauthorized (401) - Access token may be expired or invalid") logger.error("Unauthorized (401) - Access token may be expired or invalid")
if operationId:
self.services.chat.progressLogFinish(operationId, False)
return ActionResult.isFailure(error="Authentication failed. Please check your connection and try again.") return ActionResult.isFailure(error="Authentication failed. Please check your connection and try again.")
elif e.response.status_code == 403: elif e.response.status_code == 403:
logger.error("Forbidden (403) - Insufficient permissions to access emails") logger.error("Forbidden (403) - Insufficient permissions to access emails")
if operationId:
self.services.chat.progressLogFinish(operationId, False)
return ActionResult.isFailure(error="Insufficient permissions to read emails from this folder.") return ActionResult.isFailure(error="Insufficient permissions to read emails from this folder.")
else: else:
logger.error(f"HTTP Error {e.response.status_code}: {e.response.text}") logger.error(f"HTTP Error {e.response.status_code}: {e.response.text}")
if operationId:
self.services.chat.progressLogFinish(operationId, False)
return ActionResult.isFailure(error=f"HTTP Error {e.response.status_code}: {e.response.text}") return ActionResult.isFailure(error=f"HTTP Error {e.response.status_code}: {e.response.text}")
except Exception as e: except Exception as e:
logger.error(f"Error reading emails from Microsoft Graph API: {str(e)}") logger.error(f"Error reading emails from Microsoft Graph API: {str(e)}")
if operationId:
self.services.chat.progressLogFinish(operationId, False)
return ActionResult.isFailure(error=f"Failed to read emails: {str(e)}") return ActionResult.isFailure(error=f"Failed to read emails: {str(e)}")
# Determine output format based on MIME type # Determine output format based on MIME type
@ -475,6 +514,9 @@ class MethodOutlook(MethodBase):
"outputMimeType": outputMimeType "outputMimeType": outputMimeType
} }
self.services.chat.progressLogUpdate(operationId, 0.9, f"Found {email_data.get('count', 0)} emails")
self.services.chat.progressLogFinish(operationId, True)
return ActionResult.isSuccess( return ActionResult.isSuccess(
documents=[ActionDocument( documents=[ActionDocument(
documentName=f"outlook_emails_{self._format_timestamp_for_filename()}.json", documentName=f"outlook_emails_{self._format_timestamp_for_filename()}.json",
@ -486,6 +528,11 @@ class MethodOutlook(MethodBase):
except Exception as e: except Exception as e:
logger.error(f"Error reading emails: {str(e)}") logger.error(f"Error reading emails: {str(e)}")
if operationId:
try:
self.services.chat.progressLogFinish(operationId, False)
except:
pass # Don't fail on progress logging errors
return ActionResult.isFailure( return ActionResult.isFailure(
error=str(e) error=str(e)
) )
@ -1491,14 +1538,32 @@ Return JSON:
- connectionReference (str, required): Microsoft connection label. - connectionReference (str, required): Microsoft connection label.
- documentList (list, required): Document reference(s) to draft emails in JSON format (outputs from outlook.composeAndDraftEmailWithContext function). - documentList (list, required): Document reference(s) to draft emails in JSON format (outputs from outlook.composeAndDraftEmailWithContext function).
""" """
import time
operationId = None
try: try:
# Init progress logger
workflowId = self.services.workflow.id if self.services.workflow else f"no-workflow-{int(time.time())}"
operationId = f"outlook_send_{workflowId}_{int(time.time())}"
# Start progress tracking
self.services.chat.progressLogStart(
operationId,
"Send Draft Email",
"Outlook Email Sending",
f"Processing {len(parameters.get('documentList', []))} draft(s)"
)
connectionReference = parameters.get("connectionReference") connectionReference = parameters.get("connectionReference")
documentList = parameters.get("documentList", []) documentList = parameters.get("documentList", [])
if not connectionReference: if not connectionReference:
if operationId:
self.services.chat.progressLogFinish(operationId, False)
return ActionResult.isFailure(error="Connection reference is required") return ActionResult.isFailure(error="Connection reference is required")
if not documentList: if not documentList:
if operationId:
self.services.chat.progressLogFinish(operationId, False)
return ActionResult.isFailure(error="documentList is required and cannot be empty") return ActionResult.isFailure(error="documentList is required and cannot be empty")
# Convert single value to list if needed # Convert single value to list if needed
@ -1506,16 +1571,21 @@ Return JSON:
documentList = [documentList] documentList = [documentList]
# Get Microsoft connection # Get Microsoft connection
self.services.chat.progressLogUpdate(operationId, 0.2, "Getting Microsoft connection")
connection = self._getMicrosoftConnection(connectionReference) connection = self._getMicrosoftConnection(connectionReference)
if not connection: if not connection:
self.services.chat.progressLogFinish(operationId, False)
return ActionResult.isFailure(error="No valid Microsoft connection found for the provided connection reference") return ActionResult.isFailure(error="No valid Microsoft connection found for the provided connection reference")
# Check permissions # Check permissions
self.services.chat.progressLogUpdate(operationId, 0.3, "Checking permissions")
permissions_ok = await self._checkPermissions(connection) permissions_ok = await self._checkPermissions(connection)
if not permissions_ok: if not permissions_ok:
self.services.chat.progressLogFinish(operationId, False)
return ActionResult.isFailure(error="Connection lacks necessary permissions for Outlook operations") return ActionResult.isFailure(error="Connection lacks necessary permissions for Outlook operations")
# Read draft email JSON documents from documentList # Read draft email JSON documents from documentList
self.services.chat.progressLogUpdate(operationId, 0.4, "Reading draft email documents")
draftEmails = [] draftEmails = []
for docRef in documentList: for docRef in documentList:
try: try:
@ -1586,8 +1656,11 @@ Return JSON:
continue continue
if not draftEmails: if not draftEmails:
self.services.chat.progressLogFinish(operationId, False)
return ActionResult.isFailure(error="No valid draft email JSON documents found in documentList") return ActionResult.isFailure(error="No valid draft email JSON documents found in documentList")
self.services.chat.progressLogUpdate(operationId, 0.6, f"Found {len(draftEmails)} draft email(s) to send")
# Send all draft emails # Send all draft emails
graph_url = "https://graph.microsoft.com/v1.0" graph_url = "https://graph.microsoft.com/v1.0"
headers = { headers = {
@ -1598,7 +1671,8 @@ Return JSON:
sentResults = [] sentResults = []
failedResults = [] failedResults = []
for draftEmail in draftEmails: self.services.chat.progressLogUpdate(operationId, 0.7, "Sending emails")
for idx, draftEmail in enumerate(draftEmails):
draftEmailJson = draftEmail["draftEmailJson"] draftEmailJson = draftEmail["draftEmailJson"]
draftId = draftEmail["draftId"] draftId = draftEmail["draftId"]
sourceDocument = draftEmail["sourceDocument"] sourceDocument = draftEmail["sourceDocument"]
@ -1628,6 +1702,7 @@ Return JSON:
"sourceDocument": sourceDocument "sourceDocument": sourceDocument
}) })
logger.info(f"Email sent successfully. Draft ID: {draftId}, Subject: {subject}") logger.info(f"Email sent successfully. Draft ID: {draftId}, Subject: {subject}")
self.services.chat.progressLogUpdate(operationId, 0.7 + (idx + 1) * 0.2 / len(draftEmails), f"Sent {idx + 1}/{len(draftEmails)}: {subject}")
else: else:
errorResult = { errorResult = {
"status": "error", "status": "error",
@ -1674,7 +1749,9 @@ Return JSON:
} }
# Determine overall success status # Determine overall success status
self.services.chat.progressLogUpdate(operationId, 0.9, f"Sent {successfulEmails}/{totalEmails} email(s)")
if successfulEmails == 0: if successfulEmails == 0:
self.services.chat.progressLogFinish(operationId, False)
validationMetadata = { validationMetadata = {
"actionType": "outlook.sendDraftEmail", "actionType": "outlook.sendDraftEmail",
"connectionReference": connectionReference, "connectionReference": connectionReference,
@ -1703,6 +1780,7 @@ Return JSON:
"failedEmails": failedEmails, "failedEmails": failedEmails,
"status": "partial_success" "status": "partial_success"
} }
self.services.chat.progressLogFinish(operationId, True)
return ActionResult( return ActionResult(
success=True, success=True,
documents=[ActionDocument( documents=[ActionDocument(
@ -1723,6 +1801,7 @@ Return JSON:
"failedEmails": failedEmails, "failedEmails": failedEmails,
"status": "all_successful" "status": "all_successful"
} }
self.services.chat.progressLogFinish(operationId, True)
return ActionResult( return ActionResult(
success=True, success=True,
documents=[ActionDocument( documents=[ActionDocument(

View file

@ -1120,7 +1120,21 @@ class MethodSharepoint(MethodBase):
- documentData: Base64-encoded content (binary files) or plain text (text files) - documentData: Base64-encoded content (binary files) or plain text (text files)
- mimeType: MIME type (e.g., application/pdf, text/plain) - mimeType: MIME type (e.g., application/pdf, text/plain)
""" """
import time
operationId = None
try: try:
# Init progress logger
workflowId = self.services.workflow.id if self.services.workflow else f"no-workflow-{int(time.time())}"
operationId = f"sharepoint_read_{workflowId}_{int(time.time())}"
# Start progress tracking
self.services.chat.progressLogStart(
operationId,
"Read Documents",
"SharePoint Document Reading",
f"Path: {parameters.get('pathQuery', parameters.get('pathObject', '*'))}"
)
documentList = parameters.get("documentList") documentList = parameters.get("documentList")
if isinstance(documentList, str): if isinstance(documentList, str):
documentList = [documentList] documentList = [documentList]
@ -1131,11 +1145,16 @@ class MethodSharepoint(MethodBase):
# Validate connection reference # Validate connection reference
if not connectionReference: if not connectionReference:
if operationId:
self.services.chat.progressLogFinish(operationId, False)
return ActionResult.isFailure(error="Connection reference is required") return ActionResult.isFailure(error="Connection reference is required")
# Get connection first - needed for both pathObject and documentList approaches # Get connection first - needed for both pathObject and documentList approaches
self.services.chat.progressLogUpdate(operationId, 0.2, "Getting Microsoft connection")
connection = self._getMicrosoftConnection(connectionReference) connection = self._getMicrosoftConnection(connectionReference)
if not connection: if not connection:
if operationId:
self.services.chat.progressLogFinish(operationId, False)
return ActionResult.isFailure(error="No valid Microsoft connection found for the provided connection reference") return ActionResult.isFailure(error="No valid Microsoft connection found for the provided connection reference")
# If pathObject is provided, extract SharePoint file IDs and read them directly # If pathObject is provided, extract SharePoint file IDs and read them directly
@ -1150,6 +1169,8 @@ class MethodSharepoint(MethodBase):
from modules.datamodels.datamodelDocref import DocumentReferenceList from modules.datamodels.datamodelDocref import DocumentReferenceList
pathObjectDocuments = self.services.chat.getChatDocumentsFromDocumentList(DocumentReferenceList.from_string_list([pathObject])) pathObjectDocuments = self.services.chat.getChatDocumentsFromDocumentList(DocumentReferenceList.from_string_list([pathObject]))
if not pathObjectDocuments or len(pathObjectDocuments) == 0: if not pathObjectDocuments or len(pathObjectDocuments) == 0:
if operationId:
self.services.chat.progressLogFinish(operationId, False)
return ActionResult.isFailure(error=f"No document list found for reference: {pathObject}") return ActionResult.isFailure(error=f"No document list found for reference: {pathObject}")
# Get the first document's content (which should be the JSON from findDocumentPath) # Get the first document's content (which should be the JSON from findDocumentPath)
@ -1267,8 +1288,10 @@ class MethodSharepoint(MethodBase):
readResults = [] readResults = []
siteId = sites[0]['id'] siteId = sites[0]['id']
for fileId in sharePointFileIds: self.services.chat.progressLogUpdate(operationId, 0.5, f"Reading {len(sharePointFileIds)} file(s) from SharePoint")
for idx, fileId in enumerate(sharePointFileIds):
try: try:
self.services.chat.progressLogUpdate(operationId, 0.5 + (idx * 0.3 / len(sharePointFileIds)), f"Reading file {idx + 1}/{len(sharePointFileIds)}")
# Get file info from SharePoint # Get file info from SharePoint
endpoint = f"sites/{siteId}/drive/items/{fileId}" endpoint = f"sites/{siteId}/drive/items/{fileId}"
fileInfo = await self._makeGraphApiCall(endpoint) fileInfo = await self._makeGraphApiCall(endpoint)
@ -1314,11 +1337,13 @@ class MethodSharepoint(MethodBase):
continue continue
if not readResults: if not readResults:
self.services.chat.progressLogFinish(operationId, False)
return ActionResult.isFailure(error="No files could be read from pathObject") return ActionResult.isFailure(error="No files could be read from pathObject")
# Convert read results to ActionDocument objects # Convert read results to ActionDocument objects
# IMPORTANT: For binary files (PDFs), store Base64-encoded content directly in documentData # IMPORTANT: For binary files (PDFs), store Base64-encoded content directly in documentData
# The system will create FileData and ChatDocument automatically # The system will create FileData and ChatDocument automatically
self.services.chat.progressLogUpdate(operationId, 0.8, f"Processing {len(readResults)} document(s)")
from modules.datamodels.datamodelChat import ActionDocument from modules.datamodels.datamodelChat import ActionDocument
import base64 import base64
@ -1413,6 +1438,8 @@ class MethodSharepoint(MethodBase):
actionDocuments.append(actionDoc) actionDocuments.append(actionDoc)
# Return success with action documents # Return success with action documents
self.services.chat.progressLogUpdate(operationId, 0.9, f"Read {len(actionDocuments)} document(s)")
self.services.chat.progressLogFinish(operationId, True)
return ActionResult.isSuccess(documents=actionDocuments) return ActionResult.isSuccess(documents=actionDocuments)
# Fallback: Use documentList parameter (for backward compatibility) # Fallback: Use documentList parameter (for backward compatibility)
@ -1643,6 +1670,11 @@ class MethodSharepoint(MethodBase):
) )
except Exception as e: except Exception as e:
logger.error(f"Error reading SharePoint documents: {str(e)}") logger.error(f"Error reading SharePoint documents: {str(e)}")
if operationId:
try:
self.services.chat.progressLogFinish(operationId, False)
except:
pass # Don't fail on progress logging errors
return ActionResult( return ActionResult(
success=False, success=False,
error=str(e) error=str(e)

View file

@ -139,14 +139,11 @@ class ContentValidator:
"statistics": {} "statistics": {}
} }
# Extract metadata # Extract metadata - include ALL metadata fields (generic for all action types)
metadata = jsonData.get("metadata", {}) metadata = jsonData.get("metadata", {})
if metadata: if metadata and isinstance(metadata, dict):
summary["metadata"] = { # Include all metadata fields, not just specific ones
"title": metadata.get("title"), summary["metadata"] = dict(metadata)
"split_strategy": metadata.get("split_strategy"),
"extraction_method": metadata.get("extraction_method")
}
# Extract documents array (if present) # Extract documents array (if present)
documents = jsonData.get("documents", []) documents = jsonData.get("documents", [])
@ -195,6 +192,17 @@ class ContentValidator:
text = textElement.get("text", "") text = textElement.get("text", "")
if text: if text:
sectionSummary["textPreview"] = text[:100] + ("..." if len(text) > 100 else "") sectionSummary["textPreview"] = text[:100] + ("..." if len(text) > 100 else "")
# Also check for textPreview directly in section (for web crawl results)
if section.get("textPreview"):
sectionSummary["textPreview"] = section.get("textPreview")
# Include any additional fields from section (generic approach)
# This ensures all action-specific fields are preserved
for key, value in section.items():
if key not in sectionSummary and key not in ["elements"]: # Skip elements as they're processed separately
# Include simple types (str, int, float, bool, list of primitives)
if isinstance(value, (str, int, float, bool)) or (isinstance(value, list) and len(value) <= 10):
sectionSummary[key] = value
summary["sections"].append(sectionSummary) summary["sections"].append(sectionSummary)
else: else:
@ -206,7 +214,8 @@ class ContentValidator:
sectionSummary = { sectionSummary = {
"id": section.get("id"), "id": section.get("id"),
"content_type": section.get("content_type"), "content_type": section.get("content_type"),
"title": section.get("title") "title": section.get("title"),
"order": section.get("order")
} }
if section.get("content_type") == "table": if section.get("content_type") == "table":
@ -220,8 +229,21 @@ class ContentValidator:
sectionSummary["rowCount"] = len(rows) sectionSummary["rowCount"] = len(rows)
sectionSummary["headers"] = headers sectionSummary["headers"] = headers
# Include any additional fields from section (generic approach)
for key, value in section.items():
if key not in sectionSummary and key not in ["elements"]: # Skip elements as they're processed separately
# Include simple types (str, int, float, bool, list of primitives)
if isinstance(value, (str, int, float, bool)) or (isinstance(value, list) and len(value) <= 10):
sectionSummary[key] = value
summary["sections"].append(sectionSummary) summary["sections"].append(sectionSummary)
# Extract statistics from root level (generic - include all statistics fields)
rootStatistics = jsonData.get("statistics", {})
if rootStatistics and isinstance(rootStatistics, dict):
# Merge root statistics into summary statistics
summary["statistics"].update(rootStatistics)
return summary return summary
except Exception as e: except Exception as e:

View file

@ -28,11 +28,21 @@ class TaskPlanner:
logger.info(f"=== STARTING TASK PLAN GENERATION ===") logger.info(f"=== STARTING TASK PLAN GENERATION ===")
logger.info(f"Workflow ID: {workflow.id}") logger.info(f"Workflow ID: {workflow.id}")
logger.info(f"User Input: {userInput}") # Log normalized request instead of raw user input for security
normalizedPrompt = getattr(self.services, 'currentUserPromptNormalized', None) if self.services else None
if normalizedPrompt:
logger.info(f"Normalized Request: {normalizedPrompt}")
else:
logger.info(f"Normalized Request: {userInput}")
# Use stored user prompt if available, otherwise use the input # Use normalized request if available, otherwise fallback to currentUserPrompt, then userInput
actualUserPrompt = self.services.currentUserPrompt if self.services and hasattr(self.services, 'currentUserPrompt') and self.services.currentUserPrompt else userInput actualUserPrompt = None
logger.info(f"Actual User Prompt: {actualUserPrompt}") if self.services and hasattr(self.services, 'currentUserPromptNormalized') and self.services.currentUserPromptNormalized:
actualUserPrompt = self.services.currentUserPromptNormalized
elif self.services and hasattr(self.services, 'currentUserPrompt') and self.services.currentUserPrompt:
actualUserPrompt = self.services.currentUserPrompt
else:
actualUserPrompt = userInput
# Check workflow status before calling AI service # Check workflow status before calling AI service
checkWorkflowStopped(self.services) checkWorkflowStopped(self.services)

View file

@ -96,6 +96,10 @@ class DynamicMode(BaseMode):
# NEW: Reset progress tracking for new task # NEW: Reset progress tracking for new task
self.progressTracker.reset() self.progressTracker.reset()
# Initialize executed actions tracking for this task
if not hasattr(context, 'executedActions') or context.executedActions is None:
context.executedActions = []
# Update workflow object before executing task # Update workflow object before executing task
self._updateWorkflowBeforeExecutingTask(taskIndex) self._updateWorkflowBeforeExecutingTask(taskIndex)
@ -104,7 +108,8 @@ class DynamicMode(BaseMode):
state = TaskExecutionState(taskStep) state = TaskExecutionState(taskStep)
# Dynamic mode uses max_steps instead of max_retries # Dynamic mode uses max_steps instead of max_retries
state.max_steps = max(1, int(getattr(workflow, 'maxSteps', 10))) # maxSteps is set in workflowManager.py when workflow is created
state.max_steps = int(getattr(workflow, 'maxSteps', 1))
logger.info(f"Using Dynamic mode execution with max_steps: {state.max_steps}") logger.info(f"Using Dynamic mode execution with max_steps: {state.max_steps}")
step = 1 step = 1
@ -128,6 +133,19 @@ class DynamicMode(BaseMode):
observation = self._observeBuild(result) observation = self._observeBuild(result)
# Note: resultLabel is already set correctly in _observeBuild from actionResult.resultLabel # Note: resultLabel is already set correctly in _observeBuild from actionResult.resultLabel
# Store executed action in context for action history
if not hasattr(context, 'executedActions') or context.executedActions is None:
context.executedActions = []
actionName = selection.get('action', 'unknown')
actionParameters = selection.get('parameters', {}) or {}
# Filter out documentList for clarity in history
relevantParams = {k: v for k, v in actionParameters.items() if k not in ['documentList', 'connections']}
context.executedActions.append({
'action': actionName,
'parameters': relevantParams,
'step': step
})
# Content validation (against original cleaned user prompt / workflow intent) # Content validation (against original cleaned user prompt / workflow intent)
if getattr(self, 'workflowIntent', None) and result.documents: if getattr(self, 'workflowIntent', None) and result.documents:
# Pass ALL documents to validator - validator decides what to validate (generic approach) # Pass ALL documents to validator - validator decides what to validate (generic approach)
@ -883,9 +901,20 @@ class DynamicMode(BaseMode):
elif progressState['nextActionsSuggested']: elif progressState['nextActionsSuggested']:
enhancedReviewContent += f"Next Action Suggestions: {', '.join(progressState['nextActionsSuggested'])}\n" enhancedReviewContent += f"Next Action Suggestions: {', '.join(progressState['nextActionsSuggested'])}\n"
# NEW: Add action history to review content # NEW: Add action history to review content - use all executed actions
actionHistory = []
# First, add all executed actions from the current task
if hasattr(context, 'executedActions') and context.executedActions:
for executedAction in context.executedActions:
action = executedAction.get('action', 'unknown')
params = executedAction.get('parameters', {}) or {}
paramsStr = json.dumps(params, ensure_ascii=False) if params else "{}"
step = executedAction.get('step', 0)
actionHistory.append(f"Step {step}: {action} {paramsStr}")
# Also include refinement decisions for completeness (these show what was planned)
if hasattr(context, 'previousReviewResult') and context.previousReviewResult: if hasattr(context, 'previousReviewResult') and context.previousReviewResult:
actionHistory = []
for i, prevDecision in enumerate(context.previousReviewResult, 1): for i, prevDecision in enumerate(context.previousReviewResult, 1):
if prevDecision and hasattr(prevDecision, 'nextAction') and prevDecision.nextAction: if prevDecision and hasattr(prevDecision, 'nextAction') and prevDecision.nextAction:
action = prevDecision.nextAction action = prevDecision.nextAction
@ -895,21 +924,27 @@ class DynamicMode(BaseMode):
paramsStr = json.dumps(relevantParams, ensure_ascii=False) if relevantParams else "{}" paramsStr = json.dumps(relevantParams, ensure_ascii=False) if relevantParams else "{}"
quality = getattr(prevDecision, 'qualityScore', None) quality = getattr(prevDecision, 'qualityScore', None)
qualityStr = f" (quality: {quality:.2f})" if quality is not None else "" qualityStr = f" (quality: {quality:.2f})" if quality is not None else ""
actionHistory.append(f"Round {i}: {action} {paramsStr}{qualityStr}") # Only add if not already in executedActions (avoid duplicates)
actionEntry = f"Refinement {i}: {action} {paramsStr}{qualityStr}"
if actionHistory: if actionEntry not in actionHistory:
enhancedReviewContent += f"\nACTION HISTORY:\n" actionHistory.append(actionEntry)
enhancedReviewContent += "\n".join(f"- {entry}" for entry in actionHistory)
# Detect repeated actions if actionHistory:
actionCounts = {} enhancedReviewContent += f"\nACTION HISTORY:\n"
for entry in actionHistory: enhancedReviewContent += "\n".join(f"- {entry}" for entry in actionHistory)
# Extract action name (before first space or {) # Detect repeated actions
actionName = entry.split()[1] if len(entry.split()) > 1 else "unknown" actionCounts = {}
for entry in actionHistory:
# Extract action name (after first space, before next space or {)
parts = entry.split()
if len(parts) > 1:
# Skip "Step", "Refinement" prefixes and get the action name
actionName = parts[1] if parts[0] in ['Step', 'Refinement'] else parts[0]
actionCounts[actionName] = actionCounts.get(actionName, 0) + 1 actionCounts[actionName] = actionCounts.get(actionName, 0) + 1
repeatedActions = [action for action, count in actionCounts.items() if count >= 2] repeatedActions = [action for action, count in actionCounts.items() if count >= 2]
if repeatedActions: if repeatedActions:
enhancedReviewContent += f"\nWARNING: Repeated actions detected: {', '.join(repeatedActions)}. Consider a fundamentally different approach.\n" enhancedReviewContent += f"\nWARNING: Repeated actions detected: {', '.join(repeatedActions)}. Consider a fundamentally different approach.\n"
# Update placeholders with enhanced review content # Update placeholders with enhanced review content
placeholders["REVIEW_CONTENT"] = enhancedReviewContent placeholders["REVIEW_CONTENT"] = enhancedReviewContent

View file

@ -19,7 +19,7 @@ class TaskExecutionState:
self.max_retries = 3 self.max_retries = 3
# Iterative loop (dynamic mode) # Iterative loop (dynamic mode)
self.current_step = 0 self.current_step = 0
self.max_steps = 5 self.max_steps = 0 # Will be overridden by workflow.maxSteps from workflowManager.py
def addSuccessfulAction(self, action_result: ActionResult): def addSuccessfulAction(self, action_result: ActionResult):
"""Add a successful action to the state""" """Add a successful action to the state"""
@ -56,7 +56,7 @@ class TaskExecutionState:
patterns.append("permission_issues") patterns.append("permission_issues")
return list(set(patterns)) return list(set(patterns))
def shouldContinue(observation: Optional[Observation], review=None, current_step: int = 0, max_steps: int = 5) -> bool: def shouldContinue(observation: Optional[Observation], review=None, current_step: int = 0, max_steps: int = 1) -> bool:
"""Helper to decide if the iterative loop should continue """Helper to decide if the iterative loop should continue
Args: Args:

View file

@ -61,6 +61,7 @@ Break down user requests into logical, executable task steps.
## 📋 Context ## 📋 Context
### User Request ### User Request
The following is the user's normalized request:
{{KEY:USER_PROMPT}} {{KEY:USER_PROMPT}}
### Workflow Intent ### Workflow Intent

View file

@ -64,21 +64,30 @@ class WorkflowProcessor:
logger.info(f"=== STARTING TASK PLAN GENERATION ===") logger.info(f"=== STARTING TASK PLAN GENERATION ===")
logger.info(f"Using user language: {self.services.currentUserLanguage}") logger.info(f"Using user language: {self.services.currentUserLanguage}")
logger.info(f"Workflow ID: {workflow.id}") logger.info(f"Workflow ID: {workflow.id}")
logger.info(f"User Input: {userInput}") # Log normalized request instead of raw user input for security
normalizedPrompt = getattr(self.services, 'currentUserPromptNormalized', None) or userInput
logger.info(f"Normalized Request: {normalizedPrompt}")
modeValue = workflow.workflowMode.value if hasattr(workflow.workflowMode, 'value') else workflow.workflowMode modeValue = workflow.workflowMode.value if hasattr(workflow.workflowMode, 'value') else workflow.workflowMode
logger.info(f"Workflow Mode: {modeValue}") logger.info(f"Workflow Mode: {modeValue}")
# Update progress - generating task plan # Update progress - generating task plan
self.services.chat.progressLogUpdate(operationId, 0.3, "Analyzing input") self.services.chat.progressLogUpdate(operationId, 0.3, "Analyzing input")
# Use normalized request instead of raw userInput for security
normalizedPrompt = getattr(self.services, 'currentUserPromptNormalized', None) or userInput
# Delegate to the appropriate mode # Delegate to the appropriate mode
taskPlan = await self.mode.generateTaskPlan(userInput, workflow) taskPlan = await self.mode.generateTaskPlan(normalizedPrompt, workflow)
# Update progress - creating task plan message # Update progress - creating task plan message
self.services.chat.progressLogUpdate(operationId, 0.8, "Creating plan") self.services.chat.progressLogUpdate(operationId, 0.8, "Creating plan")
# Create task plan message # Create task plan message only if there are 2+ tasks
await self.mode.createTaskPlanMessage(taskPlan, workflow) # Single-task workflows don't need a task plan message
if taskPlan.tasks and len(taskPlan.tasks) >= 2:
await self.mode.createTaskPlanMessage(taskPlan, workflow)
else:
logger.info(f"Skipping task plan message creation - only {len(taskPlan.tasks) if taskPlan.tasks else 0} task(s)")
# Complete progress tracking # Complete progress tracking
self.services.chat.progressLogFinish(operationId, True) self.services.chat.progressLogFinish(operationId, True)
@ -315,12 +324,16 @@ class WorkflowProcessor:
# Fast Path Implementation # Fast Path Implementation
async def detectComplexity(self, prompt: str, documents: Optional[List[ChatDocument]] = None) -> str: async def detectComplexity(self, prompt: str, documents: Optional[List[ChatDocument]] = None) -> tuple[str, bool, Optional[str]]:
""" """
Detect request complexity using AI-based semantic understanding. Detect request complexity using AI-based semantic understanding.
Also detects user language for fast path responses.
Returns: Returns:
"simple" | "moderate" | "complex" Tuple of (complexity: str, needsWorkflowHistory: bool, detectedLanguage: Optional[str])
complexity: "simple" | "moderate" | "complex"
needsWorkflowHistory: True if request needs previous workflow rounds/history
detectedLanguage: ISO 639-1 language code (e.g., "de", "en") or None
Simple: Single question, no documents, straightforward answer (5-15s) Simple: Single question, no documents, straightforward answer (5-15s)
Moderate: Multiple steps, some documents, structured response (30-60s) Moderate: Multiple steps, some documents, structured response (30-60s)
@ -330,38 +343,47 @@ class WorkflowProcessor:
# Ensure AI service is initialized # Ensure AI service is initialized
await self.services.ai.ensureAiObjectsInitialized() await self.services.ai.ensureAiObjectsInitialized()
# Build complexity detection prompt (language-agnostic, semantic) # Build complexity detection prompt (includes language detection)
# JSON template comes BEFORE user input for security
complexityPrompt = ( complexityPrompt = (
"You are a complexity analyzer. Analyze the user's request and determine its complexity level.\n\n" "You are a complexity analyzer. Analyze the user's request and determine its complexity level and language.\n\n"
"Consider:\n" "Consider:\n"
"- Number of distinct tasks or steps required\n" "- Number of distinct tasks or steps required\n"
"- Amount and type of documents provided\n" "- Amount and type of documents provided\n"
"- Need for external research or web search\n" "- Need for external research or web search\n"
"- Need for document analysis or extraction\n" "- Need for document analysis or extraction\n"
"- Need for content generation (reports, summaries, etc.)\n" "- Need for content generation (reports, summaries, etc.)\n"
"- Need for multi-step reasoning or planning\n\n" "- Need for multi-step reasoning or planning\n"
"- Need for previous workflow rounds/history (e.g., 'continue', 'retry', 'fix', 'improve', 'update', 'modify', 'based on previous', 'build on', references to earlier work)\n"
"- Language: Detect the ISO 639-1 language code (e.g., de, en, fr, it) from the user's request\n\n"
"Complexity levels:\n" "Complexity levels:\n"
"- 'simple': Single question, no documents or minimal documents, straightforward answer that can be provided in one AI response (5-15s)\n" "- 'simple': Single question, no documents or minimal documents, straightforward answer that can be provided in one AI response (5-15s)\n"
"- 'moderate': Multiple steps, some documents, structured response requiring some processing (30-60s)\n" "- 'moderate': Multiple steps, some documents, structured response requiring some processing (30-60s)\n"
"- 'complex': Multi-task workflow, many documents, research needed, content generation required, multi-step planning (60-120s)\n\n" "- 'complex': Multi-task workflow, many documents, research needed, content generation required, multi-step planning (60-120s)\n\n"
f"User request:\n{prompt}\n\n" "Return ONLY a JSON object with this exact structure:\n"
"{\n"
' "complexity": "simple" | "moderate" | "complex",\n'
' "reasoning": "Brief explanation of why this complexity level",\n'
' "needsWorkflowHistory": true|false,\n'
' "detectedLanguage": "de|en|fr|it|..." (ISO 639-1 language code)\n'
"}\n\n"
"################ USER INPUT START #################\n"
) )
# Add sanitized user input with clear delimiters
# Escape curly braces for f-string safety, but preserve format (no quote wrapping)
sanitizedPrompt = prompt.replace('{', '{{').replace('}', '}}') if prompt else ""
complexityPrompt += f"{sanitizedPrompt}\n"
complexityPrompt += "################ USER INPUT FINISH #################\n\n"
if documents and len(documents) > 0: if documents and len(documents) > 0:
complexityPrompt += f"\nDocuments provided: {len(documents)} document(s)\n" complexityPrompt += f"Documents provided: {len(documents)} document(s)\n"
# Add document types # Add document types
docTypes = [doc.mimeType for doc in documents if hasattr(doc, 'mimeType')] docTypes = [doc.mimeType for doc in documents if hasattr(doc, 'mimeType')]
if docTypes: if docTypes:
complexityPrompt += f"Document types: {', '.join(set(docTypes))}\n" complexityPrompt += f"Document types: {', '.join(set(docTypes))}\n"
complexityPrompt += (
"\nReturn ONLY a JSON object with this exact structure:\n"
"{\n"
' "complexity": "simple" | "moderate" | "complex",\n'
' "reasoning": "Brief explanation of why this complexity level"\n'
"}\n"
)
# Call AI for complexity detection (planning call - no documents needed) # Call AI for complexity detection (planning call - no documents needed)
aiResponse = await self.services.ai.callAiPlanning( aiResponse = await self.services.ai.callAiPlanning(
prompt=complexityPrompt, prompt=complexityPrompt,
@ -371,6 +393,8 @@ class WorkflowProcessor:
# Parse response # Parse response
complexity = "moderate" # Default fallback complexity = "moderate" # Default fallback
needsWorkflowHistory = False # Default fallback
detectedLanguage = None # Default fallback
try: try:
# callAiPlanning returns a string directly, not an object # callAiPlanning returns a string directly, not an object
responseContent = str(aiResponse) if aiResponse else "" responseContent = str(aiResponse) if aiResponse else ""
@ -384,19 +408,21 @@ class WorkflowProcessor:
if jsonStr: if jsonStr:
parsed = json.loads(jsonStr) parsed = json.loads(jsonStr)
complexity = parsed.get("complexity", "moderate") complexity = parsed.get("complexity", "moderate")
needsWorkflowHistory = parsed.get("needsWorkflowHistory", False)
detectedLanguage = parsed.get("detectedLanguage") or None
reasoning = parsed.get("reasoning", "") reasoning = parsed.get("reasoning", "")
logger.info(f"Complexity detected: {complexity} - {reasoning}") logger.info(f"Complexity detected: {complexity}, needsWorkflowHistory: {needsWorkflowHistory}, language: {detectedLanguage} - {reasoning}")
else: else:
logger.warning("Could not parse complexity detection response, defaulting to 'moderate'") logger.warning("Could not parse complexity detection response, defaulting to 'moderate'")
except Exception as e: except Exception as e:
logger.warning(f"Error parsing complexity detection: {str(e)}, defaulting to 'moderate'") logger.warning(f"Error parsing complexity detection: {str(e)}, defaulting to 'moderate'")
return complexity return (complexity, needsWorkflowHistory, detectedLanguage)
except Exception as e: except Exception as e:
logger.error(f"Error in detectComplexity: {str(e)}") logger.error(f"Error in detectComplexity: {str(e)}")
# Default to moderate on error (safe fallback) # Default to moderate on error (safe fallback)
return "moderate" return ("moderate", False, None)
async def fastPathExecute(self, prompt: str, documents: Optional[List[ChatDocument]] = None, userLanguage: Optional[str] = None) -> ActionResult: async def fastPathExecute(self, prompt: str, documents: Optional[List[ChatDocument]] = None, userLanguage: Optional[str] = None) -> ActionResult:
""" """
@ -416,9 +442,12 @@ class WorkflowProcessor:
await self.services.ai.ensureAiObjectsInitialized() await self.services.ai.ensureAiObjectsInitialized()
# Build fast path prompt (understand + execute + deliver in one call) # Build fast path prompt (understand + execute + deliver in one call)
# Clearly separate user prompt for security
fastPathPrompt = ( fastPathPrompt = (
"You are a helpful assistant. Answer the user's question directly and comprehensively.\n\n" "You are a helpful assistant. Answer the user's question directly and comprehensively.\n\n"
f"User question:\n{prompt}\n\n" "## User Question\n"
"The following is the user's request:\n\n"
f"{prompt}\n\n"
) )
# Add user language context if available # Add user language context if available
@ -443,7 +472,7 @@ class WorkflowProcessor:
) )
# Call AI directly (no document generation - just plain text response) # Call AI directly (no document generation - just plain text response)
# Use aiObjects.call() instead of callAiContent() to avoid document generation path # Use callWithTextContext() for text-only calls
aiRequest = AiCallRequest( aiRequest = AiCallRequest(
prompt=fastPathPrompt, prompt=fastPathPrompt,
context="", context="",
@ -451,7 +480,7 @@ class WorkflowProcessor:
contentParts=None # Fast path doesn't process documents contentParts=None # Fast path doesn't process documents
) )
aiCallResponse = await self.services.ai.aiObjects.call(aiRequest) aiCallResponse = await self.services.ai.aiObjects.callWithTextContext(aiRequest)
# Extract response content (AiCallResponse.content is a string) # Extract response content (AiCallResponse.content is a string)
responseText = aiCallResponse.content if aiCallResponse.content else "" responseText = aiCallResponse.content if aiCallResponse.content else ""

View file

@ -97,7 +97,7 @@ class WorkflowManager:
"mandateId": self.services.user.mandateId, "mandateId": self.services.user.mandateId,
"messageIds": [], "messageIds": [],
"workflowMode": workflowMode, "workflowMode": workflowMode,
"maxSteps": 5 if workflowMode == WorkflowModeEnum.WORKFLOW_DYNAMIC else 1, # Set maxSteps for Dynamic mode "maxSteps": 10 , # Set maxSteps
} }
workflow = self.services.chat.createWorkflow(workflowData) workflow = self.services.chat.createWorkflow(workflowData)
@ -160,6 +160,9 @@ class WorkflowManager:
# Reset progress logger for new workflow # Reset progress logger for new workflow
self.services.chat._progressLogger = None self.services.chat._progressLogger = None
# Reset workflow history flag at start of each workflow
setattr(self.services, '_needsWorkflowHistory', False)
self.workflowProcessor = WorkflowProcessor(self.services) self.workflowProcessor = WorkflowProcessor(self.services)
# Get workflow mode to determine if complexity detection is needed # Get workflow mode to determine if complexity detection is needed
@ -169,6 +172,8 @@ class WorkflowManager:
if skipComplexityDetection: if skipComplexityDetection:
logger.info("Skipping complexity detection for AUTOMATION mode - using predefined plan") logger.info("Skipping complexity detection for AUTOMATION mode - using predefined plan")
complexity = "moderate" # Default for automation workflows complexity = "moderate" # Default for automation workflows
needsWorkflowHistory = False # Automation workflows don't need history
detectedLanguage = None # No language detection in automation mode
else: else:
# Process user-uploaded documents from userInput for complexity detection # Process user-uploaded documents from userInput for complexity detection
# This is the correct way: use the input data directly, not workflow state # This is the correct way: use the input data directly, not workflow state
@ -180,24 +185,27 @@ class WorkflowManager:
logger.warning(f"Failed to process user fileIds for complexity detection: {e}") logger.warning(f"Failed to process user fileIds for complexity detection: {e}")
# Detect complexity (AI-based semantic understanding) using user input documents # Detect complexity (AI-based semantic understanding) using user input documents
complexity = await self.workflowProcessor.detectComplexity(userInput.prompt, documents) # Also detects language for fast path responses
logger.info(f"Request complexity detected: {complexity}") complexity, needsWorkflowHistory, detectedLanguage = await self.workflowProcessor.detectComplexity(userInput.prompt, documents)
logger.info(f"Request complexity detected: {complexity}, needsWorkflowHistory: {needsWorkflowHistory}, language: {detectedLanguage}")
# Set detected language for fast path (if detected)
if detectedLanguage and isinstance(detectedLanguage, str):
self._setUserLanguage(detectedLanguage)
try:
setattr(self.services, 'currentUserLanguage', detectedLanguage)
except Exception:
pass
# Now send the first message (which will also process the documents again, but that's fine) # Route to fast path for simple requests if history is not needed
await self._sendFirstMessage(userInput) # Skip fast path for automation mode or if history is needed
if complexity == "simple" and not needsWorkflowHistory:
# 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") logger.info("Routing to fast path for simple request")
await self._executeFastPath(userInput, documents) await self._executeFastPath(userInput, documents)
return # Fast path completes the workflow 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") # Now send the first message (which will also process the documents again, but that's fine)
await self._sendFirstMessage(userInput)
# Route to full workflow for moderate/complex requests or automation mode # 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 "")) logger.info(f"Routing to full workflow for {complexity} request" + (" (automation mode)" if skipComplexityDetection else ""))
@ -222,9 +230,10 @@ class WorkflowManager:
# Get user language if available # Get user language if available
userLanguage = getattr(self.services, 'currentUserLanguage', None) userLanguage = getattr(self.services, 'currentUserLanguage', None)
# Execute fast path # Execute fast path - use normalizedRequest if available, otherwise use raw prompt
normalizedPrompt = getattr(self.services, 'currentUserPromptNormalized', None) or userInput.prompt
result = await self.workflowProcessor.fastPathExecute( result = await self.workflowProcessor.fastPathExecute(
prompt=userInput.prompt, prompt=normalizedPrompt,
documents=documents, documents=documents,
userLanguage=userLanguage userLanguage=userLanguage
) )
@ -279,12 +288,20 @@ class WorkflowManager:
} }
chatDocuments.append(chatDoc) chatDocuments.append(chatDoc)
# Mark workflow as completed BEFORE storing message (so UI polling stops)
workflow.status = "completed"
workflow.lastActivity = self.services.utils.timestampGetUtc()
self.services.chat.updateWorkflow(workflow.id, {
"status": "completed",
"lastActivity": workflow.lastActivity
})
# Create ChatMessage with fast path response (in user's language) # Create ChatMessage with fast path response (in user's language)
messageData = { messageData = {
"workflowId": workflow.id, "workflowId": workflow.id,
"role": "assistant", "role": "assistant",
"message": responseText or "Fast path response completed", "message": responseText or "Fast path response completed",
"status": "last", # Fast path completes the workflow "status": "last", # Fast path completes the workflow - UI polling stops on this
"sequenceNr": len(workflow.messages) + 1, "sequenceNr": len(workflow.messages) + 1,
"publishedAt": self.services.utils.timestampGetUtc(), "publishedAt": self.services.utils.timestampGetUtc(),
"documentsLabel": "fast_path_response", "documentsLabel": "fast_path_response",
@ -301,14 +318,6 @@ class WorkflowManager:
# Store message with documents # Store message with documents
self.services.chat.storeMessageWithDocuments(workflow, messageData, chatDocuments) self.services.chat.storeMessageWithDocuments(workflow, messageData, chatDocuments)
# Mark workflow as completed
workflow.status = "completed"
workflow.lastActivity = self.services.utils.timestampGetUtc()
self.services.chat.updateWorkflow(workflow.id, {
"status": "completed",
"lastActivity": workflow.lastActivity
})
logger.info(f"Fast path completed successfully, response length: {len(responseText)} chars") logger.info(f"Fast path completed successfully, response length: {len(responseText)} chars")
except Exception as e: except Exception as e:
@ -405,7 +414,11 @@ class WorkflowManager:
" \"successCriteria\": [\"specific criterion 1\", \"specific criterion 2\"],\n" " \"successCriteria\": [\"specific criterion 1\", \"specific criterion 2\"],\n"
" \"needsWorkflowHistory\": true|false\n" " \"needsWorkflowHistory\": true|false\n"
"}\n\n" "}\n\n"
f"User message:\n{self.services.utils.sanitizePromptContent(userInput.prompt, 'userinput')}" "## User Message\n"
"The following is the user's original input message. Extract intent, normalize the request, and identify any large context blocks that should be moved to separate documents:\n\n"
"################ USER INPUT START #################\n"
f"{userInput.prompt.replace('{', '{{').replace('}', '}}') if userInput.prompt else ''}\n"
"################ USER INPUT FINISH #################"
) )
# Call AI analyzer (planning call - will use static parameters) # Call AI analyzer (planning call - will use static parameters)
@ -446,8 +459,8 @@ class WorkflowManager:
# Store needsWorkflowHistory in services for fast path decision # Store needsWorkflowHistory in services for fast path decision
needsHistoryFromIntention = parsed.get('needsWorkflowHistory', False) needsHistoryFromIntention = parsed.get('needsWorkflowHistory', False)
if isinstance(needsHistoryFromIntention, bool): # Always set the value - default to False if not a boolean
setattr(self.services, '_needsWorkflowHistory', needsHistoryFromIntention) setattr(self.services, '_needsWorkflowHistory', bool(needsHistoryFromIntention) if isinstance(needsHistoryFromIntention, bool) else False)
# Store workflowIntent in workflow object for reuse # Store workflowIntent in workflow object for reuse
if hasattr(self.services, 'workflow') and self.services.workflow: if hasattr(self.services, 'workflow') and self.services.workflow:
@ -455,6 +468,8 @@ class WorkflowManager:
except Exception: except Exception:
contextItems = [] contextItems = []
workflowIntent = None workflowIntent = None
# Ensure needsWorkflowHistory is False if parsing fails
setattr(self.services, '_needsWorkflowHistory', False)
# Update services state # Update services state
if detectedLanguage and isinstance(detectedLanguage, str): if detectedLanguage and isinstance(detectedLanguage, str):
@ -531,7 +546,9 @@ class WorkflowManager:
workflow = self.services.workflow workflow = self.services.workflow
handling = self.workflowProcessor handling = self.workflowProcessor
# Generate task plan first (shared for both modes) # Generate task plan first (shared for both modes)
taskPlan = await handling.generateTaskPlan(userInput.prompt, workflow) # Use normalizedRequest instead of raw userInput.prompt for security
normalizedPrompt = getattr(self.services, 'currentUserPromptNormalized', None) or userInput.prompt
taskPlan = await handling.generateTaskPlan(normalizedPrompt, workflow)
if not taskPlan or not taskPlan.tasks: if not taskPlan or not taskPlan.tasks:
raise Exception("No tasks generated in task plan.") raise Exception("No tasks generated in task plan.")
workflowMode = getattr(workflow, 'workflowMode') workflowMode = getattr(workflow, 'workflowMode')