diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..a63dafa --- /dev/null +++ b/alembic.ini @@ -0,0 +1,37 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +sqlalchemy.url = postgresql+asyncpg://postgres:postgres@localhost:5432/chatbot_plugin + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/alembic/env.py b/alembic/env.py new file mode 100644 index 0000000..0ba0335 --- /dev/null +++ b/alembic/env.py @@ -0,0 +1,66 @@ +"""Alembic environment configuration for async PostgreSQL.""" + +import asyncio +import os +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import pool +from sqlalchemy.ext.asyncio import async_engine_from_config + +from chatbot_plugin.models.article import Base + +config = context.config +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# Override sqlalchemy.url from env var if set +db_url = os.environ.get("CHATBOT_DATABASE_URL") +if db_url: + config.set_main_option("sqlalchemy.url", db_url) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode (SQL dump).""" + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + """Run migrations in 'online' mode with async engine.""" + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode.""" + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/alembic/script.py.mako b/alembic/script.py.mako new file mode 100644 index 0000000..1ba49a8 --- /dev/null +++ b/alembic/script.py.mako @@ -0,0 +1,25 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/alembic/versions/001_initial_articles.py b/alembic/versions/001_initial_articles.py new file mode 100644 index 0000000..9b83021 --- /dev/null +++ b/alembic/versions/001_initial_articles.py @@ -0,0 +1,60 @@ +"""Initial articles table with search_tsv and GIN index + +Revision ID: 001 +Revises: None +Create Date: 2026-05-26 +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR, UUID + +revision: str = "001" +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute("CREATE EXTENSION IF NOT EXISTS vector") + + op.create_table( + "articles", + sa.Column("id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()")), + sa.Column("url", sa.String(), nullable=False, unique=True), + sa.Column("url_hash", sa.String(), nullable=False), + sa.Column("source", sa.String(), nullable=True), + sa.Column("title", sa.String(), nullable=True), + sa.Column("content", sa.Text(), nullable=True), + sa.Column("published_at", sa.String(), nullable=True), + sa.Column("scraped_at", sa.String(), nullable=True), + sa.Column("metadata", JSONB, nullable=True), + sa.Column("correlation_id", UUID(as_uuid=True), nullable=True), + sa.Column("topic_id", UUID(as_uuid=True), nullable=True), + sa.Column( + "search_tsv", + TSVECTOR, + sa.Computed( + "to_tsvector('english', coalesce(title,'') || ' ' || coalesce(content,''))", + persisted=True, + ), + nullable=True, + ), + ) + + op.create_index("idx_articles_source", "articles", ["source"]) + op.create_index("idx_articles_scraped_at", "articles", ["scraped_at"]) + op.create_index("idx_articles_topic_id", "articles", ["topic_id"]) + op.execute( + "CREATE INDEX idx_articles_tsv ON articles USING gin(search_tsv)" + ) + + +def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS idx_articles_tsv") + op.drop_index("idx_articles_topic_id", table_name="articles") + op.drop_index("idx_articles_scraped_at", table_name="articles") + op.drop_index("idx_articles_source", table_name="articles") + op.drop_table("articles") diff --git a/pyproject.toml b/pyproject.toml index 02eab2f..477c19f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "google-genai", "httpx", "tenacity", + "alembic", ] [dependency-groups] diff --git a/specs/integration.md b/specs/integration.md index 7572e84..c2eb0ca 100644 --- a/specs/integration.md +++ b/specs/integration.md @@ -46,6 +46,9 @@ GEMINI_API_KEY=your-gemini-key CLAUDE_API_KEY=your-claude-key OPENROUTER_API_KEY=your-openrouter-key +# LLM providers config path (defaults to providers.toml in project root) +CHATBOT_LLM_PROVIDERS_PATH= + # Behavior CHATBOT_MAX_CONTEXT_ARTICLES=10 CHATBOT_MAX_CONTEXT_TOKENS=8000 diff --git a/src/chatbot_plugin/config.py b/src/chatbot_plugin/config.py index 627f980..129fd91 100644 --- a/src/chatbot_plugin/config.py +++ b/src/chatbot_plugin/config.py @@ -14,7 +14,7 @@ class ChatbotSettings(BaseSettings): database_url: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/chatbot_plugin" # LLM providers path (defaults to providers.toml in project root) - llm_providers_path: str = "" + llm_providers_path: str | None = None # RAG behavior max_context_articles: int = 10 diff --git a/src/chatbot_plugin/contracts/responses.py b/src/chatbot_plugin/contracts/responses.py index 4ecea07..f172d60 100644 --- a/src/chatbot_plugin/contracts/responses.py +++ b/src/chatbot_plugin/contracts/responses.py @@ -28,7 +28,7 @@ class ChunkResult(BaseModel): content: str = Field(..., description="Chunk text") article_id: str = Field(..., description="Parent article UUID") article_title: str = Field(..., description="Parent article title") - score: float = Field(..., ge=0.0, description="Relevance score") + score: float = Field(..., ge=0.0, le=1.0, description="Relevance score (0-1)") class SearchResponse(BaseModel): diff --git a/src/chatbot_plugin/db.py b/src/chatbot_plugin/db.py index d9a8b29..9b96924 100644 --- a/src/chatbot_plugin/db.py +++ b/src/chatbot_plugin/db.py @@ -2,9 +2,11 @@ from collections.abc import AsyncGenerator +from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from chatbot_plugin.config import settings +from chatbot_plugin.models.article import Base engine = create_async_engine(settings.database_url, echo=False) async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) @@ -14,3 +16,13 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]: """FastAPI dependency that yields an async DB session.""" async with async_session() as session: yield session + + +async def init_db() -> None: + """Initialize database: create extensions and tables if they don't exist. + + For production, use Alembic migrations instead. + """ + async with engine.begin() as conn: + await conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector")) + await conn.run_sync(Base.metadata.create_all) diff --git a/src/chatbot_plugin/models/article.py b/src/chatbot_plugin/models/article.py index 8e856ad..56adad9 100644 --- a/src/chatbot_plugin/models/article.py +++ b/src/chatbot_plugin/models/article.py @@ -6,8 +6,8 @@ import uuid -from sqlalchemy import Column, Index, String, Text, UniqueConstraint -from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy import Column, Computed, Index, String, Text, UniqueConstraint +from sqlalchemy.dialects.postgresql import JSONB, TSVECTOR, UUID from sqlalchemy.orm import DeclarativeBase @@ -29,11 +29,20 @@ class Article(Base): metadata_ = Column("metadata", JSONB, nullable=True) correlation_id = Column(UUID(as_uuid=True), nullable=True) topic_id = Column(UUID(as_uuid=True), nullable=True) + search_tsv = Column( + TSVECTOR, + Computed( + "to_tsvector('english', coalesce(title,'') || ' ' || coalesce(content,''))", + persisted=True, + ), + nullable=True, + ) __table_args__ = ( Index("idx_articles_source", "source"), Index("idx_articles_scraped_at", "scraped_at"), Index("idx_articles_topic_id", "topic_id"), + Index("idx_articles_tsv", "search_tsv", postgresql_using="gin"), ) def __repr__(self) -> str: diff --git a/src/chatbot_plugin/rag/__init__.py b/src/chatbot_plugin/rag/__init__.py index 87df685..78847f0 100644 --- a/src/chatbot_plugin/rag/__init__.py +++ b/src/chatbot_plugin/rag/__init__.py @@ -1 +1,5 @@ """RAG pipeline components.""" + +from chatbot_plugin.rag.retriever import Retriever + +__all__ = ["Retriever"] diff --git a/src/chatbot_plugin/rag/retriever.py b/src/chatbot_plugin/rag/retriever.py new file mode 100644 index 0000000..e7075a5 --- /dev/null +++ b/src/chatbot_plugin/rag/retriever.py @@ -0,0 +1,59 @@ +"""Retriever — retrieves relevant articles/chunks from PostgreSQL. + +Spec reference: specs/rag-pipeline.md — Retriever + +Phase 1: full-text search via search_tsv (generated tsvector column) + GIN index. +Phase 2: hybrid (dense + sparse) with RRF fusion. +""" + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + + +class Retriever: + """Retrieves ranked articles using PostgreSQL full-text search. + + Uses the ``search_tsv`` stored generated column with a GIN index + for efficient full-text search. Phase 2 will add dense vector + search and RRF fusion. + """ + + def __init__(self, db: AsyncSession) -> None: + self.db = db + + async def search( + self, query: str, limit: int = 10, topic_id: str | None = None + ) -> list[dict]: + """Full-text search on articles using the search_tsv GIN-indexed column. + + Args: + query: Search query string. + limit: Max results. + topic_id: Optional topic filter. + + Returns: + List of article dicts with id, title, content, rank. + """ + params: dict = {"query": query, "limit": limit} + topic_filter = "" + if topic_id is not None: + topic_filter = "AND topic_id = :topic_id" + params["topic_id"] = topic_id + + sql = text(f""" + SELECT id, title, content, + ts_rank(search_tsv, plainto_tsquery('english', :query)) AS rank + FROM articles + WHERE search_tsv @@ plainto_tsquery('english', :query) + {topic_filter} + ORDER BY rank DESC + LIMIT :limit + """) + + result = await self.db.execute(sql, params) + rows = result.mappings().all() + # Normalize ts_rank (unbounded) to 0-1 using rank/(rank+1) + return [ + {**dict(row), "rank": row["rank"] / (row["rank"] + 1) if row["rank"] else 0.0} + for row in rows + ] diff --git a/src/chatbot_plugin/routers.py b/src/chatbot_plugin/routers.py index 8715227..7a51976 100644 --- a/src/chatbot_plugin/routers.py +++ b/src/chatbot_plugin/routers.py @@ -34,7 +34,8 @@ def init_llm_service() -> None: """Initialize the LLM service. Call once at app startup.""" global _llm_service from chatbot_plugin.llm.bootstrap import build_llm_service - _llm_service = build_llm_service() + from chatbot_plugin.config import settings + _llm_service = build_llm_service(path=settings.llm_providers_path) def _service(db: AsyncSession = Depends(get_db)) -> ChatbotService: diff --git a/src/chatbot_plugin/service.py b/src/chatbot_plugin/service.py index 8ebd29f..068bff0 100644 --- a/src/chatbot_plugin/service.py +++ b/src/chatbot_plugin/service.py @@ -4,6 +4,8 @@ Spec reference: specs/chat-api.md """ +import uuid + from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession @@ -18,6 +20,7 @@ ) from chatbot_plugin.llm.resilient_llm_service import ResilientLLMService from chatbot_plugin.rag.chain import rag_generate +from chatbot_plugin.rag.retriever import Retriever class ChatbotService: @@ -26,6 +29,8 @@ class ChatbotService: def __init__(self, db: AsyncSession, llm_service: ResilientLLMService) -> None: self.db = db self.llm_service = llm_service + self.retriever = Retriever(db) + self._indexing_articles: set[str] = set() async def chat(self, message: str, user_id: str | None = None) -> ChatMessageResponse: """Process a user message and return a chatbot reply. @@ -43,7 +48,7 @@ async def chat(self, message: str, user_id: str | None = None) -> ChatMessageRes from fastapi import HTTPException # 1. Retrieve relevant articles via full-text search - articles = await self._search_articles(message, limit=settings.max_context_articles) + articles = await self.retriever.search(message, limit=settings.max_context_articles) # 2. Generate reply via RAG chain try: @@ -73,7 +78,7 @@ async def search( Returns: SearchResponse with ranked chunks (Phase 1: whole articles as chunks). """ - articles = await self._search_articles(query, limit=top_k, topic_id=topic_id) + articles = await self.retriever.search(query, limit=top_k, topic_id=topic_id) chunks = [ ChunkResult( content=a["content"] or "", @@ -100,15 +105,24 @@ async def trigger_index(self, article_id: str | None = None) -> IndexResponse: from fastapi import HTTPException if article_id is not None: + if article_id in self._indexing_articles: + raise HTTPException(status_code=409, detail="Indexing already in progress") result = await self.db.execute( text("SELECT id FROM articles WHERE id = :id"), {"id": article_id}, ) if result.scalar() is None: raise HTTPException(status_code=404, detail="Article not found") + self._indexing_articles.add(article_id) # Phase 1: stub response. Phase 2 will implement background indexing. - return IndexResponse(job_id="stub-job-id") + job_id = str(uuid.uuid4()) + + # Clean up tracking set (Phase 2 will remove after background task completes) + if article_id is not None: + self._indexing_articles.discard(article_id) + + return IndexResponse(job_id=job_id) async def get_status(self) -> StatusResponse: """Get indexing status and vector store stats. @@ -116,46 +130,9 @@ async def get_status(self) -> StatusResponse: Returns: StatusResponse with total_chunks, last_indexed_at, pending_articles. """ - # Phase 1: return article count as proxy. Phase 2 will query article_chunks. - result = await self.db.execute(text("SELECT count(*) FROM articles")) - count = result.scalar() or 0 + # Phase 1: no indexer, no chunks. Return zeros. return StatusResponse( total_chunks=0, last_indexed_at=None, - pending_articles=count, + pending_articles=0, ) - - async def _search_articles( - self, query: str, limit: int = 10, topic_id: str | None = None - ) -> list[dict]: - """Full-text search on articles using PostgreSQL tsvector. - - Args: - query: Search query string. - limit: Max results. - topic_id: Optional topic filter. - - Returns: - List of article dicts with id, title, content, rank. - """ - params: dict = {"query": query, "limit": limit} - topic_filter = "" - if topic_id is not None: - topic_filter = "AND topic_id = :topic_id" - params["topic_id"] = topic_id - - sql = text(f""" - SELECT id, title, content, - ts_rank(to_tsvector('english', coalesce(title,'') || ' ' || coalesce(content,'')), - plainto_tsquery('english', :query)) AS rank - FROM articles - WHERE to_tsvector('english', coalesce(title,'') || ' ' || coalesce(content,'')) - @@ plainto_tsquery('english', :query) - {topic_filter} - ORDER BY rank DESC - LIMIT :limit - """) - - result = await self.db.execute(sql, params) - rows = result.mappings().all() - return [dict(row) for row in rows] diff --git a/src/tests/contracts/test_contracts.py b/src/tests/contracts/test_contracts.py index 9f8aebf..5f34180 100644 --- a/src/tests/contracts/test_contracts.py +++ b/src/tests/contracts/test_contracts.py @@ -139,7 +139,8 @@ def test_no_results_empty_list(self): def test_score_bounds(self): ChunkResult(content="t", article_id="u", article_title="T", score=0.0) ChunkResult(content="t", article_id="u", article_title="T", score=1.0) - ChunkResult(content="t", article_id="u", article_title="T", score=5.0) # ts_rank can exceed 1.0 + with pytest.raises(ValidationError): + ChunkResult(content="t", article_id="u", article_title="T", score=1.1) with pytest.raises(ValidationError): ChunkResult(content="t", article_id="u", article_title="T", score=-0.1) diff --git a/src/tests/test_retriever.py b/src/tests/test_retriever.py new file mode 100644 index 0000000..69d5187 --- /dev/null +++ b/src/tests/test_retriever.py @@ -0,0 +1,124 @@ +"""Tests for Retriever — full-text search via search_tsv + GIN index.""" + +import pytest +from unittest.mock import AsyncMock, MagicMock + +from chatbot_plugin.rag.retriever import Retriever + + +def _mock_result(rows: list[dict] | None = None): + """Build a mock DB result that supports .mappings().all().""" + result = MagicMock() + result.mappings.return_value.all.return_value = rows or [] + return result + + +@pytest.mark.asyncio +async def test_search_uses_search_tsv_column(): + """Retriever must use search_tsv column, not on-the-fly to_tsvector.""" + mock_db = AsyncMock() + mock_db.execute.return_value = _mock_result([]) + + retriever = Retriever(mock_db) + await retriever.search("test query") + + sql_text = str(mock_db.execute.call_args[0][0]) + # Must use search_tsv directly (enables GIN index) + assert "search_tsv @@" in sql_text + assert "ts_rank(search_tsv" in sql_text + # Must NOT compute to_tsvector in WHERE clause + where_clause = sql_text.split("WHERE")[1].split("ORDER BY")[0] + assert "to_tsvector" not in where_clause + + +@pytest.mark.asyncio +async def test_search_returns_correct_shape(): + mock_db = AsyncMock() + mock_db.execute.return_value = _mock_result( + rows=[ + {"id": "uuid-1", "title": "Article A", "content": "Content A", "rank": 4.0}, + {"id": "uuid-2", "title": "Article B", "content": "Content B", "rank": 1.0}, + ] + ) + + retriever = Retriever(mock_db) + results = await retriever.search("RAG") + + assert len(results) == 2 + assert results[0]["id"] == "uuid-1" + # rank normalized: 4.0 / (4.0 + 1) = 0.8 + assert results[0]["rank"] == pytest.approx(0.8, abs=0.01) + + +@pytest.mark.asyncio +async def test_search_with_topic_id(): + mock_db = AsyncMock() + mock_db.execute.return_value = _mock_result([]) + + retriever = Retriever(mock_db) + await retriever.search("RAG", topic_id="topic-uuid") + + sql_text = str(mock_db.execute.call_args[0][0]) + assert "topic_id" in sql_text + params = mock_db.execute.call_args[0][1] + assert params["topic_id"] == "topic-uuid" + + +@pytest.mark.asyncio +async def test_search_no_topic_id(): + mock_db = AsyncMock() + mock_db.execute.return_value = _mock_result([]) + + retriever = Retriever(mock_db) + await retriever.search("RAG") + + sql_text = str(mock_db.execute.call_args[0][0]) + assert "topic_id" not in sql_text + + +@pytest.mark.asyncio +async def test_search_no_results(): + mock_db = AsyncMock() + mock_db.execute.return_value = _mock_result([]) + + retriever = Retriever(mock_db) + results = await retriever.search("nonexistent") + assert results == [] + + +@pytest.mark.asyncio +async def test_search_uses_plainto_tsquery(): + mock_db = AsyncMock() + mock_db.execute.return_value = _mock_result([]) + + retriever = Retriever(mock_db) + await retriever.search("test query") + + sql_text = str(mock_db.execute.call_args[0][0]) + assert "plainto_tsquery" in sql_text + + +@pytest.mark.asyncio +async def test_search_normalizes_score_to_0_1(): + """ts_rank is unbounded; retriever must normalize to 0-1 range.""" + mock_db = AsyncMock() + mock_db.execute.return_value = _mock_result( + rows=[{"id": "uuid-1", "title": "A", "content": "C", "rank": 99.0}] + ) + + retriever = Retriever(mock_db) + results = await retriever.search("test") + assert 0.0 <= results[0]["rank"] <= 1.0 + assert results[0]["rank"] == pytest.approx(99.0 / 100.0, abs=0.01) + + +@pytest.mark.asyncio +async def test_search_zero_rank_returns_zero(): + mock_db = AsyncMock() + mock_db.execute.return_value = _mock_result( + rows=[{"id": "uuid-1", "title": "A", "content": "C", "rank": 0.0}] + ) + + retriever = Retriever(mock_db) + results = await retriever.search("test") + assert results[0]["rank"] == 0.0 diff --git a/src/tests/test_service.py b/src/tests/test_service.py index a404912..357fefc 100644 --- a/src/tests/test_service.py +++ b/src/tests/test_service.py @@ -1,5 +1,7 @@ """Tests for ChatbotService.""" +import uuid + import pytest from unittest.mock import AsyncMock, MagicMock from fastapi import HTTPException @@ -20,9 +22,9 @@ def _mock_result(rows: list[dict] | None = None, scalar_val=None): @pytest.mark.asyncio async def test_chat_returns_reply_and_articles(service, mock_db, mock_llm_service): - mock_db.execute.return_value = _mock_result( - rows=[{"id": "uuid-1", "title": "RAG Article", "content": "RAG content...", "rank": 0.5}] - ) + service.retriever.search = AsyncMock(return_value=[ + {"id": "uuid-1", "title": "RAG Article", "content": "RAG content...", "rank": 0.5} + ]) mock_llm_service.generate.return_value = "RAG is retrieval-augmented generation." result = await service.chat("What is RAG?") @@ -35,7 +37,7 @@ async def test_chat_returns_reply_and_articles(service, mock_db, mock_llm_servic @pytest.mark.asyncio async def test_chat_no_articles_still_generates(service, mock_db, mock_llm_service): - mock_db.execute.return_value = _mock_result(rows=[]) + service.retriever.search = AsyncMock(return_value=[]) mock_llm_service.generate.return_value = "I don't have specific articles on that." result = await service.chat("obscure topic") @@ -45,7 +47,7 @@ async def test_chat_no_articles_still_generates(service, mock_db, mock_llm_servi @pytest.mark.asyncio async def test_chat_llm_failure_raises_503(service, mock_db, mock_llm_service): - mock_db.execute.return_value = _mock_result(rows=[]) + service.retriever.search = AsyncMock(return_value=[]) mock_llm_service.generate.return_value = None # All providers failed with pytest.raises(HTTPException) as exc_info: @@ -53,16 +55,37 @@ async def test_chat_llm_failure_raises_503(service, mock_db, mock_llm_service): assert exc_info.value.status_code == 503 +@pytest.mark.asyncio +async def test_chat_generic_exception_raises_503(service, mock_db, mock_llm_service): + """Non-RuntimeError exceptions from rag_generate also produce 503.""" + service.retriever.search = AsyncMock(return_value=[]) + mock_llm_service.generate.side_effect = Exception("unexpected error") + + with pytest.raises(HTTPException) as exc_info: + await service.chat("hello") + assert exc_info.value.status_code == 503 + + +@pytest.mark.asyncio +async def test_chat_untitled_fallback_for_none_title(service, mock_db, mock_llm_service): + """Articles with None/empty title get 'Untitled' fallback.""" + service.retriever.search = AsyncMock(return_value=[ + {"id": "uuid-1", "title": None, "content": "Some content", "rank": 0.5} + ]) + mock_llm_service.generate.return_value = "Reply" + + result = await service.chat("hello") + assert result.articles_used[0].title == "Untitled" + + # ── search() ── @pytest.mark.asyncio async def test_search_returns_chunks(service, mock_db, mock_llm_service): - mock_db.execute.return_value = _mock_result( - rows=[ - {"id": "uuid-1", "title": "Article A", "content": "Content A", "rank": 0.8}, - {"id": "uuid-2", "title": "Article B", "content": "Content B", "rank": 0.5}, - ] - ) + service.retriever.search = AsyncMock(return_value=[ + {"id": "uuid-1", "title": "Article A", "content": "Content A", "rank": 0.8}, + {"id": "uuid-2", "title": "Article B", "content": "Content B", "rank": 0.5}, + ]) result = await service.search("RAG", top_k=10) assert isinstance(result, SearchResponse) @@ -72,101 +95,78 @@ async def test_search_returns_chunks(service, mock_db, mock_llm_service): @pytest.mark.asyncio async def test_search_no_results(service, mock_db, mock_llm_service): - mock_db.execute.return_value = _mock_result(rows=[]) + service.retriever.search = AsyncMock(return_value=[]) result = await service.search("nonexistent") assert result.chunks == [] -# ── trigger_index() ── - @pytest.mark.asyncio -async def test_trigger_index_returns_202(service, mock_db, mock_llm_service): - result = await service.trigger_index() - assert result.status == "started" - assert result.job_id - - -@pytest.mark.asyncio -async def test_trigger_index_article_not_found_raises_404(service, mock_db, mock_llm_service): - mock_db.execute.return_value = _mock_result(scalar_val=None) - - with pytest.raises(HTTPException) as exc_info: - await service.trigger_index(article_id="nonexistent-uuid") - assert exc_info.value.status_code == 404 +async def test_search_with_topic_id(service, mock_db, mock_llm_service): + service.retriever.search = AsyncMock(return_value=[ + {"id": "uuid-1", "title": "Article A", "content": "Content A", "rank": 0.8} + ]) + result = await service.search("RAG", topic_id="topic-uuid") + assert len(result.chunks) == 1 + service.retriever.search.assert_called_once_with("RAG", limit=10, topic_id="topic-uuid") -# ── get_status() ── @pytest.mark.asyncio -async def test_get_status_returns_shape(service, mock_db, mock_llm_service): - mock_db.execute.return_value = _mock_result(scalar_val=42) - - result = await service.get_status() - assert result.pending_articles == 42 - assert result.total_chunks == 0 - assert result.last_indexed_at is None - +async def test_search_untitled_fallback(service, mock_db, mock_llm_service): + service.retriever.search = AsyncMock(return_value=[ + {"id": "uuid-1", "title": "", "content": "Content", "rank": 0.5} + ]) -# ── Missing branch tests ── + result = await service.search("test") + assert result.chunks[0].article_title == "Untitled" @pytest.mark.asyncio -async def test_chat_generic_exception_raises_503(service, mock_db, mock_llm_service): - """Non-RuntimeError exceptions from rag_generate also produce 503.""" - mock_db.execute.return_value = _mock_result(rows=[]) - mock_llm_service.generate.side_effect = Exception("unexpected error") +async def test_search_empty_content_fallback(service, mock_db, mock_llm_service): + service.retriever.search = AsyncMock(return_value=[ + {"id": "uuid-1", "title": "Title", "content": None, "rank": 0.5} + ]) - with pytest.raises(HTTPException) as exc_info: - await service.chat("hello") - assert exc_info.value.status_code == 503 + result = await service.search("test") + assert result.chunks[0].content == "" -@pytest.mark.asyncio -async def test_chat_untitled_fallback_for_none_title(service, mock_db, mock_llm_service): - """Articles with None/empty title get 'Untitled' fallback.""" - mock_db.execute.return_value = _mock_result( - rows=[{"id": "uuid-1", "title": None, "content": "Some content", "rank": 0.5}] - ) - mock_llm_service.generate.return_value = "Reply" +# ── trigger_index() ── - result = await service.chat("hello") - assert result.articles_used[0].title == "Untitled" +@pytest.mark.asyncio +async def test_trigger_index_returns_202(service, mock_db, mock_llm_service): + result = await service.trigger_index() + assert result.status == "started" + assert result.job_id @pytest.mark.asyncio -async def test_search_with_topic_id(service, mock_db, mock_llm_service): - """search() with topic_id passes it through to _search_articles.""" - mock_db.execute.return_value = _mock_result( - rows=[{"id": "uuid-1", "title": "Article A", "content": "Content A", "rank": 0.8}] - ) - - result = await service.search("RAG", topic_id="topic-uuid") - assert len(result.chunks) == 1 - # Verify the SQL was executed (topic_id passed to params) - mock_db.execute.assert_called_once() +async def test_trigger_index_job_id_is_uuid(service, mock_db, mock_llm_service): + """job_id must be a valid UUID per spec.""" + result = await service.trigger_index() + uuid.UUID(result.job_id) # raises ValueError if not a valid UUID @pytest.mark.asyncio -async def test_search_untitled_fallback(service, mock_db, mock_llm_service): - """Search results with None/empty title get 'Untitled' fallback.""" - mock_db.execute.return_value = _mock_result( - rows=[{"id": "uuid-1", "title": "", "content": "Content", "rank": 0.5}] - ) +async def test_trigger_index_article_not_found_raises_404(service, mock_db, mock_llm_service): + mock_db.execute.return_value = _mock_result(scalar_val=None) - result = await service.search("test") - assert result.chunks[0].article_title == "Untitled" + with pytest.raises(HTTPException) as exc_info: + await service.trigger_index(article_id="nonexistent-uuid") + assert exc_info.value.status_code == 404 @pytest.mark.asyncio -async def test_search_empty_content_fallback(service, mock_db, mock_llm_service): - """Search results with None content get empty string fallback.""" - mock_db.execute.return_value = _mock_result( - rows=[{"id": "uuid-1", "title": "Title", "content": None, "rank": 0.5}] - ) +async def test_trigger_index_already_indexing_raises_409(service, mock_db, mock_llm_service): + """409 when article_id is currently being indexed (in _indexing_articles).""" + # Simulate an in-progress indexing by manually adding to the tracking set + service._indexing_articles.add("uuid-1") - result = await service.search("test") - assert result.chunks[0].content == "" + with pytest.raises(HTTPException) as exc_info: + await service.trigger_index(article_id="uuid-1") + assert exc_info.value.status_code == 409 + assert "already in progress" in exc_info.value.detail.lower() @pytest.mark.asyncio @@ -176,13 +176,14 @@ async def test_trigger_index_with_article_found(service, mock_db, mock_llm_servi result = await service.trigger_index(article_id="uuid-1") assert result.status == "started" - assert result.job_id + uuid.UUID(result.job_id) # valid UUID -@pytest.mark.asyncio -async def test_get_status_with_none_scalar(service, mock_db, mock_llm_service): - """get_status when count(*) returns None — should default to 0.""" - mock_db.execute.return_value = _mock_result(scalar_val=None) +# ── get_status() ── +@pytest.mark.asyncio +async def test_get_status_returns_shape(service, mock_db, mock_llm_service): result = await service.get_status() assert result.pending_articles == 0 + assert result.total_chunks == 0 + assert result.last_indexed_at is None