diff --git a/backend/app/api/endpoints/ai_sessions.py b/backend/app/api/endpoints/ai_sessions.py index 8bddc79d..9ca8df2b 100644 --- a/backend/app/api/endpoints/ai_sessions.py +++ b/backend/app/api/endpoints/ai_sessions.py @@ -11,6 +11,7 @@ CRUD and interaction endpoints for AI-powered troubleshooting sessions: POST /ai-sessions/{id}/rate — Submit post-session rating """ import logging +from datetime import datetime from typing import Annotated, Optional from uuid import UUID @@ -464,6 +465,13 @@ async def list_sessions( session_status: Optional[str] = Query(None, alias="status"), skip: int = Query(0, ge=0), limit: int = Query(20, ge=1, le=100), + problem_domain: Optional[str] = Query(None), + matched_flow_id: Optional[UUID] = Query(None), + confidence_tier: Optional[str] = Query(None, pattern="^(guided|exploring|discovery)$"), + ticket_id: Optional[str] = Query(None), + date_from: Optional[datetime] = Query(None), + date_to: Optional[datetime] = Query(None), + q: Optional[str] = Query(None, min_length=2, max_length=200), ): """List the current user's AI sessions (owned or picked up).""" user_id_str = str(current_user.id) @@ -482,6 +490,19 @@ async def list_sessions( if session_status: query = query.where(AISession.status == session_status) + if problem_domain: + query = query.where(AISession.problem_domain == problem_domain) + if matched_flow_id: + query = query.where(AISession.matched_flow_id == matched_flow_id) + if confidence_tier: + query = query.where(AISession.confidence_tier == confidence_tier) + if ticket_id: + query = query.where(AISession.ticket_id == ticket_id) + if date_from: + query = query.where(AISession.created_at >= date_from) + if date_to: + query = query.where(AISession.created_at <= date_to) + # TODO: Full-text search via q param — see Task 7 result = await db.execute(query) sessions = result.scalars().all() diff --git a/backend/app/api/endpoints/sessions.py b/backend/app/api/endpoints/sessions.py index ea4375f1..570b7672 100644 --- a/backend/app/api/endpoints/sessions.py +++ b/backend/app/api/endpoints/sessions.py @@ -424,18 +424,43 @@ async def export_session( for sd in sd_result.scalars().all() ] + # Query file upload evidence for non-PDF formats + from app.models.file_upload import FileUpload + from app.services import storage_service as _storage_service + from app.core.config import settings as _export_settings + upload_items: list[dict] = [] + if _export_settings.STORAGE_ENDPOINT: + try: + uploads_result = await db.execute( + select(FileUpload) + .where(FileUpload.session_id == session_id) + .order_by(FileUpload.created_at) + ) + for u in uploads_result.scalars().all(): + try: + url = _storage_service.get_presigned_url(u.storage_key) + upload_items.append({ + "filename": u.filename, + "url": url, + "is_image": u.content_type.startswith("image/"), + }) + except Exception: + pass # Skip uploads that fail URL generation + except Exception: + pass # Storage errors should not fail the export + # Generate export based on format if export_options.format == "markdown": - content = generate_markdown_export(session, export_options, supporting_data=supporting_data_items) + content = generate_markdown_export(session, export_options, supporting_data=supporting_data_items, uploads=upload_items) media_type = "text/markdown" elif export_options.format == "html": - content = generate_html_export(session, export_options, supporting_data=supporting_data_items) + content = generate_html_export(session, export_options, supporting_data=supporting_data_items, uploads=upload_items) media_type = "text/html" elif export_options.format == "psa": - content = generate_psa_export(session, export_options, supporting_data=supporting_data_items) + content = generate_psa_export(session, export_options, supporting_data=supporting_data_items, uploads=upload_items) media_type = "text/plain" else: # text - content = generate_text_export(session, export_options, supporting_data=supporting_data_items) + content = generate_text_export(session, export_options, supporting_data=supporting_data_items, uploads=upload_items) media_type = "text/plain" # Resolve variables in export output diff --git a/backend/app/services/export_service.py b/backend/app/services/export_service.py index 5fa2530f..ce03cc7d 100644 --- a/backend/app/services/export_service.py +++ b/backend/app/services/export_service.py @@ -171,10 +171,10 @@ def _escape_markdown_table(value: str) -> str: return value.replace("|", "\\|").replace("\n", " ") -def generate_markdown_export(session: Session, options: SessionExport, supporting_data: list[dict] | None = None) -> str: +def generate_markdown_export(session: Session, options: SessionExport, supporting_data: list[dict] | None = None, uploads: list[dict] | None = None) -> str: """Generate markdown export.""" if _is_procedural_session(session): - return _generate_procedural_markdown(session, options) + return _generate_procedural_markdown(session, options, uploads=uploads) lines = [] outcome_label = _get_outcome_label(session) @@ -223,6 +223,21 @@ def generate_markdown_export(session: Session, options: SessionExport, supportin lines.append("---") lines.append("") + # File upload evidence + if uploads: + lines.append("## Evidence") + lines.append("") + for upload in uploads: + name = upload["filename"] + url = upload["url"] + if upload.get("is_image"): + lines.append(f"- ") + else: + lines.append(f"- [{name}]({url})") + lines.append("") + lines.append("---") + lines.append("") + lines.append("## Troubleshooting Steps") lines.append("") @@ -306,10 +321,10 @@ def generate_markdown_export(session: Session, options: SessionExport, supportin return "\n".join(lines) -def generate_text_export(session: Session, options: SessionExport, supporting_data: list[dict] | None = None) -> str: +def generate_text_export(session: Session, options: SessionExport, supporting_data: list[dict] | None = None, uploads: list[dict] | None = None) -> str: """Generate plain text export.""" if _is_procedural_session(session): - return _generate_procedural_text(session, options) + return _generate_procedural_text(session, options, uploads=uploads) lines = [] outcome_label = _get_outcome_label(session) @@ -349,6 +364,13 @@ def generate_text_export(session: Session, options: SessionExport, supporting_da lines.append(scratchpad) lines.append("") + # File upload evidence + if uploads: + lines.append("--- Evidence ---") + for upload in uploads: + lines.append(f"- {upload['filename']}: {upload['url']}") + lines.append("") + lines.append("TROUBLESHOOTING STEPS") lines.append("-" * 20) @@ -420,10 +442,10 @@ def generate_text_export(session: Session, options: SessionExport, supporting_da return "\n".join(lines) -def generate_html_export(session: Session, options: SessionExport, supporting_data: list[dict] | None = None) -> str: +def generate_html_export(session: Session, options: SessionExport, supporting_data: list[dict] | None = None, uploads: list[dict] | None = None) -> str: """Generate HTML export.""" if _is_procedural_session(session): - return _generate_procedural_html(session, options) + return _generate_procedural_html(session, options, uploads=uploads) tree_name = html.escape(session.tree_snapshot.get("name", "Troubleshooting Session")) outcome_label = _get_outcome_label(session) @@ -476,6 +498,19 @@ def generate_html_export(session: Session, options: SessionExport, supporting_da html_parts.append('
Generated with ResolutionFlow — https://resolutionflow.com
') @@ -922,7 +999,7 @@ def _generate_procedural_html(session: Session, options: SessionExport) -> str: return "\n".join(html_parts) -def _generate_procedural_psa(session: Session, options: SessionExport) -> str: +def _generate_procedural_psa(session: Session, options: SessionExport, uploads: list[dict] | None = None) -> str: """Generate PSA/ticket export for procedural sessions.""" lines = [] outcome_label = _get_outcome_label(session) @@ -987,6 +1064,13 @@ def _generate_procedural_psa(session: Session, options: SessionExport) -> str: lines.append("--- TIME SPENT ---") lines.append(f"Duration: {_format_duration(session.started_at, session.completed_at)}") + # File upload evidence + if uploads: + lines.append("") + lines.append("--- Evidence ---") + for upload in uploads: + lines.append(f"- {upload['filename']} — [{upload['url']}]") + # Branding footer lines.append("") lines.append("---") @@ -1095,6 +1179,32 @@ async def generate_pdf_export(session: Session, options: SessionExport, db) -> b for sd in supporting_data_rows ] + # Query file upload evidence + from app.models.file_upload import FileUpload + from app.services import storage_service + from app.core.config import settings as _settings + uploads_for_export: list[dict] = [] + if _settings.STORAGE_ENDPOINT: + try: + uploads_result = await db.execute( + sa_select(FileUpload) + .where(FileUpload.session_id == session.id) + .order_by(FileUpload.created_at) + ) + upload_rows = uploads_result.scalars().all() + for u in upload_rows: + try: + url = storage_service.get_presigned_url(u.storage_key) + uploads_for_export.append({ + "filename": u.filename, + "url": url, + "is_image": u.content_type.startswith("image/"), + }) + except Exception: + pass # Skip individual uploads that fail URL generation + except Exception: + pass # Storage errors should not fail the export + # Calculate duration and format outcome duration = _format_duration(session.started_at, session.completed_at) session_date = session.started_at.strftime("%Y-%m-%d %H:%M") @@ -1172,6 +1282,7 @@ async def generate_pdf_export(session: Session, options: SessionExport, db) -> b summary=summary_text, steps=steps, supporting_data=supporting_data, + uploads=uploads_for_export, generated_at=generated_at, ) diff --git a/backend/app/templates/export_pdf.html b/backend/app/templates/export_pdf.html index 13c82b82..7900a7dd 100644 --- a/backend/app/templates/export_pdf.html +++ b/backend/app/templates/export_pdf.html @@ -362,5 +362,24 @@ {% endif %} + + {% if uploads %} +