Skip to content

Commit c48612d

Browse files
abossardCopilot
andcommitted
fix: address code review issues and add KBA drafter e2e tests
Fixes: - Fix CSV folder case mismatch (CSV -> csv) in app.py and operations.py - Remove duplicate get_ticket_by_incident_id method in csv_data.py - Replace inefficient len(session.exec().all()) with SQL COUNT(*) in kba_service.py - Replace hardcoded placeholder credentials with env var lookups in kba_service.py - Fix scheduler swallowing exceptions (remove bare raise, return None) - Add settings reload at start of each scheduler run to fix race condition - Add generation_warnings field to surface search questions failures to users - Add schema migration for generation_warnings column Tests: - Add 19 Playwright e2e tests for KBA Drafter feature covering: page load, navigation, LLM health status, draft generation, draft display, draft list, editing, review workflow, duplicate handling, and backend API integration Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 2d5b05a commit c48612d

7 files changed

Lines changed: 794 additions & 24 deletions

File tree

backend/app.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -779,7 +779,7 @@ async def get_qa_tickets():
779779
_csv_ticket_service = get_csv_ticket_service()
780780

781781
# Load CSV data on startup (using relative path from backend folder)
782-
_csv_data_path = Path(__file__).parent.parent / "CSV" / "data.csv"
782+
_csv_data_path = Path(__file__).parent.parent / "csv" / "data.csv"
783783
if _csv_data_path.exists():
784784
_csv_loaded = _csv_ticket_service.load_csv(_csv_data_path)
785785
print(f"📊 Loaded {_csv_loaded} tickets from CSV")

backend/csv_data.py

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -467,15 +467,14 @@ def get_ticket(self, ticket_id: UUID) -> Optional[Ticket]:
467467
return self._tickets.get(ticket_id)
468468

469469
def get_ticket_by_incident_id(self, incident_id: str) -> Optional[Ticket]:
470-
"""Get ticket by INC number (e.g. INC000016349327)."""
471-
return self._tickets_by_incident_id.get(incident_id)
472-
473-
def get_ticket_by_incident_id(self, incident_id: str) -> Optional[Ticket]:
474-
"""
475-
Get ticket by Incident ID (e.g., INC000016349815).
470+
"""Get ticket by INC number (e.g. INC000016349327).
476471
477-
Uses deterministic UUID generation to find ticket.
472+
Uses direct dictionary lookup first, falls back to UUID-based lookup.
478473
"""
474+
ticket = self._tickets_by_incident_id.get(incident_id)
475+
if ticket is not None:
476+
return ticket
477+
# Fallback: deterministic UUID generation
479478
ticket_uuid = generate_uuid_from_incident_id(incident_id)
480479
return self._tickets.get(ticket_uuid)
481480

backend/kba_models.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,12 @@ class KBADraft(BaseModel):
113113
description="User search queries - how users might search for this KBA"
114114
)
115115

116+
# Generation warnings (e.g. search questions generation failed)
117+
generation_warnings: list[str] = Field(
118+
default_factory=list,
119+
description="Warnings from the generation process (partial failures)"
120+
)
121+
116122
# Guidelines used during generation
117123
guidelines_used: list[str] = Field(default_factory=list, description="Guideline categories used")
118124

@@ -270,6 +276,7 @@ class KBADraftTable(SQLModel, table=True):
270276
tags: list[str] = SQLField(sa_column=Column(JSON))
271277
related_tickets: list[str] = SQLField(sa_column=Column(JSON))
272278
search_questions: list[str] = SQLField(sa_column=Column(JSON))
279+
generation_warnings: list[str] = SQLField(sa_column=Column(JSON, default=[]))
273280
guidelines_used: list[str] = SQLField(sa_column=Column(JSON))
274281

275282
# Metadata

backend/kba_service.py

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,13 @@
1616
"""
1717

1818
import logging
19+
import os
1920
from datetime import datetime
2021
from time import perf_counter
2122
from typing import Optional, Union
2223
from uuid import UUID, uuid4
2324

25+
from sqlalchemy import func
2426
from sqlmodel import Session, select, desc
2527

2628
from csv_data import get_csv_ticket_service
@@ -221,12 +223,14 @@ async def generate_draft(self, create_req: KBADraftCreate) -> KBADraft:
221223
extra={"draft_id": str(draft.id), "ticket_id": str(create_req.ticket_id)}
222224
)
223225
except Exception as e:
224-
# Log error but continue - search questions are optional
225226
logger.warning(
226227
f"Failed to generate search questions: {str(e)}",
227228
extra={"draft_id": str(draft.id), "error": str(e)}
228229
)
229230
draft.search_questions = []
231+
draft.generation_warnings.append(
232+
f"Search questions generation failed: {str(e)}"
233+
)
230234

231235
# 7. Save to database
232236
draft_table = self._draft_to_table(draft)
@@ -557,8 +561,9 @@ def list_drafts(self, filters: KBADraftFilter) -> KBADraftListResponse:
557561
if filters.incident_id:
558562
statement = statement.where(KBADraftTable.incident_id == filters.incident_id)
559563

560-
# Count total
561-
total = len(self.session.exec(statement).all())
564+
# Count total using SQL COUNT(*)
565+
count_statement = select(func.count()).select_from(statement.subquery())
566+
total = self.session.exec(count_statement).one()
562567

563568
# Apply pagination
564569
statement = statement.offset(filters.offset).limit(filters.limit)
@@ -981,23 +986,31 @@ def _get_adapter_config(self, target_system: str) -> dict:
981986
"create_categories": True
982987
},
983988
"sharepoint": {
984-
"site_url": "https://example.sharepoint.com/sites/KB",
985-
"client_id": "...", # TODO: Load from env
986-
"client_secret": "..." # TODO: Load from env
989+
"site_url": os.environ.get("KB_SHAREPOINT_SITE_URL", ""),
990+
"client_id": os.environ.get("KB_SHAREPOINT_CLIENT_ID", ""),
991+
"client_secret": os.environ.get("KB_SHAREPOINT_CLIENT_SECRET", ""),
987992
},
988993
"itsm": {
989-
"instance_url": "https://example.service-now.com",
990-
"username": "...", # TODO: Load from env
991-
"password": "..." # TODO: Load from env
994+
"instance_url": os.environ.get("KB_ITSM_INSTANCE_URL", ""),
995+
"username": os.environ.get("KB_ITSM_USERNAME", ""),
996+
"password": os.environ.get("KB_ITSM_PASSWORD", ""),
992997
},
993998
"confluence": {
994-
"base_url": "https://example.atlassian.net",
995-
"username": "...", # TODO: Load from env
996-
"api_token": "..." # TODO: Load from env
999+
"base_url": os.environ.get("KB_CONFLUENCE_BASE_URL", ""),
1000+
"username": os.environ.get("KB_CONFLUENCE_USERNAME", ""),
1001+
"api_token": os.environ.get("KB_CONFLUENCE_API_TOKEN", ""),
9971002
}
9981003
}
9991004

1000-
return configs.get(target_system, {})
1005+
config = configs.get(target_system, {})
1006+
if target_system != "file" and config:
1007+
missing = [k for k, v in config.items() if not v]
1008+
if missing:
1009+
raise NotImplementedError(
1010+
f"Adapter '{target_system}' requires environment variables: "
1011+
f"{', '.join(f'KB_{target_system.upper()}_{k.upper()}' for k in missing)}"
1012+
)
1013+
return config
10011014

10021015
def _draft_to_table(self, draft: KBADraft) -> KBADraftTable:
10031016
"""Convert KBADraft to KBADraftTable for persistence"""
@@ -1024,6 +1037,7 @@ def _draft_to_table(self, draft: KBADraft) -> KBADraftTable:
10241037
tags=draft.tags,
10251038
related_tickets=draft.related_tickets,
10261039
search_questions=draft.search_questions if draft.search_questions is not None else [],
1040+
generation_warnings=draft.generation_warnings if draft.generation_warnings is not None else [],
10271041
guidelines_used=draft.guidelines_used,
10281042
status=draft.status.value,
10291043
created_at=draft.created_at,
@@ -1055,6 +1069,7 @@ def _table_to_draft(self, table: KBADraftTable) -> KBADraft:
10551069
tags=table.tags,
10561070
related_tickets=table.related_tickets,
10571071
search_questions=table.search_questions if hasattr(table, 'search_questions') and table.search_questions is not None else [],
1072+
generation_warnings=table.generation_warnings if hasattr(table, 'generation_warnings') and table.generation_warnings is not None else [],
10581073
guidelines_used=table.guidelines_used,
10591074
status=KBADraftStatus(table.status),
10601075
created_at=table.created_at,

backend/operations.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,13 @@ def _migrate_kba_schema(engine) -> None:
7171
"ALTER TABLE kba_drafts ADD COLUMN is_auto_generated INTEGER DEFAULT 0"
7272
))
7373
session.commit()
74+
75+
# Check if generation_warnings column exists
76+
if 'generation_warnings' not in columns:
77+
session.exec(text(
78+
"ALTER TABLE kba_drafts ADD COLUMN generation_warnings TEXT DEFAULT '[]'"
79+
))
80+
session.commit()
7481

7582

7683
def _get_kba_session() -> Session:
@@ -134,7 +141,7 @@ def _ensure_csv_loaded() -> None:
134141
if _csv_loaded:
135142
return
136143

137-
default_csv_path = Path(__file__).resolve().parents[1] / "CSV" / "data.csv"
144+
default_csv_path = Path(__file__).resolve().parents[1] / "csv" / "data.csv"
138145
if default_csv_path.exists():
139146
try:
140147
_csv_service.load_csv(default_csv_path)

backend/scheduler.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,18 @@ async def run_now(self) -> dict:
9090
return result.model_dump()
9191

9292
async def _run_auto_generation(self):
93-
"""Internal method to run auto-generation"""
93+
"""Internal method to run auto-generation.
94+
95+
Reloads settings on each run to pick up any changes.
96+
Catches all exceptions to prevent APScheduler from silently swallowing them.
97+
"""
9498
try:
99+
# Reload settings to pick up any changes since last run
100+
settings = self.auto_gen_service.get_settings()
101+
if not settings.enabled:
102+
logger.info("Auto-generation is disabled, skipping run")
103+
return None
104+
95105
result = await self.auto_gen_service.run_auto_generation()
96106

97107
if result.success:
@@ -109,7 +119,7 @@ async def _run_auto_generation(self):
109119

110120
except Exception as e:
111121
logger.error(f"Auto-generation failed with exception: {e}", exc_info=True)
112-
raise
122+
return None
113123

114124
@staticmethod
115125
def _parse_time(time_str: str) -> tuple[int, int]:

0 commit comments

Comments
 (0)