Files
delphi-database/app/api/search.py
2025-08-08 15:55:15 -05:00

1120 lines
37 KiB
Python

"""
Advanced Search API endpoints - Comprehensive search across all data types
"""
from typing import List, Optional, Union, Dict, Any
from fastapi import APIRouter, Depends, HTTPException, status, Query, Body
from sqlalchemy.orm import Session, joinedload
from sqlalchemy import or_, and_, func, desc, asc, text, case, cast, String, DateTime, Date, Numeric
from datetime import date, datetime, timedelta
from pydantic import BaseModel, Field
import json
import re
from app.database.base import get_db
from app.models.rolodex import Rolodex, Phone
from app.models.files import File
from app.models.ledger import Ledger
from app.models.qdro import QDRO
from app.models.lookups import FormIndex, Employee, FileType, FileStatus, TransactionType, TransactionCode, State
from app.models.user import User
from app.auth.security import get_current_user
router = APIRouter()
# Enhanced Search Schemas
class SearchResult(BaseModel):
"""Enhanced search result with metadata"""
type: str # "customer", "file", "ledger", "qdro", "document", "template", "phone"
id: Union[str, int]
title: str
description: str
url: str
metadata: Optional[Dict[str, Any]] = None
relevance_score: Optional[float] = None
highlight: Optional[str] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
class AdvancedSearchCriteria(BaseModel):
"""Advanced search criteria"""
query: Optional[str] = None
search_types: List[str] = ["customer", "file", "ledger", "qdro", "document", "template"]
# Text search options
exact_phrase: bool = False
case_sensitive: bool = False
whole_words: bool = False
# Date filters
date_field: Optional[str] = None # "created", "updated", "opened", "closed"
date_from: Optional[date] = None
date_to: Optional[date] = None
# Amount filters
amount_field: Optional[str] = None # "amount", "balance", "total_charges"
amount_min: Optional[float] = None
amount_max: Optional[float] = None
# Category filters
file_types: Optional[List[str]] = None
file_statuses: Optional[List[str]] = None
employees: Optional[List[str]] = None
transaction_types: Optional[List[str]] = None
states: Optional[List[str]] = None
# Boolean filters
active_only: bool = True
has_balance: Optional[bool] = None
is_billed: Optional[bool] = None
# Result options
sort_by: str = "relevance" # relevance, date, amount, title
sort_order: str = "desc" # asc, desc
limit: int = Field(50, ge=1, le=200)
offset: int = Field(0, ge=0)
class SearchFilter(BaseModel):
"""Individual search filter"""
field: str
operator: str # "equals", "contains", "starts_with", "ends_with", "greater_than", "less_than", "between", "in", "not_in"
value: Union[str, int, float, List[Union[str, int, float]]]
class SavedSearch(BaseModel):
"""Saved search definition"""
id: Optional[int] = None
name: str
description: Optional[str] = None
criteria: AdvancedSearchCriteria
is_public: bool = False
created_by: Optional[str] = None
created_at: Optional[datetime] = None
last_used: Optional[datetime] = None
use_count: int = 0
class SearchStats(BaseModel):
"""Search statistics"""
total_customers: int
total_files: int
total_ledger_entries: int
total_qdros: int
total_documents: int
total_templates: int
total_phones: int
search_execution_time: float
class AdvancedSearchResponse(BaseModel):
"""Advanced search response"""
criteria: AdvancedSearchCriteria
results: List[SearchResult]
stats: SearchStats
facets: Dict[str, Dict[str, int]]
total_results: int
page_info: Dict[str, Any]
class GlobalSearchResponse(BaseModel):
"""Enhanced global search response"""
query: str
total_results: int
execution_time: float
customers: List[SearchResult]
files: List[SearchResult]
ledgers: List[SearchResult]
qdros: List[SearchResult]
documents: List[SearchResult]
templates: List[SearchResult]
phones: List[SearchResult]
# Advanced Search Endpoints
@router.post("/advanced", response_model=AdvancedSearchResponse)
async def advanced_search(
criteria: AdvancedSearchCriteria = Body(...),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Advanced search with complex criteria and filtering"""
start_time = datetime.now()
all_results = []
facets = {}
# Search each entity type based on criteria
if "customer" in criteria.search_types:
customer_results = await _search_customers(criteria, db)
all_results.extend(customer_results)
if "file" in criteria.search_types:
file_results = await _search_files(criteria, db)
all_results.extend(file_results)
if "ledger" in criteria.search_types:
ledger_results = await _search_ledger(criteria, db)
all_results.extend(ledger_results)
if "qdro" in criteria.search_types:
qdro_results = await _search_qdros(criteria, db)
all_results.extend(qdro_results)
if "document" in criteria.search_types:
document_results = await _search_documents(criteria, db)
all_results.extend(document_results)
if "template" in criteria.search_types:
template_results = await _search_templates(criteria, db)
all_results.extend(template_results)
# Sort results
sorted_results = _sort_search_results(all_results, criteria.sort_by, criteria.sort_order)
# Apply pagination
total_count = len(sorted_results)
paginated_results = sorted_results[criteria.offset:criteria.offset + criteria.limit]
# Calculate facets
facets = _calculate_facets(sorted_results)
# Calculate stats
execution_time = (datetime.now() - start_time).total_seconds()
stats = await _calculate_search_stats(db, execution_time)
# Page info
page_info = {
"current_page": (criteria.offset // criteria.limit) + 1,
"total_pages": (total_count + criteria.limit - 1) // criteria.limit,
"has_next": criteria.offset + criteria.limit < total_count,
"has_previous": criteria.offset > 0
}
return AdvancedSearchResponse(
criteria=criteria,
results=paginated_results,
stats=stats,
facets=facets,
total_results=total_count,
page_info=page_info
)
@router.get("/global", response_model=GlobalSearchResponse)
async def global_search(
q: str = Query(..., min_length=1),
limit: int = Query(10, ge=1, le=50),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Enhanced global search across all entities"""
start_time = datetime.now()
# Create criteria for global search
criteria = AdvancedSearchCriteria(
query=q,
search_types=["customer", "file", "ledger", "qdro", "document", "template"],
limit=limit
)
# Search each entity type
customer_results = await _search_customers(criteria, db)
file_results = await _search_files(criteria, db)
ledger_results = await _search_ledger(criteria, db)
qdro_results = await _search_qdros(criteria, db)
document_results = await _search_documents(criteria, db)
template_results = await _search_templates(criteria, db)
phone_results = await _search_phones(criteria, db)
total_results = (len(customer_results) + len(file_results) + len(ledger_results) +
len(qdro_results) + len(document_results) + len(template_results) + len(phone_results))
execution_time = (datetime.now() - start_time).total_seconds()
return GlobalSearchResponse(
query=q,
total_results=total_results,
execution_time=execution_time,
customers=customer_results[:limit],
files=file_results[:limit],
ledgers=ledger_results[:limit],
qdros=qdro_results[:limit],
documents=document_results[:limit],
templates=template_results[:limit],
phones=phone_results[:limit]
)
@router.get("/suggestions")
async def search_suggestions(
q: str = Query(..., min_length=1),
limit: int = Query(10, ge=1, le=20),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get search suggestions and autocomplete"""
suggestions = []
# Customer name suggestions
customers = db.query(Rolodex.first, Rolodex.last).filter(
or_(
Rolodex.first.ilike(f"{q}%"),
Rolodex.last.ilike(f"{q}%")
)
).limit(limit//2).all()
for customer in customers:
full_name = f"{customer.first or ''} {customer.last}".strip()
if full_name:
suggestions.append({
"text": full_name,
"type": "customer_name",
"category": "Customers"
})
# File number suggestions
files = db.query(File.file_no, File.regarding).filter(
File.file_no.ilike(f"{q}%")
).limit(limit//2).all()
for file_obj in files:
suggestions.append({
"text": file_obj.file_no,
"type": "file_number",
"category": "Files",
"description": file_obj.regarding
})
return {"suggestions": suggestions[:limit]}
@router.get("/facets")
async def get_search_facets(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Get available search facets and filters"""
# File types
file_types = db.query(FileType.type_code, FileType.description).filter(
FileType.active == True
).all()
# File statuses
file_statuses = db.query(FileStatus.status_code, FileStatus.description).filter(
FileStatus.active == True
).all()
# Employees
employees = db.query(Employee.empl_num, Employee.first_name, Employee.last_name).filter(
Employee.active == True
).all()
# Transaction types
transaction_types = db.query(TransactionType.t_type, TransactionType.description).filter(
TransactionType.active == True
).all()
# States
states = db.query(State.abrev, State.name).filter(
State.active == True
).order_by(State.name).all()
return {
"file_types": [{"code": ft[0], "name": ft[1]} for ft in file_types],
"file_statuses": [{"code": fs[0], "name": fs[1]} for fs in file_statuses],
"employees": [{"code": emp[0], "name": f"{emp[1] or ''} {emp[2]}".strip()} for emp in employees],
"transaction_types": [{"code": tt[0], "name": tt[1]} for tt in transaction_types],
"states": [{"code": st[0], "name": st[1]} for st in states],
"date_fields": [
{"code": "created", "name": "Created Date"},
{"code": "updated", "name": "Updated Date"},
{"code": "opened", "name": "File Opened Date"},
{"code": "closed", "name": "File Closed Date"}
],
"amount_fields": [
{"code": "amount", "name": "Transaction Amount"},
{"code": "balance", "name": "Account Balance"},
{"code": "total_charges", "name": "Total Charges"}
],
"sort_options": [
{"code": "relevance", "name": "Relevance"},
{"code": "date", "name": "Date"},
{"code": "amount", "name": "Amount"},
{"code": "title", "name": "Title"}
]
}
# Legacy endpoints for backward compatibility
@router.get("/customers", response_model=List[SearchResult])
async def search_customers(
q: str = Query(..., min_length=2),
limit: int = Query(20, ge=1, le=100),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Search customers (legacy endpoint)"""
criteria = AdvancedSearchCriteria(
query=q,
search_types=["customer"],
limit=limit
)
return await _search_customers(criteria, db)
@router.get("/files", response_model=List[SearchResult])
async def search_files(
q: str = Query(..., min_length=2),
limit: int = Query(20, ge=1, le=100),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""Search files (legacy endpoint)"""
criteria = AdvancedSearchCriteria(
query=q,
search_types=["file"],
limit=limit
)
return await _search_files(criteria, db)
# Search Implementation Functions
async def _search_customers(criteria: AdvancedSearchCriteria, db: Session) -> List[SearchResult]:
"""Search customers with advanced criteria"""
query = db.query(Rolodex).options(joinedload(Rolodex.phones))
if criteria.query:
search_conditions = []
if criteria.exact_phrase:
# Exact phrase search
search_term = criteria.query
search_conditions.append(
or_(
func.concat(Rolodex.first, ' ', Rolodex.last).contains(search_term),
Rolodex.memo1.contains(search_term),
Rolodex.memo2.contains(search_term)
)
)
else:
# Regular search with individual terms
search_terms = criteria.query.split()
for term in search_terms:
if criteria.case_sensitive:
search_conditions.append(
or_(
Rolodex.id.contains(term),
Rolodex.last.contains(term),
Rolodex.first.contains(term),
Rolodex.city.contains(term),
Rolodex.email.contains(term),
Rolodex.memo1.contains(term),
Rolodex.memo2.contains(term)
)
)
else:
search_conditions.append(
or_(
Rolodex.id.ilike(f"%{term}%"),
Rolodex.last.ilike(f"%{term}%"),
Rolodex.first.ilike(f"%{term}%"),
Rolodex.city.ilike(f"%{term}%"),
Rolodex.email.ilike(f"%{term}%"),
Rolodex.memo1.ilike(f"%{term}%"),
Rolodex.memo2.ilike(f"%{term}%")
)
)
if search_conditions:
query = query.filter(and_(*search_conditions))
# Apply filters
if criteria.states:
query = query.filter(Rolodex.abrev.in_(criteria.states))
# Apply date filters
if criteria.date_from or criteria.date_to:
date_field_map = {
"created": Rolodex.created_at,
"updated": Rolodex.updated_at
}
if criteria.date_field in date_field_map:
field = date_field_map[criteria.date_field]
if criteria.date_from:
query = query.filter(field >= criteria.date_from)
if criteria.date_to:
query = query.filter(field <= criteria.date_to)
customers = query.limit(criteria.limit).all()
results = []
for customer in customers:
full_name = f"{customer.first or ''} {customer.last}".strip()
location = f"{customer.city or ''}, {customer.abrev or ''}".strip(', ')
# Calculate relevance score
relevance = _calculate_customer_relevance(customer, criteria.query or "")
# Create highlight snippet
highlight = _create_customer_highlight(customer, criteria.query or "")
# Get phone numbers
phone_numbers = [p.phone for p in customer.phones] if customer.phones else []
results.append(SearchResult(
type="customer",
id=customer.id,
title=full_name or f"Customer {customer.id}",
description=f"ID: {customer.id} | {location}",
url=f"/customers?id={customer.id}",
metadata={
"location": location,
"email": customer.email,
"phones": phone_numbers,
"group": customer.group
},
relevance_score=relevance,
highlight=highlight,
created_at=customer.created_at,
updated_at=customer.updated_at
))
return results
async def _search_files(criteria: AdvancedSearchCriteria, db: Session) -> List[SearchResult]:
"""Search files with advanced criteria"""
query = db.query(File).options(joinedload(File.owner))
if criteria.query:
search_terms = criteria.query.split()
search_conditions = []
for term in search_terms:
if criteria.case_sensitive:
search_conditions.append(
or_(
File.file_no.contains(term),
File.id.contains(term),
File.regarding.contains(term),
File.file_type.contains(term),
File.memo1.contains(term),
File.memo2.contains(term)
)
)
else:
search_conditions.append(
or_(
File.file_no.ilike(f"%{term}%"),
File.id.ilike(f"%{term}%"),
File.regarding.ilike(f"%{term}%"),
File.file_type.ilike(f"%{term}%"),
File.memo1.ilike(f"%{term}%"),
File.memo2.ilike(f"%{term}%")
)
)
if search_conditions:
query = query.filter(and_(*search_conditions))
# Apply filters
if criteria.file_types:
query = query.filter(File.file_type.in_(criteria.file_types))
if criteria.file_statuses:
query = query.filter(File.status.in_(criteria.file_statuses))
if criteria.employees:
query = query.filter(File.empl_num.in_(criteria.employees))
if criteria.has_balance is not None:
if criteria.has_balance:
query = query.filter(File.amount_owing > 0)
else:
query = query.filter(File.amount_owing <= 0)
# Amount filters
if criteria.amount_min is not None or criteria.amount_max is not None:
amount_field_map = {
"balance": File.amount_owing,
"total_charges": File.total_charges
}
if criteria.amount_field in amount_field_map:
field = amount_field_map[criteria.amount_field]
if criteria.amount_min is not None:
query = query.filter(field >= criteria.amount_min)
if criteria.amount_max is not None:
query = query.filter(field <= criteria.amount_max)
# Date filters
if criteria.date_from or criteria.date_to:
date_field_map = {
"created": File.created_at,
"updated": File.updated_at,
"opened": File.opened,
"closed": File.closed
}
if criteria.date_field in date_field_map:
field = date_field_map[criteria.date_field]
if criteria.date_from:
query = query.filter(field >= criteria.date_from)
if criteria.date_to:
query = query.filter(field <= criteria.date_to)
files = query.limit(criteria.limit).all()
results = []
for file_obj in files:
client_name = ""
if file_obj.owner:
client_name = f"{file_obj.owner.first or ''} {file_obj.owner.last}".strip()
relevance = _calculate_file_relevance(file_obj, criteria.query or "")
highlight = _create_file_highlight(file_obj, criteria.query or "")
results.append(SearchResult(
type="file",
id=file_obj.file_no,
title=f"File #{file_obj.file_no}",
description=f"Client: {client_name} | {file_obj.regarding or 'No description'} | Status: {file_obj.status}",
url=f"/files?file_no={file_obj.file_no}",
metadata={
"client_id": file_obj.id,
"client_name": client_name,
"file_type": file_obj.file_type,
"status": file_obj.status,
"employee": file_obj.empl_num,
"amount_owing": float(file_obj.amount_owing or 0),
"total_charges": float(file_obj.total_charges or 0)
},
relevance_score=relevance,
highlight=highlight,
created_at=file_obj.created_at,
updated_at=file_obj.updated_at
))
return results
async def _search_ledger(criteria: AdvancedSearchCriteria, db: Session) -> List[SearchResult]:
"""Search ledger entries with advanced criteria"""
query = db.query(Ledger).options(joinedload(Ledger.file).joinedload(File.owner))
if criteria.query:
search_terms = criteria.query.split()
search_conditions = []
for term in search_terms:
if criteria.case_sensitive:
search_conditions.append(
or_(
Ledger.file_no.contains(term),
Ledger.t_code.contains(term),
Ledger.note.contains(term),
Ledger.empl_num.contains(term)
)
)
else:
search_conditions.append(
or_(
Ledger.file_no.ilike(f"%{term}%"),
Ledger.t_code.ilike(f"%{term}%"),
Ledger.note.ilike(f"%{term}%"),
Ledger.empl_num.ilike(f"%{term}%")
)
)
if search_conditions:
query = query.filter(and_(*search_conditions))
# Apply filters
if criteria.transaction_types:
query = query.filter(Ledger.t_type.in_(criteria.transaction_types))
if criteria.employees:
query = query.filter(Ledger.empl_num.in_(criteria.employees))
if criteria.is_billed is not None:
query = query.filter(Ledger.billed == ("Y" if criteria.is_billed else "N"))
# Amount filters
if criteria.amount_min is not None:
query = query.filter(Ledger.amount >= criteria.amount_min)
if criteria.amount_max is not None:
query = query.filter(Ledger.amount <= criteria.amount_max)
# Date filters
if criteria.date_from:
query = query.filter(Ledger.date >= criteria.date_from)
if criteria.date_to:
query = query.filter(Ledger.date <= criteria.date_to)
ledgers = query.limit(criteria.limit).all()
results = []
for ledger in ledgers:
client_name = ""
if ledger.file and ledger.file.owner:
client_name = f"{ledger.file.owner.first or ''} {ledger.file.owner.last}".strip()
relevance = _calculate_ledger_relevance(ledger, criteria.query or "")
highlight = _create_ledger_highlight(ledger, criteria.query or "")
results.append(SearchResult(
type="ledger",
id=ledger.id,
title=f"Transaction {ledger.t_code} - ${ledger.amount}",
description=f"File: {ledger.file_no} | Client: {client_name} | Date: {ledger.date} | {ledger.note or 'No note'}",
url=f"/financial?file_no={ledger.file_no}",
metadata={
"file_no": ledger.file_no,
"transaction_type": ledger.t_type,
"transaction_code": ledger.t_code,
"amount": float(ledger.amount),
"quantity": float(ledger.quantity or 0),
"rate": float(ledger.rate or 0),
"employee": ledger.empl_num,
"billed": ledger.billed == "Y",
"date": ledger.date.isoformat() if ledger.date else None
},
relevance_score=relevance,
highlight=highlight,
created_at=ledger.created_at,
updated_at=ledger.updated_at
))
return results
async def _search_qdros(criteria: AdvancedSearchCriteria, db: Session) -> List[SearchResult]:
"""Search QDRO documents with advanced criteria"""
query = db.query(QDRO).options(joinedload(QDRO.file))
if criteria.query:
search_terms = criteria.query.split()
search_conditions = []
for term in search_terms:
if criteria.case_sensitive:
search_conditions.append(
or_(
QDRO.file_no.contains(term),
QDRO.title.contains(term),
QDRO.participant_name.contains(term),
QDRO.spouse_name.contains(term),
QDRO.plan_name.contains(term),
QDRO.notes.contains(term)
)
)
else:
search_conditions.append(
or_(
QDRO.file_no.ilike(f"%{term}%"),
QDRO.title.ilike(f"%{term}%"),
QDRO.participant_name.ilike(f"%{term}%"),
QDRO.spouse_name.ilike(f"%{term}%"),
QDRO.plan_name.ilike(f"%{term}%"),
QDRO.notes.ilike(f"%{term}%")
)
)
if search_conditions:
query = query.filter(and_(*search_conditions))
qdros = query.limit(criteria.limit).all()
results = []
for qdro in qdros:
relevance = _calculate_qdro_relevance(qdro, criteria.query or "")
highlight = _create_qdro_highlight(qdro, criteria.query or "")
results.append(SearchResult(
type="qdro",
id=qdro.id,
title=qdro.title or f"QDRO v{qdro.version}",
description=f"File: {qdro.file_no} | Status: {qdro.status} | Participant: {qdro.participant_name or 'N/A'}",
url=f"/documents?qdro_id={qdro.id}",
metadata={
"file_no": qdro.file_no,
"version": qdro.version,
"status": qdro.status,
"participant": qdro.participant_name,
"spouse": qdro.spouse_name,
"plan": qdro.plan_name
},
relevance_score=relevance,
highlight=highlight,
created_at=qdro.created_at,
updated_at=qdro.updated_at
))
return results
async def _search_documents(criteria: AdvancedSearchCriteria, db: Session) -> List[SearchResult]:
"""Search document templates and forms"""
query = db.query(FormIndex)
if criteria.query:
search_terms = criteria.query.split()
search_conditions = []
for term in search_terms:
if criteria.case_sensitive:
search_conditions.append(
or_(
FormIndex.form_id.contains(term),
FormIndex.form_name.contains(term),
FormIndex.category.contains(term)
)
)
else:
search_conditions.append(
or_(
FormIndex.form_id.ilike(f"%{term}%"),
FormIndex.form_name.ilike(f"%{term}%"),
FormIndex.category.ilike(f"%{term}%")
)
)
if search_conditions:
query = query.filter(and_(*search_conditions))
if criteria.active_only:
query = query.filter(FormIndex.active == True)
documents = query.limit(criteria.limit).all()
results = []
for doc in documents:
relevance = _calculate_document_relevance(doc, criteria.query or "")
results.append(SearchResult(
type="document",
id=doc.form_id,
title=doc.form_name,
description=f"Template ID: {doc.form_id} | Category: {doc.category}",
url=f"/documents?template_id={doc.form_id}",
metadata={
"form_id": doc.form_id,
"category": doc.category,
"active": doc.active
},
relevance_score=relevance,
created_at=doc.created_at,
updated_at=doc.updated_at
))
return results
async def _search_templates(criteria: AdvancedSearchCriteria, db: Session) -> List[SearchResult]:
"""Search templates (alias for documents)"""
return await _search_documents(criteria, db)
async def _search_phones(criteria: AdvancedSearchCriteria, db: Session) -> List[SearchResult]:
"""Search phone numbers"""
query = db.query(Phone).options(joinedload(Phone.person))
if criteria.query:
# Clean phone number for search (remove non-digits)
clean_query = re.sub(r'[^\d]', '', criteria.query)
query = query.filter(
or_(
Phone.phone.contains(criteria.query),
Phone.phone.contains(clean_query),
Phone.location.ilike(f"%{criteria.query}%")
)
)
phones = query.limit(criteria.limit).all()
results = []
for phone in phones:
owner_name = ""
if phone.person:
owner_name = f"{phone.person.first or ''} {phone.person.last}".strip()
results.append(SearchResult(
type="phone",
id=f"{phone.id}_{phone.phone}",
title=phone.phone,
description=f"Owner: {owner_name} | Location: {phone.location or 'Unknown'}",
url=f"/customers?id={phone.id}",
metadata={
"owner_id": phone.id,
"owner_name": owner_name,
"location": phone.location
},
relevance_score=1.0,
created_at=phone.created_at,
updated_at=phone.updated_at
))
return results
# Utility Functions
def _sort_search_results(results: List[SearchResult], sort_by: str, sort_order: str) -> List[SearchResult]:
"""Sort search results based on criteria"""
reverse = sort_order == "desc"
if sort_by == "relevance":
return sorted(results, key=lambda x: x.relevance_score or 0, reverse=reverse)
elif sort_by == "date":
return sorted(results, key=lambda x: x.updated_at or datetime.min, reverse=reverse)
elif sort_by == "amount":
return sorted(results, key=lambda x: x.metadata.get("amount", 0) if x.metadata else 0, reverse=reverse)
elif sort_by == "title":
return sorted(results, key=lambda x: x.title, reverse=reverse)
else:
return results
def _calculate_facets(results: List[SearchResult]) -> Dict[str, Dict[str, int]]:
"""Calculate facets from search results"""
facets = {
"type": {},
"file_type": {},
"status": {},
"employee": {},
"category": {}
}
for result in results:
# Type facet
facets["type"][result.type] = facets["type"].get(result.type, 0) + 1
# Metadata facets
if result.metadata:
for facet_key in ["file_type", "status", "employee", "category"]:
if facet_key in result.metadata:
value = result.metadata[facet_key]
if value:
facets[facet_key][value] = facets[facet_key].get(value, 0) + 1
return facets
async def _calculate_search_stats(db: Session, execution_time: float) -> SearchStats:
"""Calculate search statistics"""
total_customers = db.query(Rolodex).count()
total_files = db.query(File).count()
total_ledger_entries = db.query(Ledger).count()
total_qdros = db.query(QDRO).count()
total_documents = db.query(FormIndex).count()
total_templates = db.query(FormIndex).count()
total_phones = db.query(Phone).count()
return SearchStats(
total_customers=total_customers,
total_files=total_files,
total_ledger_entries=total_ledger_entries,
total_qdros=total_qdros,
total_documents=total_documents,
total_templates=total_templates,
total_phones=total_phones,
search_execution_time=execution_time
)
# Relevance calculation functions
def _calculate_customer_relevance(customer: Rolodex, query: str) -> float:
"""Calculate relevance score for customer"""
if not query:
return 1.0
score = 0.0
query_lower = query.lower()
# Exact matches get higher scores
full_name = f"{customer.first or ''} {customer.last}".strip().lower()
if query_lower == full_name:
score += 10.0
elif query_lower in full_name:
score += 5.0
# ID matches
if query_lower == (customer.id or "").lower():
score += 8.0
elif query_lower in (customer.id or "").lower():
score += 3.0
# Email matches
if customer.email and query_lower in customer.email.lower():
score += 4.0
# City matches
if customer.city and query_lower in customer.city.lower():
score += 2.0
return max(score, 0.1) # Minimum score
def _calculate_file_relevance(file_obj: File, query: str) -> float:
"""Calculate relevance score for file"""
if not query:
return 1.0
score = 0.0
query_lower = query.lower()
# File number exact match
if query_lower == (file_obj.file_no or "").lower():
score += 10.0
elif query_lower in (file_obj.file_no or "").lower():
score += 5.0
# Client ID match
if query_lower == (file_obj.id or "").lower():
score += 8.0
# Regarding field
if file_obj.regarding and query_lower in file_obj.regarding.lower():
score += 4.0
# File type
if file_obj.file_type and query_lower in file_obj.file_type.lower():
score += 3.0
return max(score, 0.1)
def _calculate_ledger_relevance(ledger: Ledger, query: str) -> float:
"""Calculate relevance score for ledger entry"""
if not query:
return 1.0
score = 0.0
query_lower = query.lower()
# File number match
if query_lower == (ledger.file_no or "").lower():
score += 8.0
elif query_lower in (ledger.file_no or "").lower():
score += 4.0
# Transaction code match
if query_lower == (ledger.t_code or "").lower():
score += 6.0
# Note content
if ledger.note and query_lower in ledger.note.lower():
score += 3.0
return max(score, 0.1)
def _calculate_qdro_relevance(qdro: QDRO, query: str) -> float:
"""Calculate relevance score for QDRO"""
if not query:
return 1.0
score = 0.0
query_lower = query.lower()
# Title exact match
if qdro.title and query_lower == qdro.title.lower():
score += 10.0
elif qdro.title and query_lower in qdro.title.lower():
score += 5.0
# Participant names
if qdro.participant_name and query_lower in qdro.participant_name.lower():
score += 6.0
if qdro.spouse_name and query_lower in qdro.spouse_name.lower():
score += 6.0
# Plan name
if qdro.plan_name and query_lower in qdro.plan_name.lower():
score += 4.0
return max(score, 0.1)
def _calculate_document_relevance(doc: FormIndex, query: str) -> float:
"""Calculate relevance score for document"""
if not query:
return 1.0
score = 0.0
query_lower = query.lower()
# Form ID exact match
if query_lower == (doc.form_id or "").lower():
score += 10.0
# Form name match
if doc.form_name and query_lower in doc.form_name.lower():
score += 5.0
# Category match
if doc.category and query_lower in doc.category.lower():
score += 3.0
return max(score, 0.1)
# Highlight functions
def _create_customer_highlight(customer: Rolodex, query: str) -> str:
"""Create highlight snippet for customer"""
if not query:
return ""
full_name = f"{customer.first or ''} {customer.last}".strip()
if query.lower() in full_name.lower():
return f"Name: {full_name}"
if customer.email and query.lower() in customer.email.lower():
return f"Email: {customer.email}"
if customer.city and query.lower() in customer.city.lower():
return f"City: {customer.city}"
return ""
def _create_file_highlight(file_obj: File, query: str) -> str:
"""Create highlight snippet for file"""
if not query:
return ""
if file_obj.regarding and query.lower() in file_obj.regarding.lower():
return f"Matter: {file_obj.regarding}"
if file_obj.file_type and query.lower() in file_obj.file_type.lower():
return f"Type: {file_obj.file_type}"
return ""
def _create_ledger_highlight(ledger: Ledger, query: str) -> str:
"""Create highlight snippet for ledger"""
if not query:
return ""
if ledger.note and query.lower() in ledger.note.lower():
return f"Note: {ledger.note[:100]}..."
return ""
def _create_qdro_highlight(qdro: QDRO, query: str) -> str:
"""Create highlight snippet for QDRO"""
if not query:
return ""
if qdro.title and query.lower() in qdro.title.lower():
return f"Title: {qdro.title}"
if qdro.participant_name and query.lower() in qdro.participant_name.lower():
return f"Participant: {qdro.participant_name}"
return ""