@@ -409,6 +409,69 @@ def _startup_init_db() -> None:
409409 app .mount ("/final_results" , StaticFiles (directory = str (final_results_path )), name = "final_results" )
410410 logger .info (f"Mounted /final_results from { final_results_path .absolute ()} " )
411411
412+ # -------------------------------------------------------------------------
413+ # Frontend serving (auto-enabled if frontend_dist exists)
414+ # -------------------------------------------------------------------------
415+ def find_frontend_dist () -> Path | None :
416+ """Find the frontend dist directory in the package installation."""
417+ # Try inside the stringsight package (installed location)
418+ import stringsight
419+ package_dir = Path (stringsight .__file__ ).parent
420+ dist_path = package_dir / "frontend_dist"
421+
422+ if dist_path .exists () and (dist_path / "index.html" ).exists ():
423+ return dist_path
424+
425+ # Try relative to current working directory (for development)
426+ cwd_dist = Path .cwd () / "stringsight" / "frontend_dist"
427+ if cwd_dist .exists () and (cwd_dist / "index.html" ).exists ():
428+ return cwd_dist
429+
430+ return None
431+
432+ # Auto-detect and mount frontend if available
433+ frontend_dist = find_frontend_dist ()
434+ if frontend_dist :
435+ from fastapi import Request
436+ from fastapi .responses import FileResponse
437+ from starlette .exceptions import HTTPException as StarletteHTTPException
438+
439+ # Mount assets directory for static files (JS, CSS, etc.)
440+ assets_path = frontend_dist / "assets"
441+ if assets_path .exists ():
442+ app .mount ("/assets" , StaticFiles (directory = str (assets_path )), name = "assets" )
443+ logger .info (f"Mounted frontend assets from { assets_path } " )
444+
445+ # Root route to serve index.html
446+ @app .get ("/" )
447+ async def serve_frontend_root ():
448+ """Serve the frontend application root."""
449+ return FileResponse (frontend_dist / "index.html" )
450+
451+ # Exception handler for SPA routing (serve index.html for 404s on non-API routes)
452+ @app .exception_handler (StarletteHTTPException )
453+ async def spa_exception_handler (request : Request , exc : StarletteHTTPException ):
454+ """Handle 404s by serving index.html for frontend SPA routing."""
455+ # If it's a 404 and not an API route, serve the SPA
456+ if exc .status_code == 404 and not request .url .path .startswith ("/api" ):
457+ # Check if it's a static file in the frontend dist
458+ file_path = frontend_dist / request .url .path .lstrip ("/" )
459+ if file_path .is_file ():
460+ return FileResponse (file_path )
461+ # Otherwise serve index.html for SPA routing
462+ return FileResponse (frontend_dist / "index.html" )
463+
464+ # For API routes or other errors, return JSON error
465+ from fastapi .responses import JSONResponse
466+ return JSONResponse (
467+ status_code = exc .status_code ,
468+ content = {"detail" : exc .detail }
469+ )
470+
471+ logger .info (f"Frontend auto-mounted from { frontend_dist } " )
472+ else :
473+ logger .info ("Frontend not found - API-only mode" )
474+
412475# NOTE:
413476# All of the primary API endpoints are implemented in `stringsight/routers/*` and
414477# are registered above via `app.include_router(...)`.
@@ -475,12 +538,12 @@ async def _run_cluster_job_async(job: ClusterJob, req: ClusterRunRequest):
475538
476539 # Force-drop any pre-initialized global LMDB caches
477540 from stringsight .core import llm_utils as _llm_utils
478- from stringsight .clusterers import clustering_utils as _cu
541+ from stringsight .clusterers import embeddings as _embed
479542 from stringsight .core .caching import UnifiedCache
480543
481544 _orig_default_cache : UnifiedCache | None = getattr (_llm_utils , "_default_cache" , None )
482545 _orig_default_llm_utils = getattr (_llm_utils , "_default_llm_utils" , None )
483- _orig_embed_cache = getattr (_cu , "_cache" , None )
546+ _orig_embed_cache = getattr (_embed , "_cache" , None )
484547 try :
485548 if hasattr (_llm_utils , "_default_cache" ):
486549 _llm_utils ._default_cache = None # type: ignore
@@ -490,8 +553,8 @@ async def _run_cluster_job_async(job: ClusterJob, req: ClusterRunRequest):
490553 # Intentionally silent - cache clearing is best-effort
491554 pass
492555 try :
493- if hasattr (_cu , "_cache" ):
494- _cu ._cache = None # type: ignore
556+ if hasattr (_embed , "_cache" ):
557+ _embed ._cache = None # type: ignore
495558 except Exception :
496559 # Intentionally silent - cache clearing is best-effort
497560 pass
@@ -949,7 +1012,13 @@ async def _run_cluster_job_async(job: ClusterJob, req: ClusterRunRequest):
9491012 enriched = []
9501013 total_conversations = {}
9511014 for model in all_models :
952- model_convs = [c for c in conversations if c .model == model ]
1015+ # Handle both single model strings and model arrays (for side-by-side)
1016+ if isinstance (conversations [0 ].model , list ) if conversations else False :
1017+ # Side-by-side: check if model is in the array
1018+ model_convs = [c for c in conversations if model in c .model ]
1019+ else :
1020+ # Single model: direct comparison
1021+ model_convs = [c for c in conversations if c .model == model ]
9531022 total_conversations [model ] = len (model_convs )
9541023
9551024 total_unique_conversations = len ({c .question_id for c in conversations })
0 commit comments