feat(search): add structured filters to AI session list endpoint and frontend
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ CRUD and interaction endpoints for AI-powered troubleshooting sessions:
|
|||||||
POST /ai-sessions/{id}/rate — Submit post-session rating
|
POST /ai-sessions/{id}/rate — Submit post-session rating
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
from typing import Annotated, Optional
|
from typing import Annotated, Optional
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
@@ -464,6 +465,13 @@ async def list_sessions(
|
|||||||
session_status: Optional[str] = Query(None, alias="status"),
|
session_status: Optional[str] = Query(None, alias="status"),
|
||||||
skip: int = Query(0, ge=0),
|
skip: int = Query(0, ge=0),
|
||||||
limit: int = Query(20, ge=1, le=100),
|
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)."""
|
"""List the current user's AI sessions (owned or picked up)."""
|
||||||
user_id_str = str(current_user.id)
|
user_id_str = str(current_user.id)
|
||||||
@@ -482,6 +490,19 @@ async def list_sessions(
|
|||||||
|
|
||||||
if session_status:
|
if session_status:
|
||||||
query = query.where(AISession.status == 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)
|
result = await db.execute(query)
|
||||||
sessions = result.scalars().all()
|
sessions = result.scalars().all()
|
||||||
|
|||||||
@@ -424,18 +424,43 @@ async def export_session(
|
|||||||
for sd in sd_result.scalars().all()
|
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
|
# Generate export based on format
|
||||||
if export_options.format == "markdown":
|
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"
|
media_type = "text/markdown"
|
||||||
elif export_options.format == "html":
|
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"
|
media_type = "text/html"
|
||||||
elif export_options.format == "psa":
|
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"
|
media_type = "text/plain"
|
||||||
else: # text
|
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"
|
media_type = "text/plain"
|
||||||
|
|
||||||
# Resolve variables in export output
|
# Resolve variables in export output
|
||||||
|
|||||||
@@ -171,10 +171,10 @@ def _escape_markdown_table(value: str) -> str:
|
|||||||
return value.replace("|", "\\|").replace("\n", " ")
|
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."""
|
"""Generate markdown export."""
|
||||||
if _is_procedural_session(session):
|
if _is_procedural_session(session):
|
||||||
return _generate_procedural_markdown(session, options)
|
return _generate_procedural_markdown(session, options, uploads=uploads)
|
||||||
lines = []
|
lines = []
|
||||||
outcome_label = _get_outcome_label(session)
|
outcome_label = _get_outcome_label(session)
|
||||||
|
|
||||||
@@ -223,6 +223,21 @@ def generate_markdown_export(session: Session, options: SessionExport, supportin
|
|||||||
lines.append("---")
|
lines.append("---")
|
||||||
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("## Troubleshooting Steps")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
|
|
||||||
@@ -306,10 +321,10 @@ def generate_markdown_export(session: Session, options: SessionExport, supportin
|
|||||||
return "\n".join(lines)
|
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."""
|
"""Generate plain text export."""
|
||||||
if _is_procedural_session(session):
|
if _is_procedural_session(session):
|
||||||
return _generate_procedural_text(session, options)
|
return _generate_procedural_text(session, options, uploads=uploads)
|
||||||
lines = []
|
lines = []
|
||||||
outcome_label = _get_outcome_label(session)
|
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(scratchpad)
|
||||||
lines.append("")
|
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("TROUBLESHOOTING STEPS")
|
||||||
lines.append("-" * 20)
|
lines.append("-" * 20)
|
||||||
|
|
||||||
@@ -420,10 +442,10 @@ def generate_text_export(session: Session, options: SessionExport, supporting_da
|
|||||||
return "\n".join(lines)
|
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."""
|
"""Generate HTML export."""
|
||||||
if _is_procedural_session(session):
|
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"))
|
tree_name = html.escape(session.tree_snapshot.get("name", "Troubleshooting Session"))
|
||||||
outcome_label = _get_outcome_label(session)
|
outcome_label = _get_outcome_label(session)
|
||||||
|
|
||||||
@@ -476,6 +498,19 @@ def generate_html_export(session: Session, options: SessionExport, supporting_da
|
|||||||
html_parts.append('<h2>Evidence / Reference</h2>')
|
html_parts.append('<h2>Evidence / Reference</h2>')
|
||||||
html_parts.append(f'<div class="scratchpad" style="white-space: pre-wrap; background: #f9f9f9; padding: 12px; border-radius: 4px; margin-bottom: 20px;">{html.escape(scratchpad)}</div>')
|
html_parts.append(f'<div class="scratchpad" style="white-space: pre-wrap; background: #f9f9f9; padding: 12px; border-radius: 4px; margin-bottom: 20px;">{html.escape(scratchpad)}</div>')
|
||||||
|
|
||||||
|
# File upload evidence
|
||||||
|
if uploads:
|
||||||
|
html_parts.append('<h3>Evidence</h3>')
|
||||||
|
html_parts.append('<div class="evidence-grid" style="margin-bottom: 20px;">')
|
||||||
|
for upload in uploads:
|
||||||
|
name = html.escape(upload["filename"])
|
||||||
|
url = html.escape(upload["url"])
|
||||||
|
if upload.get("is_image"):
|
||||||
|
html_parts.append(f'<img src="{url}" alt="{name}" style="max-width: 400px; border-radius: 8px; display: block; margin-bottom: 8px;" />')
|
||||||
|
else:
|
||||||
|
html_parts.append(f'<p><a href="{url}">{name}</a></p>')
|
||||||
|
html_parts.append('</div>')
|
||||||
|
|
||||||
html_parts.append('<h2>Troubleshooting Steps</h2>')
|
html_parts.append('<h2>Troubleshooting Steps</h2>')
|
||||||
|
|
||||||
decisions = session.decisions
|
decisions = session.decisions
|
||||||
@@ -541,10 +576,10 @@ def generate_html_export(session: Session, options: SessionExport, supporting_da
|
|||||||
return "\n".join(html_parts)
|
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."""
|
"""Generate PSA/ticket note export optimized for ConnectWise and similar PSA tools."""
|
||||||
if _is_procedural_session(session):
|
if _is_procedural_session(session):
|
||||||
return _generate_procedural_psa(session, options)
|
return _generate_procedural_psa(session, options, uploads=uploads)
|
||||||
lines = []
|
lines = []
|
||||||
outcome_label = _get_outcome_label(session)
|
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 ''
|
scratchpad = getattr(session, 'scratchpad', '') or ''
|
||||||
lines.append(scratchpad.strip() if scratchpad.strip() else "None")
|
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
|
# Branding footer
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("---")
|
lines.append("---")
|
||||||
@@ -682,7 +724,7 @@ def _get_session_variables(session: Session) -> dict[str, str]:
|
|||||||
return {}
|
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."""
|
"""Generate markdown export for procedural sessions."""
|
||||||
lines = []
|
lines = []
|
||||||
outcome_label = _get_outcome_label(session)
|
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(outcome_notes.strip())
|
||||||
lines.append("")
|
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"- ")
|
||||||
|
else:
|
||||||
|
lines.append(f"- [{name}]({url})")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
# Branding footer
|
# Branding footer
|
||||||
lines.append("---")
|
lines.append("---")
|
||||||
lines.append("Generated with ResolutionFlow — https://resolutionflow.com")
|
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)
|
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."""
|
"""Generate plain text export for procedural sessions."""
|
||||||
lines = []
|
lines = []
|
||||||
outcome_label = _get_outcome_label(session)
|
outcome_label = _get_outcome_label(session)
|
||||||
@@ -824,6 +881,13 @@ def _generate_procedural_text(session: Session, options: SessionExport) -> str:
|
|||||||
lines.append("-" * 20)
|
lines.append("-" * 20)
|
||||||
lines.append(outcome_notes.strip())
|
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
|
# Branding footer
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("---")
|
lines.append("---")
|
||||||
@@ -832,7 +896,7 @@ def _generate_procedural_text(session: Session, options: SessionExport) -> str:
|
|||||||
return "\n".join(lines)
|
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."""
|
"""Generate HTML export for procedural sessions."""
|
||||||
tree_name = html.escape(session.tree_snapshot.get("name", "Procedure"))
|
tree_name = html.escape(session.tree_snapshot.get("name", "Procedure"))
|
||||||
outcome_label = _get_outcome_label(session)
|
outcome_label = _get_outcome_label(session)
|
||||||
@@ -914,6 +978,19 @@ def _generate_procedural_html(session: Session, options: SessionExport) -> str:
|
|||||||
html_parts.append('<h2>Notes</h2>')
|
html_parts.append('<h2>Notes</h2>')
|
||||||
html_parts.append(f'<div style="white-space: pre-wrap;">{html.escape(outcome_notes.strip())}</div>')
|
html_parts.append(f'<div style="white-space: pre-wrap;">{html.escape(outcome_notes.strip())}</div>')
|
||||||
|
|
||||||
|
# File upload evidence
|
||||||
|
if uploads:
|
||||||
|
html_parts.append('<h3>Evidence</h3>')
|
||||||
|
html_parts.append('<div class="evidence-grid" style="margin-bottom: 20px;">')
|
||||||
|
for upload in uploads:
|
||||||
|
name = html.escape(upload["filename"])
|
||||||
|
url = html.escape(upload["url"])
|
||||||
|
if upload.get("is_image"):
|
||||||
|
html_parts.append(f'<img src="{url}" alt="{name}" style="max-width: 400px; border-radius: 8px; display: block; margin-bottom: 8px;" />')
|
||||||
|
else:
|
||||||
|
html_parts.append(f'<p><a href="{url}">{name}</a></p>')
|
||||||
|
html_parts.append('</div>')
|
||||||
|
|
||||||
# Branding footer
|
# Branding footer
|
||||||
html_parts.append('<hr style="margin-top: 32px; border: none; border-top: 1px solid #ddd;">')
|
html_parts.append('<hr style="margin-top: 32px; border: none; border-top: 1px solid #ddd;">')
|
||||||
html_parts.append('<p style="margin-top: 12px; font-size: 0.8em; color: #999; text-align: center;">Generated with <a href="https://resolutionflow.com" style="color: #06b6d4; text-decoration: none;">ResolutionFlow</a> — https://resolutionflow.com</p>')
|
html_parts.append('<p style="margin-top: 12px; font-size: 0.8em; color: #999; text-align: center;">Generated with <a href="https://resolutionflow.com" style="color: #06b6d4; text-decoration: none;">ResolutionFlow</a> — https://resolutionflow.com</p>')
|
||||||
@@ -922,7 +999,7 @@ def _generate_procedural_html(session: Session, options: SessionExport) -> str:
|
|||||||
return "\n".join(html_parts)
|
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."""
|
"""Generate PSA/ticket export for procedural sessions."""
|
||||||
lines = []
|
lines = []
|
||||||
outcome_label = _get_outcome_label(session)
|
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("--- TIME SPENT ---")
|
||||||
lines.append(f"Duration: {_format_duration(session.started_at, session.completed_at)}")
|
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
|
# Branding footer
|
||||||
lines.append("")
|
lines.append("")
|
||||||
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
|
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
|
# Calculate duration and format outcome
|
||||||
duration = _format_duration(session.started_at, session.completed_at)
|
duration = _format_duration(session.started_at, session.completed_at)
|
||||||
session_date = session.started_at.strftime("%Y-%m-%d %H:%M")
|
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,
|
summary=summary_text,
|
||||||
steps=steps,
|
steps=steps,
|
||||||
supporting_data=supporting_data,
|
supporting_data=supporting_data,
|
||||||
|
uploads=uploads_for_export,
|
||||||
generated_at=generated_at,
|
generated_at=generated_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -362,5 +362,24 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- File Upload Evidence -->
|
||||||
|
{% if uploads %}
|
||||||
|
<div class="supporting-data">
|
||||||
|
<div class="section-title">Evidence</div>
|
||||||
|
<div style="display: flex; flex-wrap: wrap; gap: 12px;">
|
||||||
|
{% for upload in uploads %}
|
||||||
|
<div style="margin-bottom: 8px;">
|
||||||
|
{% if upload.is_image %}
|
||||||
|
<img src="{{ upload.url }}" alt="{{ upload.filename }}" style="max-width: 400px; border-radius: 8px; display: block; margin-bottom: 4px;" />
|
||||||
|
<div style="font-size: 0.8em; color: #666;">{{ upload.filename }}</div>
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ upload.url }}" style="color: #06b6d4;">{{ upload.filename }}</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -43,8 +43,23 @@ export const aiSessionsApi = {
|
|||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
async listSessions(params?: { status?: string; skip?: number; limit?: number }): Promise<AISessionSummary[]> {
|
async listSessions(params?: {
|
||||||
const response = await apiClient.get<AISessionSummary[]>('/ai-sessions', { 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<AISessionSummary[]> {
|
||||||
|
// 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<AISessionSummary[]>('/ai-sessions', { params: cleanParams })
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useState, useRef, useCallback } from 'react'
|
import { useEffect, useState, useRef, useCallback } from 'react'
|
||||||
|
import { Search } from 'lucide-react'
|
||||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
||||||
import { PageMeta } from '@/components/common/PageMeta'
|
import { PageMeta } from '@/components/common/PageMeta'
|
||||||
import { sessionsApi } from '@/api/sessions'
|
import { sessionsApi } from '@/api/sessions'
|
||||||
@@ -24,6 +25,13 @@ export function SessionHistoryPage() {
|
|||||||
const [sessionType, setSessionType] = useState<'flow' | 'ai'>('flow')
|
const [sessionType, setSessionType] = useState<'flow' | 'ai'>('flow')
|
||||||
const [aiSessions, setAiSessions] = useState<AISessionSummary[]>([])
|
const [aiSessions, setAiSessions] = useState<AISessionSummary[]>([])
|
||||||
const [aiLoading, setAiLoading] = useState(false)
|
const [aiLoading, setAiLoading] = useState(false)
|
||||||
|
const [aiFilters, setAiFilters] = useState({
|
||||||
|
q: '',
|
||||||
|
problem_domain: '',
|
||||||
|
confidence_tier: '',
|
||||||
|
date_from: '',
|
||||||
|
date_to: '',
|
||||||
|
})
|
||||||
|
|
||||||
const [sessions, setSessions] = useState<Session[]>([])
|
const [sessions, setSessions] = useState<Session[]>([])
|
||||||
const [hasMore, setHasMore] = useState(false)
|
const [hasMore, setHasMore] = useState(false)
|
||||||
@@ -147,14 +155,21 @@ export function SessionHistoryPage() {
|
|||||||
setSearchParams(params, { replace: true })
|
setSearchParams(params, { replace: true })
|
||||||
}, [filters, setSearchParams])
|
}, [filters, setSearchParams])
|
||||||
|
|
||||||
// Load AI sessions when tab is active
|
// Load AI sessions when tab is active or filters change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sessionType !== 'ai') return
|
if (sessionType !== 'ai') return
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
const loadAiSessions = async () => {
|
const loadAiSessions = async () => {
|
||||||
setAiLoading(true)
|
setAiLoading(true)
|
||||||
try {
|
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)
|
if (!cancelled) setAiSessions(data)
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) toast.error('Failed to load AI sessions')
|
if (!cancelled) toast.error('Failed to load AI sessions')
|
||||||
@@ -164,7 +179,7 @@ export function SessionHistoryPage() {
|
|||||||
}
|
}
|
||||||
loadAiSessions()
|
loadAiSessions()
|
||||||
return () => { cancelled = true }
|
return () => { cancelled = true }
|
||||||
}, [sessionType])
|
}, [sessionType, aiFilters])
|
||||||
|
|
||||||
const handleFilterChange = (newFilters: SessionFilterState) => {
|
const handleFilterChange = (newFilters: SessionFilterState) => {
|
||||||
setFilters(newFilters)
|
setFilters(newFilters)
|
||||||
@@ -300,30 +315,131 @@ export function SessionHistoryPage() {
|
|||||||
|
|
||||||
{/* AI Sessions view */}
|
{/* AI Sessions view */}
|
||||||
{sessionType === 'ai' && (
|
{sessionType === 'ai' && (
|
||||||
aiLoading ? (
|
<>
|
||||||
<div className="flex justify-center py-12">
|
{/* AI Session Filter Bar */}
|
||||||
<Spinner />
|
<div className="glass-card-static p-3 mb-4">
|
||||||
</div>
|
<div className="flex flex-wrap gap-3 items-center">
|
||||||
) : aiSessions.length === 0 ? (
|
{/* Search input */}
|
||||||
<EmptyState
|
<div className="relative flex-1 min-w-[180px]">
|
||||||
title="No AI sessions yet"
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground pointer-events-none" />
|
||||||
description="Start a FlowPilot session to get AI-guided troubleshooting. Sessions will appear here."
|
<input
|
||||||
action={
|
type="text"
|
||||||
<Link
|
value={aiFilters.q}
|
||||||
to="/pilot"
|
onChange={(e) => setAiFilters((f) => ({ ...f, q: e.target.value }))}
|
||||||
className="inline-flex items-center gap-2 rounded-[10px] bg-gradient-brand px-5 py-2.5 text-sm font-semibold text-[#101114] shadow-lg shadow-primary/20 hover:opacity-90 active:scale-[0.97] transition-all"
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Problem domain dropdown */}
|
||||||
|
<select
|
||||||
|
value={aiFilters.problem_domain}
|
||||||
|
onChange={(e) => setAiFilters((f) => ({ ...f, problem_domain: e.target.value }))}
|
||||||
|
title="Filter by problem domain"
|
||||||
|
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 [&>option]:bg-[#1a1c21] [&>option]:text-foreground"
|
||||||
>
|
>
|
||||||
Start AI Session
|
<option value="">All domains</option>
|
||||||
</Link>
|
<option value="Active Directory">Active Directory</option>
|
||||||
}
|
<option value="Networking">Networking</option>
|
||||||
/>
|
<option value="Microsoft 365">Microsoft 365</option>
|
||||||
) : (
|
<option value="Hardware">Hardware</option>
|
||||||
<div className="space-y-2">
|
<option value="Security">Security</option>
|
||||||
{aiSessions.map((s) => (
|
<option value="Email">Email</option>
|
||||||
<AISessionListItem key={s.id} session={s} />
|
<option value="Printing">Printing</option>
|
||||||
))}
|
<option value="VPN / Remote Access">VPN / Remote Access</option>
|
||||||
|
<option value="Cloud Services">Cloud Services</option>
|
||||||
|
<option value="Other">Other</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Confidence tier pills */}
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{(['', 'guided', 'exploring', 'discovery'] as const).map((tier) => (
|
||||||
|
<button
|
||||||
|
key={tier}
|
||||||
|
onClick={() => setAiFilters((f) => ({ ...f, confidence_tier: tier }))}
|
||||||
|
className={cn(
|
||||||
|
'rounded-full border px-3 py-1 text-xs font-label transition-colors',
|
||||||
|
aiFilters.confidence_tier === tier
|
||||||
|
? 'bg-primary/10 text-foreground border-primary/30'
|
||||||
|
: 'bg-card text-muted-foreground border-border hover:text-foreground hover:border-[rgba(255,255,255,0.12)]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tier === '' ? 'All' : tier.charAt(0).toUpperCase() + tier.slice(1)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date range inputs */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={aiFilters.date_from}
|
||||||
|
onChange={(e) => 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]"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">to</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={aiFilters.date_to}
|
||||||
|
onChange={(e) => 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]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Clear filters */}
|
||||||
|
{(aiFilters.q || aiFilters.problem_domain || aiFilters.confidence_tier || aiFilters.date_from || aiFilters.date_to) && (
|
||||||
|
<button
|
||||||
|
onClick={() => setAiFilters({ q: '', problem_domain: '', confidence_tier: '', date_from: '', date_to: '' })}
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
|
||||||
|
{aiLoading ? (
|
||||||
|
<div className="flex justify-center py-12">
|
||||||
|
<Spinner />
|
||||||
|
</div>
|
||||||
|
) : aiSessions.length === 0 ? (
|
||||||
|
(aiFilters.q || aiFilters.problem_domain || aiFilters.confidence_tier || aiFilters.date_from || aiFilters.date_to) ? (
|
||||||
|
<EmptyState
|
||||||
|
title="No sessions match your filters"
|
||||||
|
description="Try adjusting your search or filters."
|
||||||
|
action={
|
||||||
|
<button
|
||||||
|
onClick={() => setAiFilters({ q: '', problem_domain: '', confidence_tier: '', date_from: '', date_to: '' })}
|
||||||
|
className="text-foreground hover:underline text-sm"
|
||||||
|
>
|
||||||
|
Clear all filters
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
title="No AI sessions yet"
|
||||||
|
description="Start a FlowPilot session to get AI-guided troubleshooting. Sessions will appear here."
|
||||||
|
action={
|
||||||
|
<Link
|
||||||
|
to="/pilot"
|
||||||
|
className="inline-flex items-center gap-2 rounded-[10px] bg-gradient-brand px-5 py-2.5 text-sm font-semibold text-[#101114] shadow-lg shadow-primary/20 hover:opacity-90 active:scale-[0.97] transition-all"
|
||||||
|
>
|
||||||
|
Start AI Session
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{aiSessions.map((s) => (
|
||||||
|
<AISessionListItem key={s.id} session={s} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Flow Sessions Content */}
|
{/* Flow Sessions Content */}
|
||||||
|
|||||||
Reference in New Issue
Block a user