Skip to content

Commit 96eeb6c

Browse files
committed
feat: add search questions generation with database migration and UI
Database & Backend: - Add search_questions column migration in operations.py (ALTER TABLE for existing databases) - Add /api/kba/drafts/{id}/replace endpoint in app.py - Fix backward compatibility in kba_service.py (_table_to_draft, _draft_to_table) - Add search questions generation to replace_draft workflow - Fix NULL constraint errors by ensuring empty strings for required fields - Update related_tickets validation: accept INC + 9-12 digits (was fixed at 12) Frontend: - Add Text component import to KBADrafterPage.jsx (fix TypeError) - Add full-screen blur overlay with centered spinner during KBA generation - Show overlay for both new draft creation and replacement operations - Update styles: loadingOverlay with backdrop-filter blur effect Documentation: - Update kba_prompts.py: clarify related_tickets format with examples - Update GENERAL.md: correct related_tickets format specification Fixes #1 - KBA drafts not loading (missing DB column) Fixes #2 - Replace endpoint not found (405 error) Fixes #3 - Ticket ID validation too strict
1 parent 78a2f98 commit 96eeb6c

11 files changed

Lines changed: 816 additions & 20 deletions

File tree

backend/app.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1054,6 +1054,19 @@ async def rest_kba_update_draft(draft_id: str):
10541054
return jsonify({"error": str(e)}), 400
10551055

10561056

1057+
@app.route("/api/kba/drafts/<draft_id>/replace", methods=["POST"])
1058+
async def rest_kba_replace_draft(draft_id: str):
1059+
"""REST wrapper: replace/regenerate KBA draft."""
1060+
try:
1061+
from operations import op_kba_replace_draft
1062+
data = await request.get_json() if await request.data else {}
1063+
user_id = data.get("user_id", "anonymous")
1064+
draft = await op_kba_replace_draft(draft_id, user_id)
1065+
return jsonify(draft.model_dump())
1066+
except Exception as e:
1067+
return jsonify({"error": str(e)}), 500
1068+
1069+
10571070
@app.route("/api/kba/drafts/<draft_id>", methods=["DELETE"])
10581071
async def rest_kba_delete_draft(draft_id: str):
10591072
"""REST wrapper: delete KBA draft."""

backend/kb_adapters.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,18 @@ def _generate_markdown(self, draft: dict[str, Any], visibility: str) -> str:
206206
""
207207
]
208208

209+
# Search Questions (after header, before symptoms)
210+
if draft.get("search_questions"):
211+
lines.extend([
212+
"## Häufige Suchanfragen",
213+
"",
214+
"*Benutzer suchen häufig nach:*",
215+
""
216+
])
217+
for question in draft["search_questions"]:
218+
lines.append(f"- {question}")
219+
lines.extend(["", "---", ""])
220+
209221
# Symptoms
210222
if draft.get("symptoms"):
211223
lines.extend([

backend/kba_models.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,12 @@ class KBADraft(BaseModel):
107107
tags: list[str] = Field(default_factory=list, description="Search tags (lowercase)")
108108
related_tickets: list[str] = Field(default_factory=list, description="Related incident IDs")
109109

110+
# Search Questions
111+
search_questions: list[str] = Field(
112+
default_factory=list,
113+
description="User search queries - how users might search for this KBA"
114+
)
115+
110116
# Guidelines used during generation
111117
guidelines_used: list[str] = Field(default_factory=list, description="Guideline categories used")
112118

@@ -187,6 +193,7 @@ class KBADraftUpdate(BaseModel):
187193
# Metadata
188194
tags: Optional[list[str]] = None
189195
related_tickets: Optional[list[str]] = None
196+
search_questions: Optional[list[str]] = None
190197
status: Optional[KBADraftStatus] = None
191198
reviewed_by: Optional[str] = None # Set when status → reviewed
192199

@@ -259,6 +266,7 @@ class KBADraftTable(SQLModel, table=True):
259266
additional_notes: str = ""
260267
tags: list[str] = SQLField(sa_column=Column(JSON))
261268
related_tickets: list[str] = SQLField(sa_column=Column(JSON))
269+
search_questions: list[str] = SQLField(sa_column=Column(JSON))
262270
guidelines_used: list[str] = SQLField(sa_column=Column(JSON))
263271

264272
# Metadata

backend/kba_output_models.py

Lines changed: 130 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,12 @@ class KBAOutputSchema(BaseModel):
8181
description="Related incident IDs in format INC0001234"
8282
)
8383

84+
# Search Questions (generated separately, not part of main draft generation)
85+
search_questions: Optional[list[str]] = Field(
86+
default_factory=list,
87+
description="User search queries - generated in separate step"
88+
)
89+
8490
# Legacy fields (optional for backward compatibility)
8591
problem_description: Optional[str] = Field(
8692
default="",
@@ -127,13 +133,14 @@ def validate_tags(cls, v: list[str]) -> list[str]:
127133
@field_validator('related_tickets')
128134
@classmethod
129135
def validate_related_tickets(cls, v: list[str]) -> list[str]:
130-
"""Ensure ticket IDs match INC format - supports 12-digit format"""
136+
"""Ensure ticket IDs match INC format - supports 9-12 digit format"""
131137
import re
132138
for ticket in v:
133-
# Support real format: INC000016312744 (12 digits)
134-
if not re.match(r'^INC[0-9]{12}$', ticket):
139+
# Support both short (INC000016346) and full format (INC000016312744)
140+
# INC followed by 9-12 digits
141+
if not re.match(r'^INC[0-9]{9,12}$', ticket):
135142
raise ValueError(
136-
f"Ticket ID '{ticket}' must match format INC000012345678 (INC + 12 digits)"
143+
f"Ticket ID '{ticket}' must match format INC + 9-12 digits (e.g., INC000016346)"
137144
)
138145
return v
139146

@@ -185,8 +192,127 @@ def validate_warnings(cls, v: Optional[list[str]]) -> list[str]:
185192
"confidence_notes": "Lösung basiert auf häufigem Windows 11 Update-Problem",
186193
"tags": ["vpn", "windows-11", "firewall", "openvpn", "timeout"],
187194
"related_tickets": [],
195+
"search_questions": [
196+
"Wie behebe ich VPN-Verbindungsprobleme unter Windows 11?",
197+
"VPN bricht nach 30 Sekunden ab was tun?",
198+
"OpenVPN Connection Timeout Error 10060 lösen",
199+
"Warum verbindet sich mein VPN nicht mehr?",
200+
"Windows Firewall blockiert VPN-Verbindung"
201+
],
188202
"problem_description": "",
189203
"additional_notes": ""
190204
}
191205
}
192206
}
207+
208+
209+
# ============================================================================
210+
# SEARCH QUESTIONS SCHEMA & VALIDATION
211+
# ============================================================================
212+
213+
class SearchQuestionsSchema(BaseModel):
214+
"""
215+
Schema for LLM-generated search questions (separate generation step).
216+
217+
Used with OpenAI's beta.chat.completions.parse() for automatic validation.
218+
"""
219+
220+
questions: list[str] = Field(
221+
...,
222+
min_length=5,
223+
max_length=15,
224+
description="User search queries - how users might search for this KBA"
225+
)
226+
227+
@field_validator('questions')
228+
@classmethod
229+
def validate_questions_format(cls, v: list[str]) -> list[str]:
230+
"""Basic validation - detailed cleaning happens in service layer"""
231+
if not v:
232+
raise ValueError("Questions list cannot be empty")
233+
234+
# Basic length checks
235+
for q in v:
236+
if not q or not q.strip():
237+
raise ValueError("Empty question not allowed")
238+
q_stripped = q.strip()
239+
if len(q_stripped) < 10:
240+
raise ValueError(f"Question too short (min 10 chars): '{q[:30]}...'")
241+
if len(q_stripped) > 200:
242+
raise ValueError(f"Question too long (max 200 chars): '{q[:50]}...'")
243+
244+
return v
245+
246+
model_config = {
247+
"extra": "forbid",
248+
"json_schema_extra": {
249+
"example": {
250+
"questions": [
251+
"Wie behebe ich VPN-Verbindungsprobleme unter Windows 11?",
252+
"VPN bricht nach 30 Sekunden ab was tun?",
253+
"OpenVPN Connection Timeout Error 10060",
254+
"Warum verbindet sich mein VPN nicht?",
255+
"Windows Firewall blockiert VPN"
256+
]
257+
}
258+
}
259+
}
260+
261+
262+
def validate_and_clean_search_questions(
263+
questions: list[str],
264+
min_questions: int = 5,
265+
max_questions: int = 15
266+
) -> list[str]:
267+
"""
268+
Validate and clean search questions with deduplication.
269+
270+
Args:
271+
questions: Raw questions from LLM
272+
min_questions: Minimum required questions
273+
max_questions: Maximum allowed questions
274+
275+
Returns:
276+
Cleaned and deduplicated question list
277+
278+
Raises:
279+
ValueError: If validation fails
280+
"""
281+
import re
282+
283+
# Step 1: Trim and filter empty
284+
cleaned = []
285+
for q in questions:
286+
q_stripped = q.strip()
287+
if q_stripped:
288+
cleaned.append(q_stripped)
289+
290+
# Step 2: Validate length
291+
valid = []
292+
for q in cleaned:
293+
if 10 <= len(q) <= 200:
294+
valid.append(q)
295+
296+
# Step 3: Deduplicate (case-insensitive, normalized)
297+
seen = set()
298+
deduplicated = []
299+
for q in valid:
300+
# Normalize: lowercase, remove extra whitespace
301+
normalized = re.sub(r'\s+', ' ', q.lower())
302+
if normalized not in seen:
303+
seen.add(normalized)
304+
deduplicated.append(q) # Keep original casing
305+
306+
# Step 4: Enforce min/max
307+
if len(deduplicated) < min_questions:
308+
raise ValueError(
309+
f"Only {len(deduplicated)} valid questions after cleaning "
310+
f"(required: {min_questions}). "
311+
f"Raw count: {len(questions)}, after filtering: {len(valid)}"
312+
)
313+
314+
if len(deduplicated) > max_questions:
315+
# Truncate to max
316+
deduplicated = deduplicated[:max_questions]
317+
318+
return deduplicated

backend/kba_prompts.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,8 @@ def escape_braces(text: Optional[str]) -> str:
124124
125125
**tags** (REQUIRED): Lowercase Suchbegriffe, 2-10 Tags
126126
127+
**related_tickets** (OPTIONAL): Verwandte Incident-IDs (Format: INC + 9-12 Ziffern, z.B. INC000016346 oder INC000016312744). Verwende die EXAKTE Incident-ID aus dem Ticket ohne Änderungen.
128+
127129
**Wichtig**:
128130
- Formuliere aus Sicht des Endbenutzers, nicht des Technikers
129131
- NUR JSON ausgeben, kein zusätzlicher Text oder Markdown-Wrapper!
@@ -220,3 +222,95 @@ def build_markdown_fallback_prompt(ticket: Ticket, guidelines: str) -> str:
220222
tag1, tag2, tag3"""
221223

222224
return prompt
225+
226+
227+
def build_search_questions_prompt(draft_data: dict) -> str:
228+
"""
229+
Build prompt for generating search questions from a KBA draft.
230+
231+
Args:
232+
draft_data: Dictionary with KBA draft fields (title, symptoms, resolution_steps, etc.)
233+
234+
Returns:
235+
Prompt string for search question generation
236+
"""
237+
import json
238+
239+
# Create clean JSON representation of KBA draft
240+
kba_draft_json = json.dumps({
241+
"title": draft_data.get("title", ""),
242+
"symptoms": draft_data.get("symptoms", []),
243+
"cause": draft_data.get("cause", ""),
244+
"resolution_steps": draft_data.get("resolution_steps", []),
245+
"tags": draft_data.get("tags", []),
246+
"validation_checks": draft_data.get("validation_checks", []),
247+
"warnings": draft_data.get("warnings", [])
248+
}, ensure_ascii=False, indent=2)
249+
250+
prompt = f"""Erstelle aus dem folgenden KBA-Draft eine Liste von Suchfragen, die Benutzer in einer Knowledge Base eingeben könnten, um genau diesen Artikel zu finden.
251+
252+
Regeln:
253+
- Verwende nur Informationen aus dem KBA-Draft.
254+
- Keine erfundenen Details.
255+
- Fragen müssen klar, neutral und suchbar sein.
256+
- Erzeuge eine Mischung aus:
257+
- symptom-orientierten Fragen
258+
- problem-/ursachen-orientierten Fragen
259+
- lösungsorientierten Fragen
260+
- kurzen natürlichen Suchanfragen
261+
- Keine Duplikate oder fast identischen Fragen.
262+
- Sprache soll zur Sprache des KBA passen.
263+
- Gib nur das geforderte JSON zurück.
264+
265+
KBA-Draft:
266+
{kba_draft_json}
267+
268+
Erwartetes JSON-Schema:
269+
{{
270+
"questions": ["...", "..."]
271+
}}
272+
273+
BEGINNE MIT DEM JSON:"""
274+
275+
return prompt
276+
277+
278+
def build_search_questions_correction_prompt(
279+
original_prompt: str,
280+
failed_output: str,
281+
validation_error: str
282+
) -> str:
283+
"""
284+
Build correction prompt for search questions retry.
285+
286+
Args:
287+
original_prompt: Original prompt that was sent
288+
failed_output: Output that failed validation
289+
validation_error: Error message from validation
290+
291+
Returns:
292+
Correction prompt with error feedback
293+
"""
294+
prompt = f"""{original_prompt}
295+
296+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
297+
FEHLER IN DEINEM VORHERIGEN OUTPUT:
298+
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
299+
300+
Dein Output:
301+
{failed_output[:500]}...
302+
303+
Validierungsfehler:
304+
{validation_error}
305+
306+
KORREKTUR:
307+
- Stelle sicher, dass mindestens 5 valide Fragen vorhanden sind
308+
- Jede Frage muss 10-200 Zeichen lang sein
309+
- Keine leeren Strings oder Duplikate
310+
- Format: {{"questions": ["...", "..."]}}
311+
- Nur Informationen aus dem KBA-Draft verwenden
312+
313+
NUR JSON zurückgeben, kein zusätzlicher Text!
314+
BEGINNE MIT {{:"""
315+
316+
return prompt

0 commit comments

Comments
 (0)