Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions alembic.ini
Original file line number Diff line number Diff line change
@@ -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
66 changes: 66 additions & 0 deletions alembic/env.py
Original file line number Diff line number Diff line change
@@ -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()
25 changes: 25 additions & 0 deletions alembic/script.py.mako
Original file line number Diff line number Diff line change
@@ -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"}
60 changes: 60 additions & 0 deletions alembic/versions/001_initial_articles.py
Original file line number Diff line number Diff line change
@@ -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")
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ dependencies = [
"google-genai",
"httpx",
"tenacity",
"alembic",
]

[dependency-groups]
Expand Down
3 changes: 3 additions & 0 deletions specs/integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/chatbot_plugin/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/chatbot_plugin/contracts/responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
12 changes: 12 additions & 0 deletions src/chatbot_plugin/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
13 changes: 11 additions & 2 deletions src/chatbot_plugin/models/article.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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:
Expand Down
4 changes: 4 additions & 0 deletions src/chatbot_plugin/rag/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
"""RAG pipeline components."""

from chatbot_plugin.rag.retriever import Retriever

__all__ = ["Retriever"]
59 changes: 59 additions & 0 deletions src/chatbot_plugin/rag/retriever.py
Original file line number Diff line number Diff line change
@@ -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
]
3 changes: 2 additions & 1 deletion src/chatbot_plugin/routers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading