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"- ![{name}]({url})") + 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('

Evidence / Reference

') html_parts.append(f'
{html.escape(scratchpad)}
') + # File upload evidence + if uploads: + html_parts.append('

Evidence

') + html_parts.append('
') + for upload in uploads: + name = html.escape(upload["filename"]) + url = html.escape(upload["url"]) + if upload.get("is_image"): + html_parts.append(f'{name}') + else: + html_parts.append(f'

{name}

') + html_parts.append('
') + html_parts.append('

Troubleshooting Steps

') decisions = session.decisions @@ -541,10 +576,10 @@ def generate_html_export(session: Session, options: SessionExport, supporting_da return "\n".join(html_parts) -def generate_psa_export(session: Session, options: SessionExport, supporting_data: list[dict] | None = None) -> str: +def generate_psa_export(session: Session, options: SessionExport, supporting_data: list[dict] | None = None, uploads: list[dict] | None = None) -> str: """Generate PSA/ticket note export optimized for ConnectWise and similar PSA tools.""" if _is_procedural_session(session): - return _generate_procedural_psa(session, options) + return _generate_procedural_psa(session, options, uploads=uploads) lines = [] outcome_label = _get_outcome_label(session) @@ -661,6 +696,13 @@ def generate_psa_export(session: Session, options: SessionExport, supporting_dat scratchpad = getattr(session, 'scratchpad', '') or '' lines.append(scratchpad.strip() if scratchpad.strip() else "None") + # 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("---") @@ -682,7 +724,7 @@ def _get_session_variables(session: Session) -> dict[str, str]: return {} -def _generate_procedural_markdown(session: Session, options: SessionExport) -> str: +def _generate_procedural_markdown(session: Session, options: SessionExport, uploads: list[dict] | None = None) -> str: """Generate markdown export for procedural sessions.""" lines = [] outcome_label = _get_outcome_label(session) @@ -755,6 +797,21 @@ def _generate_procedural_markdown(session: Session, options: SessionExport) -> s lines.append(outcome_notes.strip()) lines.append("") + # File upload evidence + if uploads: + lines.append("---") + lines.append("") + lines.append("## Evidence") + lines.append("") + for upload in uploads: + name = upload["filename"] + url = upload["url"] + if upload.get("is_image"): + lines.append(f"- ![{name}]({url})") + else: + lines.append(f"- [{name}]({url})") + lines.append("") + # Branding footer lines.append("---") lines.append("Generated with ResolutionFlow — https://resolutionflow.com") @@ -762,7 +819,7 @@ def _generate_procedural_markdown(session: Session, options: SessionExport) -> s return "\n".join(lines) -def _generate_procedural_text(session: Session, options: SessionExport) -> str: +def _generate_procedural_text(session: Session, options: SessionExport, uploads: list[dict] | None = None) -> str: """Generate plain text export for procedural sessions.""" lines = [] outcome_label = _get_outcome_label(session) @@ -824,6 +881,13 @@ def _generate_procedural_text(session: Session, options: SessionExport) -> str: lines.append("-" * 20) lines.append(outcome_notes.strip()) + # 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("---") @@ -832,7 +896,7 @@ def _generate_procedural_text(session: Session, options: SessionExport) -> str: return "\n".join(lines) -def _generate_procedural_html(session: Session, options: SessionExport) -> str: +def _generate_procedural_html(session: Session, options: SessionExport, uploads: list[dict] | None = None) -> str: """Generate HTML export for procedural sessions.""" tree_name = html.escape(session.tree_snapshot.get("name", "Procedure")) outcome_label = _get_outcome_label(session) @@ -914,6 +978,19 @@ def _generate_procedural_html(session: Session, options: SessionExport) -> str: html_parts.append('

Notes

') html_parts.append(f'
{html.escape(outcome_notes.strip())}
') + # File upload evidence + if uploads: + html_parts.append('

Evidence

') + html_parts.append('
') + for upload in uploads: + name = html.escape(upload["filename"]) + url = html.escape(upload["url"]) + if upload.get("is_image"): + html_parts.append(f'{name}') + else: + html_parts.append(f'

{name}

') + html_parts.append('
') + # Branding footer html_parts.append('
') 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 %} +
+
Evidence
+
+ {% for upload in uploads %} +
+ {% if upload.is_image %} + {{ upload.filename }} +
{{ upload.filename }}
+ {% else %} + {{ upload.filename }} + {% endif %} +
+ {% endfor %} +
+
+ {% endif %} + diff --git a/frontend/src/api/aiSessions.ts b/frontend/src/api/aiSessions.ts index 2d74135c..e102682b 100644 --- a/frontend/src/api/aiSessions.ts +++ b/frontend/src/api/aiSessions.ts @@ -43,8 +43,23 @@ export const aiSessionsApi = { return response.data }, - async listSessions(params?: { status?: string; skip?: number; limit?: number }): Promise { - const response = await apiClient.get('/ai-sessions', { params }) + async listSessions(params?: { + status?: string + skip?: number + limit?: number + problem_domain?: string + matched_flow_id?: string + confidence_tier?: string + ticket_id?: string + date_from?: string + date_to?: string + q?: string + }): Promise { + // Strip empty string values so they aren't sent as empty query params + const cleanParams = params + ? Object.fromEntries(Object.entries(params).filter(([, v]) => v !== '' && v !== undefined)) + : undefined + const response = await apiClient.get('/ai-sessions', { params: cleanParams }) return response.data }, diff --git a/frontend/src/pages/SessionHistoryPage.tsx b/frontend/src/pages/SessionHistoryPage.tsx index e1e73995..af765a48 100644 --- a/frontend/src/pages/SessionHistoryPage.tsx +++ b/frontend/src/pages/SessionHistoryPage.tsx @@ -1,4 +1,5 @@ import { useEffect, useState, useRef, useCallback } from 'react' +import { Search } from 'lucide-react' import { Link, useNavigate, useSearchParams } from 'react-router-dom' import { PageMeta } from '@/components/common/PageMeta' import { sessionsApi } from '@/api/sessions' @@ -24,6 +25,13 @@ export function SessionHistoryPage() { const [sessionType, setSessionType] = useState<'flow' | 'ai'>('flow') const [aiSessions, setAiSessions] = useState([]) const [aiLoading, setAiLoading] = useState(false) + const [aiFilters, setAiFilters] = useState({ + q: '', + problem_domain: '', + confidence_tier: '', + date_from: '', + date_to: '', + }) const [sessions, setSessions] = useState([]) const [hasMore, setHasMore] = useState(false) @@ -147,14 +155,21 @@ export function SessionHistoryPage() { setSearchParams(params, { replace: true }) }, [filters, setSearchParams]) - // Load AI sessions when tab is active + // Load AI sessions when tab is active or filters change useEffect(() => { if (sessionType !== 'ai') return let cancelled = false const loadAiSessions = async () => { setAiLoading(true) try { - const data = await aiSessionsApi.listSessions({ limit: 50 }) + const data = await aiSessionsApi.listSessions({ + limit: 50, + q: aiFilters.q || undefined, + problem_domain: aiFilters.problem_domain || undefined, + confidence_tier: aiFilters.confidence_tier || undefined, + date_from: aiFilters.date_from || undefined, + date_to: aiFilters.date_to || undefined, + }) if (!cancelled) setAiSessions(data) } catch { if (!cancelled) toast.error('Failed to load AI sessions') @@ -164,7 +179,7 @@ export function SessionHistoryPage() { } loadAiSessions() return () => { cancelled = true } - }, [sessionType]) + }, [sessionType, aiFilters]) const handleFilterChange = (newFilters: SessionFilterState) => { setFilters(newFilters) @@ -300,30 +315,131 @@ export function SessionHistoryPage() { {/* AI Sessions view */} {sessionType === 'ai' && ( - aiLoading ? ( -
- -
- ) : aiSessions.length === 0 ? ( - + {/* AI Session Filter Bar */} +
+
+ {/* Search input */} +
+ + setAiFilters((f) => ({ ...f, q: e.target.value }))} + placeholder="Search sessions..." + className="w-full rounded-lg border border-border bg-card pl-8 pr-3 py-1.5 text-sm text-foreground placeholder:text-muted-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-none" + /> +
+ + {/* Problem domain dropdown */} + + + {/* Confidence tier pills */} +
+ {(['', 'guided', 'exploring', 'discovery'] as const).map((tier) => ( + + ))} +
+ + {/* Date range inputs */} +
+ setAiFilters((f) => ({ ...f, date_from: e.target.value }))} + title="From date" + className="rounded-lg border border-border bg-card px-3 py-1.5 text-sm text-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-none [color-scheme:dark]" + /> + to + setAiFilters((f) => ({ ...f, date_to: e.target.value }))} + title="To date" + className="rounded-lg border border-border bg-card px-3 py-1.5 text-sm text-foreground focus:border-[rgba(6,182,212,0.3)] focus:outline-none [color-scheme:dark]" + /> +
+ + {/* Clear filters */} + {(aiFilters.q || aiFilters.problem_domain || aiFilters.confidence_tier || aiFilters.date_from || aiFilters.date_to) && ( + + )} +
- ) + + {aiLoading ? ( +
+ +
+ ) : aiSessions.length === 0 ? ( + (aiFilters.q || aiFilters.problem_domain || aiFilters.confidence_tier || aiFilters.date_from || aiFilters.date_to) ? ( + setAiFilters({ q: '', problem_domain: '', confidence_tier: '', date_from: '', date_to: '' })} + className="text-foreground hover:underline text-sm" + > + Clear all filters + + } + /> + ) : ( + + Start AI Session + + } + /> + ) + ) : ( +
+ {aiSessions.map((s) => ( + + ))} +
+ )} + )} {/* Flow Sessions Content */}