perf: Added SearchLog model, Alembic migration 013, Pydantic schemas, f…
- "backend/models.py" - "backend/schemas.py" - "backend/routers/search.py" - "alembic/versions/013_add_search_log.py" GSD-Task: S01/T01
This commit is contained in:
parent
dfb10f04b4
commit
4fbc77d10d
4 changed files with 138 additions and 3 deletions
31
alembic/versions/013_add_search_log.py
Normal file
31
alembic/versions/013_add_search_log.py
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
"""Add search_log table for query analytics and popular searches.
|
||||||
|
|
||||||
|
Revision ID: 013_add_search_log
|
||||||
|
Revises: 012_multi_source_fmt
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision = "013_add_search_log"
|
||||||
|
down_revision = "012_multi_source_fmt"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"search_log",
|
||||||
|
sa.Column("id", sa.Integer, primary_key=True, autoincrement=True),
|
||||||
|
sa.Column("query", sa.String(500), nullable=False),
|
||||||
|
sa.Column("scope", sa.String(50), nullable=False),
|
||||||
|
sa.Column("result_count", sa.Integer, nullable=False, server_default="0"),
|
||||||
|
sa.Column("created_at", sa.TIMESTAMP(), server_default=sa.func.now(), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index("ix_search_log_query", "search_log", ["query"])
|
||||||
|
op.create_index("ix_search_log_created_at", "search_log", ["created_at"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_search_log_created_at", table_name="search_log")
|
||||||
|
op.drop_index("ix_search_log_query", table_name="search_log")
|
||||||
|
op.drop_table("search_log")
|
||||||
|
|
@ -395,6 +395,19 @@ class ContentReport(Base):
|
||||||
|
|
||||||
# ── Pipeline Event ───────────────────────────────────────────────────────────
|
# ── Pipeline Event ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class SearchLog(Base):
|
||||||
|
"""Logged search query for analytics and popular searches."""
|
||||||
|
__tablename__ = "search_log"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
query: Mapped[str] = mapped_column(String(500), nullable=False, index=True)
|
||||||
|
scope: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
result_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
default=_now, server_default=func.now(), index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class PipelineRunStatus(str, enum.Enum):
|
class PipelineRunStatus(str, enum.Enum):
|
||||||
"""Status of a pipeline run."""
|
"""Status of a pipeline run."""
|
||||||
running = "running"
|
running = "running"
|
||||||
|
|
|
||||||
|
|
@ -2,17 +2,22 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query
|
from fastapi import APIRouter, Depends, Query
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select, text
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from config import get_settings
|
from config import get_settings
|
||||||
from database import get_session
|
from database import async_session, get_session
|
||||||
from models import Creator, TechniquePage
|
from models import Creator, SearchLog, TechniquePage
|
||||||
|
from redis_client import get_redis
|
||||||
from schemas import (
|
from schemas import (
|
||||||
|
PopularSearchesResponse,
|
||||||
|
PopularSearchItem,
|
||||||
SearchResponse,
|
SearchResponse,
|
||||||
SearchResultItem,
|
SearchResultItem,
|
||||||
SuggestionItem,
|
SuggestionItem,
|
||||||
|
|
@ -24,12 +29,29 @@ logger = logging.getLogger("chrysopedia.search.router")
|
||||||
|
|
||||||
router = APIRouter(prefix="/search", tags=["search"])
|
router = APIRouter(prefix="/search", tags=["search"])
|
||||||
|
|
||||||
|
POPULAR_CACHE_KEY = "chrysopedia:popular_searches"
|
||||||
|
POPULAR_CACHE_TTL = 300 # 5 minutes
|
||||||
|
|
||||||
|
|
||||||
def _get_search_service() -> SearchService:
|
def _get_search_service() -> SearchService:
|
||||||
"""Build a SearchService from current settings."""
|
"""Build a SearchService from current settings."""
|
||||||
return SearchService(get_settings())
|
return SearchService(get_settings())
|
||||||
|
|
||||||
|
|
||||||
|
async def _log_search(query: str, scope: str, result_count: int) -> None:
|
||||||
|
"""Fire-and-forget: persist a search log row.
|
||||||
|
|
||||||
|
Opens its own session so it doesn't interfere with the request session.
|
||||||
|
Catches all exceptions — a logging failure must never break a search request.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with async_session() as session:
|
||||||
|
session.add(SearchLog(query=query, scope=scope, result_count=result_count))
|
||||||
|
await session.commit()
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Failed to log search query %r", query, exc_info=True)
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=SearchResponse)
|
@router.get("", response_model=SearchResponse)
|
||||||
async def search(
|
async def search(
|
||||||
q: Annotated[str, Query(max_length=500)] = "",
|
q: Annotated[str, Query(max_length=500)] = "",
|
||||||
|
|
@ -46,6 +68,11 @@ async def search(
|
||||||
"""
|
"""
|
||||||
svc = _get_search_service()
|
svc = _get_search_service()
|
||||||
result = await svc.search(query=q, scope=scope, sort=sort, limit=limit, db=db)
|
result = await svc.search(query=q, scope=scope, sort=sort, limit=limit, db=db)
|
||||||
|
|
||||||
|
# Fire-and-forget search logging — only non-empty queries
|
||||||
|
if q.strip():
|
||||||
|
asyncio.create_task(_log_search(q.strip(), scope, result["total"]))
|
||||||
|
|
||||||
return SearchResponse(
|
return SearchResponse(
|
||||||
items=[SearchResultItem(**item) for item in result["items"]],
|
items=[SearchResultItem(**item) for item in result["items"]],
|
||||||
partial_matches=[SearchResultItem(**item) for item in result.get("partial_matches", [])],
|
partial_matches=[SearchResultItem(**item) for item in result.get("partial_matches", [])],
|
||||||
|
|
@ -118,3 +145,55 @@ async def suggestions(
|
||||||
_add(name, "creator")
|
_add(name, "creator")
|
||||||
|
|
||||||
return SuggestionsResponse(suggestions=items)
|
return SuggestionsResponse(suggestions=items)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/popular", response_model=PopularSearchesResponse)
|
||||||
|
async def popular_searches(
|
||||||
|
db: AsyncSession = Depends(get_session),
|
||||||
|
) -> PopularSearchesResponse:
|
||||||
|
"""Return the top 10 search queries from the last 7 days.
|
||||||
|
|
||||||
|
Results are cached in Redis for 5 minutes. Falls through to a
|
||||||
|
direct DB query when Redis is unavailable.
|
||||||
|
"""
|
||||||
|
# Try Redis cache first
|
||||||
|
try:
|
||||||
|
redis = await get_redis()
|
||||||
|
cached = await redis.get(POPULAR_CACHE_KEY)
|
||||||
|
await redis.aclose()
|
||||||
|
if cached is not None:
|
||||||
|
items = json.loads(cached)
|
||||||
|
return PopularSearchesResponse(
|
||||||
|
items=[PopularSearchItem(**i) for i in items],
|
||||||
|
cached=True,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Redis unavailable for popular searches cache", exc_info=True)
|
||||||
|
|
||||||
|
# Cache miss or Redis down — query DB
|
||||||
|
stmt = (
|
||||||
|
select(
|
||||||
|
func.lower(SearchLog.query).label("q"),
|
||||||
|
func.count().label("cnt"),
|
||||||
|
)
|
||||||
|
.where(SearchLog.created_at > func.now() - text("interval '7 days'"))
|
||||||
|
.group_by(func.lower(SearchLog.query))
|
||||||
|
.order_by(func.count().desc())
|
||||||
|
.limit(10)
|
||||||
|
)
|
||||||
|
result = await db.execute(stmt)
|
||||||
|
items = [PopularSearchItem(query=row.q, count=row.cnt) for row in result.all()]
|
||||||
|
|
||||||
|
# Write to Redis cache (best-effort)
|
||||||
|
try:
|
||||||
|
redis = await get_redis()
|
||||||
|
await redis.set(
|
||||||
|
POPULAR_CACHE_KEY,
|
||||||
|
json.dumps([i.model_dump() for i in items]),
|
||||||
|
ex=POPULAR_CACHE_TTL,
|
||||||
|
)
|
||||||
|
await redis.aclose()
|
||||||
|
except Exception:
|
||||||
|
logger.warning("Failed to cache popular searches in Redis", exc_info=True)
|
||||||
|
|
||||||
|
return PopularSearchesResponse(items=items, cached=False)
|
||||||
|
|
|
||||||
|
|
@ -240,6 +240,18 @@ class SuggestionsResponse(BaseModel):
|
||||||
suggestions: list[SuggestionItem] = Field(default_factory=list)
|
suggestions: list[SuggestionItem] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class PopularSearchItem(BaseModel):
|
||||||
|
"""A single popular search query with occurrence count."""
|
||||||
|
query: str
|
||||||
|
count: int
|
||||||
|
|
||||||
|
|
||||||
|
class PopularSearchesResponse(BaseModel):
|
||||||
|
"""Response for the popular searches endpoint."""
|
||||||
|
items: list[PopularSearchItem] = Field(default_factory=list)
|
||||||
|
cached: bool = False
|
||||||
|
|
||||||
|
|
||||||
# ── Technique Page Detail ────────────────────────────────────────────────────
|
# ── Technique Page Detail ────────────────────────────────────────────────────
|
||||||
|
|
||||||
class KeyMomentSummary(BaseModel):
|
class KeyMomentSummary(BaseModel):
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue