From db456f166722f2e6aa66e2fdbbb942e38f4953cd Mon Sep 17 00:00:00 2001
From: ValueOn AG
Date: Sun, 28 Dec 2025 13:51:19 +0100
Subject: [PATCH] fixed generation issue and ai calls only for extracted
content
---
.../services/serviceAi/subStructureFilling.py | 121 ++++++++++++------
.../serviceAi/subStructureGeneration.py | 42 +-----
.../renderers/rendererBaseTemplate.py | 47 +++++--
.../renderers/rendererHtml.py | 23 ++--
4 files changed, 138 insertions(+), 95 deletions(-)
diff --git a/modules/services/serviceAi/subStructureFilling.py b/modules/services/serviceAi/subStructureFilling.py
index d93264af..548cf128 100644
--- a/modules/services/serviceAi/subStructureFilling.py
+++ b/modules/services/serviceAi/subStructureFilling.py
@@ -134,23 +134,13 @@ class StructureFiller:
userPrompt=userPrompt
)
- # Debug: Log Prompt
- self.services.utils.writeDebugFile(
- chapterPrompt,
- f"chapter_structure_{chapterId}_prompt"
- )
-
+ # AI-Call für Chapter-Struktur-Generierung
+ # Note: Debug logging is handled by callAiPlanning
aiResponse = await self.aiService.callAiPlanning(
prompt=chapterPrompt,
debugType=f"chapter_structure_{chapterId}"
)
- # Debug: Log Response
- self.services.utils.writeDebugFile(
- aiResponse,
- f"chapter_structure_{chapterId}_response"
- )
-
sectionsStructure = json.loads(
self.services.utils.jsonExtractString(aiResponse)
)
@@ -158,20 +148,39 @@ class StructureFiller:
chapter["sections"] = sectionsStructure.get("sections", [])
# Setze useAiCall Flag (falls nicht von AI gesetzt)
+ # WICHTIG: useAiCall kann nur true sein, wenn mindestens ein ContentPart Format "extracted" hat!
+ # "object" und "reference" Formate werden direkt als Elemente hinzugefügt, benötigen kein AI.
for section in chapter["sections"]:
if "useAiCall" not in section:
contentType = section.get("content_type", "paragraph")
- useAiCall = contentType != "paragraph"
+ contentPartIds = section.get("contentPartIds", [])
- # Prüfe contentPartInstructions
- if not useAiCall:
- for partId in section.get("contentPartIds", []):
- instruction = contentPartInstructions.get(partId, {}).get("instruction", "")
- if instruction and instruction.lower() not in ["include full text", "include all content", "use full extracted text"]:
- useAiCall = True
+ # Prüfe ob mindestens ein ContentPart Format "extracted" hat
+ hasExtractedPart = False
+ for partId in contentPartIds:
+ part = self._findContentPartById(partId, contentParts)
+ if part:
+ contentFormat = part.metadata.get("contentFormat", "unknown")
+ if contentFormat == "extracted":
+ hasExtractedPart = True
break
+ # useAiCall kann nur true sein, wenn extracted Parts vorhanden sind
+ useAiCall = False
+ if hasExtractedPart:
+ # Prüfe ob Transformation nötig ist
+ useAiCall = contentType != "paragraph"
+
+ # Prüfe contentPartInstructions für Transformation
+ if not useAiCall:
+ for partId in contentPartIds:
+ instruction = contentPartInstructions.get(partId, {}).get("instruction", "")
+ if instruction and instruction.lower() not in ["include full text", "include all content", "use full extracted text"]:
+ useAiCall = True
+ break
+
section["useAiCall"] = useAiCall
+ logger.debug(f"Section {section.get('id')}: useAiCall={useAiCall} (hasExtractedPart={hasExtractedPart}, contentType={contentType})")
return chapterStructure
@@ -200,10 +209,16 @@ class StructureFiller:
sectionId = section.get("id")
contentPartIds = section.get("contentPartIds", [])
contentFormats = section.get("contentFormats", {})
- generationHint = section.get("generation_hint")
+ # Check both camelCase and snake_case for generationHint
+ generationHint = section.get("generationHint") or section.get("generation_hint")
contentType = section.get("content_type", "paragraph")
useAiCall = section.get("useAiCall", False)
+ # WICHTIG: Wenn keine ContentParts vorhanden sind, kann kein AI-Call gemacht werden
+ if len(contentPartIds) == 0:
+ useAiCall = False
+ logger.debug(f"Section {sectionId}: No content parts, setting useAiCall=False")
+
elements = []
# Prüfe ob Aggregation nötig ist
@@ -212,6 +227,8 @@ class StructureFiller:
contentPartCount=len(contentPartIds)
)
+ logger.info(f"Processing section {sectionId}: contentType={contentType}, contentPartCount={len(contentPartIds)}, useAiCall={useAiCall}, needsAggregation={needsAggregation}, hasGenerationHint={bool(generationHint)}")
+
if needsAggregation and useAiCall:
# Aggregation: Alle Parts zusammen verarbeiten
sectionParts = [
@@ -251,6 +268,7 @@ class StructureFiller:
# Aggregiere extracted Parts mit AI
if extractedParts:
+ logger.debug(f"Section {sectionId}: Aggregating {len(extractedParts)} extracted parts with AI")
generationPrompt = self._buildSectionGenerationPrompt(
section=section,
contentParts=extractedParts, # ALLE PARTS für Aggregation!
@@ -279,6 +297,7 @@ class StructureFiller:
generationPrompt,
f"section_content_{sectionId}_prompt"
)
+ logger.debug(f"Logged section prompt: section_content_{sectionId}_prompt (aggregation)")
# Verwende callAi für ContentParts-Unterstützung (nicht callAiPlanning!)
request = AiCallRequest(
@@ -297,6 +316,7 @@ class StructureFiller:
aiResponse.content,
f"section_content_{sectionId}_response"
)
+ logger.debug(f"Logged section response: section_content_{sectionId}_response (aggregation)")
# Parse und füge zu elements hinzu
generatedElements = json.loads(
@@ -348,8 +368,10 @@ class StructureFiller:
})
elif contentFormat == "extracted":
- if generationHint:
+ # WICHTIG: Prüfe sowohl useAiCall als auch generationHint
+ if useAiCall and generationHint:
# AI-Call mit einzelnen ContentPart
+ logger.debug(f"Processing section {sectionId}: Single extracted part with AI call (useAiCall={useAiCall}, generationHint={bool(generationHint)})")
generationPrompt = self._buildSectionGenerationPrompt(
section=section,
contentParts=[part], # EIN PART
@@ -378,6 +400,7 @@ class StructureFiller:
generationPrompt,
f"section_content_{sectionId}_prompt"
)
+ logger.debug(f"Logged section prompt: section_content_{sectionId}_prompt")
# Verwende callAi für ContentParts-Unterstützung
request = AiCallRequest(
@@ -396,6 +419,7 @@ class StructureFiller:
aiResponse.content,
f"section_content_{sectionId}_response"
)
+ logger.debug(f"Logged section response: section_content_{sectionId}_response")
# Parse und füge zu elements hinzu
generatedElements = json.loads(
@@ -421,6 +445,7 @@ class StructureFiller:
# NICHT raise - Section wird mit Fehlermeldung gerendert
else:
# Füge extrahierten Text direkt hinzu (kein AI-Call)
+ logger.debug(f"Processing section {sectionId}: Single extracted part WITHOUT AI call (useAiCall={useAiCall}, generationHint={bool(generationHint)}) - adding extracted text directly")
elements.append({
"type": "extracted_text",
"content": part.data,
@@ -566,8 +591,15 @@ class StructureFiller:
sectionId = section.get("id")
contentPartIds = section.get("contentPartIds", [])
contentFormats = section.get("contentFormats", {})
- generationHint = section.get("generation_hint")
+ # Check both camelCase and snake_case for generationHint
+ generationHint = section.get("generationHint") or section.get("generation_hint")
contentType = section.get("content_type", "paragraph")
+ useAiCall = section.get("useAiCall", False)
+
+ # WICHTIG: Wenn keine ContentParts vorhanden sind, kann kein AI-Call gemacht werden
+ if len(contentPartIds) == 0:
+ useAiCall = False
+ logger.debug(f"Section {sectionId} (legacy): No content parts, setting useAiCall=False")
elements = []
@@ -577,7 +609,9 @@ class StructureFiller:
contentPartCount=len(contentPartIds)
)
- if needsAggregation and generationHint:
+ logger.info(f"Processing section {sectionId} (legacy): contentType={contentType}, contentPartCount={len(contentPartIds)}, useAiCall={useAiCall}, needsAggregation={needsAggregation}, hasGenerationHint={bool(generationHint)}")
+
+ if needsAggregation and useAiCall and generationHint:
# Aggregation: Alle Parts zusammen verarbeiten
sectionParts = [
self._findContentPartById(pid, contentParts)
@@ -702,8 +736,10 @@ class StructureFiller:
})
elif contentFormat == "extracted":
- if generationHint:
+ # WICHTIG: Prüfe sowohl useAiCall als auch generationHint
+ if useAiCall and generationHint:
# AI-Call mit einzelnen ContentPart
+ logger.debug(f"Processing section {sectionId}: Single extracted part with AI call (useAiCall={useAiCall}, generationHint={bool(generationHint)})")
generationPrompt = self._buildSectionGenerationPrompt(
section=section,
contentParts=[part],
@@ -729,6 +765,7 @@ class StructureFiller:
generationPrompt,
f"section_content_{sectionId}_prompt"
)
+ logger.debug(f"Logged section prompt: section_content_{sectionId}_prompt")
request = AiCallRequest(
prompt=generationPrompt,
@@ -745,6 +782,7 @@ class StructureFiller:
aiResponse.content,
f"section_content_{sectionId}_response"
)
+ logger.debug(f"Logged section response: section_content_{sectionId}_response")
generatedElements = json.loads(
self.services.utils.jsonExtractString(aiResponse.content)
@@ -765,6 +803,8 @@ class StructureFiller:
})
logger.error(f"Error generating section {sectionId}: {str(e)}")
else:
+ # Füge extrahierten Text direkt hinzu (kein AI-Call)
+ logger.debug(f"Processing section {sectionId}: Single extracted part WITHOUT AI call (useAiCall={useAiCall}, generationHint={bool(generationHint)}) - adding extracted text directly")
elements.append({
"type": "extracted_text",
"content": part.data,
@@ -817,35 +857,44 @@ class StructureFiller:
prompt = f"""TASK: Generate Chapter Sections Structure
-CHAPTER METADATA:
-- Chapter ID: {chapterId}
-- Chapter Level: {chapterLevel}
-- Chapter Title: {chapterTitle}
-- Generation Hint: {generationHint}
+CHAPTER: {chapterTitle} (Level {chapterLevel}, ID: {chapterId})
+GENERATION HINT: {generationHint}
-WICHTIG: Chapter hat bereits vordefinierte Heading-Section.
-Generiere NICHT eine Heading-Section für Chapter-Title!
+NOTE: Chapter already has a heading section. Do NOT generate a heading for the chapter title.
AVAILABLE CONTENT PARTS:
{contentPartsIndex}
-STANDARD JSON SCHEMA FOR SECTIONS:
-Supported content_types: table, bullet_list, heading, paragraph, code_block, image
+CONTENT TYPES: table, bullet_list, heading, paragraph, code_block, image
-Return JSON:
+useAiCall RULES:
+- useAiCall: true ONLY if ContentPart Format is "extracted" AND transformation needed
+- useAiCall: false if Format is "object" or "reference" (direct insertion)
+- useAiCall: false if Format is "extracted" AND simple "include full text" instruction
+
+RETURN JSON:
{{
"sections": [
{{
"id": "section_1",
"content_type": "paragraph",
- "contentPartIds": ["part_ext_1"],
- "generationHint": "...",
+ "contentPartIds": ["extracted_part_1"],
+ "generationHint": "Include full text",
"useAiCall": false,
"elements": []
}}
]
}}
+EXAMPLES (all content types):
+- paragraph: {{"id": "s1", "content_type": "paragraph", "contentPartIds": ["extracted_1"], "generationHint": "Include full text", "useAiCall": false, "elements": []}}
+- bullet_list: {{"id": "s2", "content_type": "bullet_list", "contentPartIds": ["extracted_1"], "generationHint": "Create bullet list", "useAiCall": true, "elements": []}}
+- table: {{"id": "s3", "content_type": "table", "contentPartIds": ["extracted_1", "extracted_2"], "generationHint": "Create table", "useAiCall": true, "elements": []}}
+- heading: {{"id": "s4", "content_type": "heading", "contentPartIds": ["extracted_1"], "generationHint": "Extract heading", "useAiCall": true, "elements": []}}
+- code_block: {{"id": "s5", "content_type": "code_block", "contentPartIds": ["extracted_1"], "generationHint": "Format code", "useAiCall": true, "elements": []}}
+- image: {{"id": "s6", "content_type": "image", "contentPartIds": ["obj_1"], "generationHint": "Display image", "useAiCall": false, "elements": []}}
+- reference: {{"id": "s7", "content_type": "paragraph", "contentPartIds": ["ref_1"], "generationHint": "Reference", "useAiCall": false, "elements": []}}
+
CRITICAL: Return ONLY valid JSON. Do not include any explanatory text outside the JSON.
"""
return prompt
diff --git a/modules/services/serviceAi/subStructureGeneration.py b/modules/services/serviceAi/subStructureGeneration.py
index a4d7a19e..b8db20a1 100644
--- a/modules/services/serviceAi/subStructureGeneration.py
+++ b/modules/services/serviceAi/subStructureGeneration.py
@@ -68,24 +68,13 @@ class StructureGenerator:
outputFormat=outputFormat
)
- # Debug: Log Prompt
- self.services.utils.writeDebugFile(
- structurePrompt,
- "chapter_structure_generation_prompt"
- )
-
# AI-Call für Chapter-Struktur-Generierung
+ # Note: Debug logging is handled by callAiPlanning
aiResponse = await self.aiService.callAiPlanning(
prompt=structurePrompt,
debugType="chapter_structure_generation"
)
- # Debug: Log Response
- self.services.utils.writeDebugFile(
- aiResponse,
- "chapter_structure_generation_response"
- )
-
# Parse Struktur
structure = json.loads(self.services.utils.jsonExtractString(aiResponse))
@@ -143,34 +132,6 @@ class StructureGenerator:
# Baue Index nur für gültige Parts
for i, part in enumerate(validParts, 1):
contentFormat = part.metadata.get("contentFormat", "unknown")
- dataPreview = ""
-
- if contentFormat == "extracted":
- # Für Image-Parts: Zeige dass es ein Image ist
- if part.typeGroup == "image":
- dataLength = len(part.data) if part.data else 0
- mimeType = part.mimeType or "image"
- dataPreview = f"Image data ({mimeType}, {dataLength} chars) - base64 encoded image content"
- elif part.typeGroup == "container":
- # Container ohne Daten überspringen wir bereits oben
- dataPreview = "Container structure (no text content)"
- else:
- # Zeige Preview von extrahiertem Text
- if part.data:
- preview = part.data[:200] + "..." if len(part.data) > 200 else part.data
- dataPreview = preview
- else:
- dataPreview = "(empty)"
- elif contentFormat == "object":
- dataLength = len(part.data) if part.data else 0
- mimeType = part.mimeType or "binary"
- if part.typeGroup == "image":
- dataPreview = f"Base64 encoded image ({mimeType}, {dataLength} chars)"
- else:
- dataPreview = f"Base64 encoded binary ({mimeType}, {dataLength} chars)"
- elif contentFormat == "reference":
- dataPreview = part.metadata.get("documentReference", "reference")
-
originalFileName = part.metadata.get('originalFileName', 'N/A')
contentPartsIndex += f"\n{i}. ContentPart ID: {part.id}\n"
@@ -180,7 +141,6 @@ class StructureGenerator:
contentPartsIndex += f" Source: {part.metadata.get('documentId', 'unknown')}\n"
contentPartsIndex += f" Original file name: {originalFileName}\n"
contentPartsIndex += f" Usage hint: {part.metadata.get('usageHint', 'N/A')}\n"
- contentPartsIndex += f" Data preview: {dataPreview}\n"
if not contentPartsIndex:
contentPartsIndex = "\n(No content parts available)"
diff --git a/modules/services/serviceGeneration/renderers/rendererBaseTemplate.py b/modules/services/serviceGeneration/renderers/rendererBaseTemplate.py
index e15e0711..ebd37885 100644
--- a/modules/services/serviceGeneration/renderers/rendererBaseTemplate.py
+++ b/modules/services/serviceGeneration/renderers/rendererBaseTemplate.py
@@ -5,7 +5,7 @@ Base renderer class for all format renderers.
"""
from abc import ABC, abstractmethod
-from typing import Dict, Any, List
+from typing import Dict, Any, List, Tuple
from modules.datamodels.datamodelJson import supportedSectionTypes
from modules.datamodels.datamodelDocument import RenderedDocument
import json
@@ -201,9 +201,15 @@ class BaseRenderer(ABC):
def _extractTableData(self, sectionData: Dict[str, Any]) -> Tuple[List[str], List[List[str]]]:
"""Extract table headers and rows from section data."""
# Normalize when elements array was passed in
- if isinstance(sectionData, list) and sectionData:
- candidate = sectionData[0]
- sectionData = candidate if isinstance(candidate, dict) else {}
+ if isinstance(sectionData, list):
+ if sectionData and isinstance(sectionData[0], dict):
+ sectionData = sectionData[0]
+ else:
+ # Empty list or invalid structure - return empty table
+ return [], []
+ # Ensure sectionData is a dict before calling .get()
+ if not isinstance(sectionData, dict):
+ return [], []
headers = sectionData.get("headers", [])
rows = sectionData.get("rows", [])
return headers, rows
@@ -227,8 +233,15 @@ class BaseRenderer(ABC):
def _extractHeadingData(self, sectionData: Dict[str, Any]) -> Tuple[int, str]:
"""Extract heading level and text from section data."""
# Normalize when elements array was passed in
- if isinstance(sectionData, list) and sectionData:
- sectionData = sectionData[0] if isinstance(sectionData[0], dict) else {}
+ if isinstance(sectionData, list):
+ if sectionData and isinstance(sectionData[0], dict):
+ sectionData = sectionData[0]
+ else:
+ # Empty list or invalid structure - return default
+ return 1, ""
+ # Ensure sectionData is a dict before calling .get()
+ if not isinstance(sectionData, dict):
+ return 1, ""
level = sectionData.get("level", 1)
text = sectionData.get("text", "")
return level, text
@@ -249,8 +262,15 @@ class BaseRenderer(ABC):
def _extractCodeBlockData(self, sectionData: Dict[str, Any]) -> Tuple[str, str]:
"""Extract code and language from section data."""
# Normalize when elements array was passed in
- if isinstance(sectionData, list) and sectionData:
- sectionData = sectionData[0] if isinstance(sectionData[0], dict) else {}
+ if isinstance(sectionData, list):
+ if sectionData and isinstance(sectionData[0], dict):
+ sectionData = sectionData[0]
+ else:
+ # Empty list or invalid structure - return default
+ return "", ""
+ # Ensure sectionData is a dict before calling .get()
+ if not isinstance(sectionData, dict):
+ return "", ""
code = sectionData.get("code", "")
language = sectionData.get("language", "")
return code, language
@@ -258,8 +278,15 @@ class BaseRenderer(ABC):
def _extractImageData(self, sectionData: Dict[str, Any]) -> Tuple[str, str]:
"""Extract base64 data and alt text from section data."""
# Normalize when elements array was passed in
- if isinstance(sectionData, list) and sectionData:
- sectionData = sectionData[0] if isinstance(sectionData[0], dict) else {}
+ if isinstance(sectionData, list):
+ if sectionData and isinstance(sectionData[0], dict):
+ sectionData = sectionData[0]
+ else:
+ # Empty list or invalid structure - return default
+ return "", "Image"
+ # Ensure sectionData is a dict before calling .get()
+ if not isinstance(sectionData, dict):
+ return "", "Image"
base64Data = sectionData.get("base64Data", "")
altText = sectionData.get("altText", "Image")
return base64Data, altText
diff --git a/modules/services/serviceGeneration/renderers/rendererHtml.py b/modules/services/serviceGeneration/renderers/rendererHtml.py
index dba6a03f..275302b6 100644
--- a/modules/services/serviceGeneration/renderers/rendererHtml.py
+++ b/modules/services/serviceGeneration/renderers/rendererHtml.py
@@ -396,7 +396,7 @@ class RendererHtml(BaseRenderer):
source = element.get("source", "")
if content:
source_text = f' (Source: {source})' if source else ''
- htmlParts.append(f'
{content}{source_text}
')
+ htmlParts.append(f'
{content}{source_text}
')
elif isinstance(element, dict):
# Regular paragraph element
text = element.get("text", element.get("content", ""))
@@ -432,7 +432,7 @@ class RendererHtml(BaseRenderer):
source = element.get("source", "")
if content:
source_text = f' (Source: {source})' if source else ''
- htmlParts.append(f'
{content}{source_text}
')
+ htmlParts.append(f'
{content}{source_text}
')
if htmlParts:
return '\n'.join(htmlParts)
@@ -577,18 +577,23 @@ class RendererHtml(BaseRenderer):
def _renderJsonImage(self, imageData: Dict[str, Any], styles: Dict[str, Any]) -> str:
"""Render a JSON image to HTML with placeholder for later replacement."""
try:
+ import html
base64Data = imageData.get("base64Data", "")
altText = imageData.get("altText", "Image")
caption = imageData.get("caption", "")
+ # Escape HTML in altText and caption to prevent injection
+ altTextEscaped = html.escape(str(altText))
+ captionEscaped = html.escape(str(caption)) if caption else ""
+
if base64Data:
# Use data URI as placeholder - will be replaced with file path in _replaceImageDataUris
# Include a marker so we can find and replace it
- imageMarker = f""
- imgTag = f''
+ imageMarker = f""
+ imgTag = f''
- if caption:
- return f'{imageMarker}{imgTag}{caption}'
+ if captionEscaped:
+ return f'{imageMarker}{imgTag}{captionEscaped}'
else:
return f'{imageMarker}{imgTag}'
@@ -712,12 +717,14 @@ class RendererHtml(BaseRenderer):
break
if matchingImage:
+ import html
# Use filename from image data (generated from section ID)
filename = matchingImage.get("filename", f"image_{images.index(matchingImage) + 1}.png")
# Replace with relative path (ohne Pfad, nur Dateiname)
- altText = matchingImage.get("altText", "Image")
- caption = matchingImage.get("caption", "")
+ # Escape HTML in altText and caption to prevent injection
+ altText = html.escape(str(matchingImage.get("altText", "Image")))
+ caption = html.escape(str(matchingImage.get("caption", ""))) if matchingImage.get("caption") else ""
# Entferne IMAGE_MARKER Kommentar falls vorhanden
imgTag = f''