mitai-jinkendo/backend/question_augmenter.py
Lars de5b8cbf15
All checks were successful
Deploy Development / deploy (push) Successful in 52s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 15s
fix: CRITICAL - Use question ID (not type) for LLM communication
ROOT ARCHITECTURAL CHANGE:
Multiple questions with same type are now supported!

Problem:
- question_augmenter used q.type as LLM key
- If two questions had type="unsicherheit":
  - LLM saw duplicate keys: "- unsicherheit: [ja/nein]"
  - Could only answer one
  - Signals were ambiguous

Solution:
- Use question.id as LLM key (unique by design)
- Keep type for normalization logic
- Map id → type internally

Backend question_augmenter.py:
- format_question_list() now uses q.id as key
- Format: "- **q21**: [ja/nein]  # Question text"
- Question text as comment for LLM context

Backend workflow_executor.py:
- Removed type→id mapping (no longer needed)
- decision_signals now keyed by id (from LLM)
- Build id→type catalog for normalization
- NormalizedSignal.question_type stores id (not type!)
- End Node template: signal_{id} directly available

Flow:
1. Questions sent to LLM: "- q21: [ja/nein]  # Ist Protein unsicher?"
2. LLM answers: "- q21: nein"
3. Normalization: id→type lookup for spectrum/rules
4. Template: {{ node_4.signal_q21 }} = "nein"

Example (TWO unsicherheit questions):
Questions:
- q21: type=unsicherheit, question="Ist Protein unsicher?"
- q22: type=unsicherheit, question="Ist Energie unsicher?"

LLM Prompt:
```
## Entscheidungsfragen
- **q21**: [ja/nein]  # Ist Protein unsicher?
- **q22**: [ja/nein]  # Ist Energie unsicher?
```

LLM Response:
```
- q21: nein
- q22: ja
```

Template:
```
{{ node_4.signal_q21 }} → "nein"
{{ node_4.signal_q22 }} → "ja"
```

BREAKING CHANGE:
- Old workflows with decision_signals keyed by type will break
- Need to re-execute workflows after update

Issue: Cannot have multiple questions with same type
Version: 0.9p (workflow module)
Part 3: End Node Template Engine - ARCHITECTURAL FIX

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 21:13:50 +02:00

295 lines
8.4 KiB
Python

"""
Question Augmenter (Phase 1)
Generiert Fragenergänzungs-Suffix für Analyseprompts.
Konzept-Basis: konzept_workflow_engine_konsolidated.md (Sektion 6.4, 8.3)
Anforderungsanalyse: anforderungsanalyse_umsetzungsplan.md (Sektion 4.2, 6)
Hybridmodell (Sektion 6):
- Primär: Knotengebundene Fragenergänzungen (am Workflow-Knoten definiert)
- Sekundär: Prompt-gebundene Standardfragen (optional in ai_prompts.question_augmentations)
- Vorrangregel: Knotenspezifische überschreiben Prompt-Defaults
Output-Format (User-Entscheidung 2):
- Markdown-Sektionen mit klaren Delimitern (statt verpflichtendem JSON-Mode)
"""
from typing import List, Dict, Optional, Any
from workflow_models import QuestionAugmentation
from db import get_db, get_cursor, r2d
def generate_question_suffix(questions: List[QuestionAugmentation]) -> str:
"""
Generiert Fragenergänzungs-Suffix für einen Analyseprompt.
Format (Markdown-Sektionen):
```
## Analyse
[Hauptinhalt deines Prompts hier]
## Entscheidungsfragen
Beantworte folgende Fragen präzise:
- Relevanz: [ja/nein/unklar]
- Priorität: [hoch/mittel/niedrig/unklar]
## Begründung
[Optional: Kurze Plausibilisierung deiner Antworten (1-2 Sätze)]
```
Args:
questions: Liste von QuestionAugmentation-Objekten
Returns:
Fragenergänzungs-Suffix als Markdown-String
Raises:
ValueError: Bei leerer Fragenliste
"""
if not questions:
raise ValueError("Fragenliste darf nicht leer sein")
# Build question list
question_lines = []
for q in questions:
# Format: "- Fragentyp: [erlaubte Werte]"
spectrum_str = "/".join(q.answer_spectrum)
question_lines.append(f"- {q.type.capitalize()}: [{spectrum_str}]")
question_block = "\n".join(question_lines)
suffix = f"""
---
**WICHTIG: Strukturiere deine Antwort wie folgt:**
## Analyse
[Deine Hauptanalyse hier - beantworte die ursprüngliche Frage ausführlich]
## Entscheidungsfragen
Beantworte folgende Fragen **präzise** mit den vorgegebenen Werten:
{question_block}
## Begründung
[Optional: Kurze Plausibilisierung deiner Entscheidungsfragen-Antworten (1-2 Sätze)]
**Hinweise:**
- Antworte bei Entscheidungsfragen NUR mit den vorgegebenen Werten
- Bei Unsicherheit wähle "unklar"
- Die Begründung ist optional, aber hilfreich für die Nachvollziehbarkeit
"""
return suffix
def load_question_augmentations_from_db(
question_types: Optional[List[str]] = None
) -> List[QuestionAugmentation]:
"""
Lädt Fragenergänzungen aus der Datenbank (workflow_question_catalog).
Args:
question_types: Optionale Liste von Fragetypen zum Filtern
(z.B. ["relevanz", "prioritaet"])
Wenn None: Alle aktiven Fragen werden geladen
Returns:
Liste von QuestionAugmentation-Objekten
Raises:
HTTPException: Bei Datenbankfehlern
"""
with get_db() as conn:
cur = get_cursor(conn)
if question_types:
placeholders = ", ".join(["%s"] * len(question_types))
cur.execute(
f"""SELECT id, question_type, label, question_template, answer_spectrum
FROM workflow_question_catalog
WHERE active = true AND question_type IN ({placeholders})
ORDER BY question_type""",
tuple(question_types)
)
else:
cur.execute(
"""SELECT id, question_type, label, question_template, answer_spectrum
FROM workflow_question_catalog
WHERE active = true
ORDER BY question_type"""
)
rows = cur.fetchall()
questions = []
for row in rows:
db_row = r2d(row)
questions.append(
QuestionAugmentation(
id=db_row['question_type'], # Verwende Typ als ID (z.B. "relevanz")
type=db_row['question_type'],
question=db_row['question_template'],
answer_spectrum=db_row['answer_spectrum']
)
)
return questions
def merge_question_augmentations(
node_questions: Optional[List[QuestionAugmentation]],
prompt_default_questions: Optional[List[QuestionAugmentation]]
) -> List[QuestionAugmentation]:
"""
Merged Fragenergänzungen nach Hybridmodell-Vorrangregel.
Vorrangregel (Sektion 6.2 der Anforderungsanalyse):
1. Knotenspezifische Fragenergänzungen überschreiben Prompt-Defaults
2. Wenn Knoten keine Fragen definiert: Prompt-Defaults werden verwendet (falls vorhanden)
3. Wenn weder Knoten noch Prompt Fragen definieren: Leere Liste
Args:
node_questions: Fragenergänzungen am Workflow-Knoten (primär)
prompt_default_questions: Fragenergänzungen am Prompt (sekundär)
Returns:
Gemergete Liste von QuestionAugmentation-Objekten
"""
# Vorrangregel 1: Knotenspezifische Fragen haben absolute Priorität
if node_questions:
return node_questions
# Vorrangregel 2: Wenn Knoten keine Fragen hat, verwende Prompt-Defaults
if prompt_default_questions:
return prompt_default_questions
# Vorrangregel 3: Wenn weder Knoten noch Prompt Fragen haben: Leere Liste
return []
def parse_question_augmentations_from_jsonb(
jsonb_data: Optional[Dict[str, Any]]
) -> List[QuestionAugmentation]:
"""
Parsed QuestionAugmentation-Objekte aus JSONB-Daten (z.B. ai_prompts.question_augmentations).
Format:
[
{
"id": "q1",
"type": "relevanz",
"question": "Ist eine vertiefte Analyse relevant?",
"answer_spectrum": ["ja", "nein", "unklar"]
},
...
]
Args:
jsonb_data: JSONB-Array als Python-Dict/List
Returns:
Liste von QuestionAugmentation-Objekten
Raises:
ValueError: Bei ungültigem Format
"""
if not jsonb_data:
return []
if not isinstance(jsonb_data, list):
raise ValueError("question_augmentations muss ein Array sein")
questions = []
for item in jsonb_data:
try:
questions.append(QuestionAugmentation(**item))
except Exception as e:
raise ValueError(f"Ungültiges QuestionAugmentation-Format: {e}")
return questions
def get_question_augmentation_instruction() -> str:
"""
Gibt generische Instruktion für strukturierte Antwort zurück.
Diese Instruktion wird IMMER zum Prompt hinzugefügt, wenn Fragenergänzungen aktiv sind.
Returns:
Instruktions-String als Markdown
"""
return """
---
**WICHTIG: Strukturiere deine Antwort in folgenden Markdown-Sektionen:**
1. **## Analyse** - Deine Hauptanalyse (beantworte die ursprüngliche Frage)
2. **## Entscheidungsfragen** - Beantworte die unten stehenden Fragen PRÄZISE mit den vorgegebenen Werten
3. **## Begründung** - Optional: Kurze Plausibilisierung deiner Entscheidungsfragen (1-2 Sätze)
"""
def format_question_list(questions: List[QuestionAugmentation]) -> str:
"""
Formatiert Fragenliste als Markdown-Liste.
Verwendet question.id als Schlüssel (nicht type), damit mehrere Fragen
des gleichen Typs möglich sind.
Format:
```
- q21: [ja/nein/unklar] # Ist Protein unsicher?
- q22: [ja/nein/unklar] # Ist Energie unsicher?
```
Args:
questions: Liste von QuestionAugmentation-Objekten
Returns:
Formatierte Markdown-Liste
"""
lines = []
for q in questions:
spectrum_str = "/".join(q.answer_spectrum)
# Use ID as key (unique), show question text as comment for context
question_text = q.question[:50] if q.question else q.type
lines.append(f"- **{q.id}**: [{spectrum_str}] # {question_text}")
return "\n".join(lines)
def augment_prompt_with_questions(
base_prompt: str,
questions: List[QuestionAugmentation]
) -> str:
"""
Fügt Fragenergänzungen zu einem Basis-Prompt hinzu.
Args:
base_prompt: Original-Prompt-Text
questions: Liste von Fragenergänzungen
Returns:
Erweiterter Prompt mit Fragenergänzungen
Raises:
ValueError: Bei leerer Fragenliste
"""
if not questions:
raise ValueError("Keine Fragenergänzungen vorhanden")
instruction = get_question_augmentation_instruction()
question_list = format_question_list(questions)
augmented_prompt = f"""{base_prompt}
{instruction}
**Entscheidungsfragen:**
{question_list}
"""
return augmented_prompt