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
20 changes: 14 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
A reference **Generative AI backend** in Python: a **RAG** assistant that answers from a product catalog, built with **FastAPI**, **Domain-Driven Design (DDD)** and the **Hexagonal (Ports & Adapters)** pattern. The LLM, the embeddings and the vector store are adapters behind ports — swap providers, fake them in tests, and keep them out of your domain.

[![CI](https://github.com/jcmoro/python-fastapi/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/jcmoro/python-fastapi/actions/workflows/ci.yml)
![Tests](https://img.shields.io/badge/tests-137%20passing-2e7d32?style=flat)
![Tests](https://img.shields.io/badge/tests-192%20passing-2e7d32?style=flat)
![Architecture](https://img.shields.io/badge/architecture-DDD%20%2B%20Hexagonal-2e7d32?style=flat)
![mypy](https://img.shields.io/badge/mypy-strict-1565c0?style=flat)
![GenAI](https://img.shields.io/badge/GenAI-RAG%20assistant-6a1b9a?style=flat)
Expand Down Expand Up @@ -172,27 +172,35 @@ src/
question.py # Value object (stripped, non-empty, ≤ 1,000 chars)
completion.py # Completion: text, model, tokens, latency
catalog_product.py # assistant's own view of a product
product_document.py # ProductDocument (content + content_hash), EmbeddedDocument
product_document.py # ProductDocument (catalog fields, content, content_hash)
retrieved_document.py # Search hit with cosine similarity
exceptions.py # InvalidQuestionError, LLMUnavailableError
candidate.py # Candidate, RubricAnswers, Assessment (+ usage, failure reason)
assessment_usage.py # API calls and tokens per assessment
rubric.py # The two yes/no rubric questions, defined once
selection.py # select(): filter, rank, tie-break, threshold — pure
exceptions.py # InvalidQuestionError, LLMUnavailableError, InvalidAssessmentError
ports/
llm_client.py # LLMClient ABC — documents which failures it raises
embedding_client.py # One embedding per text, same order
document_search.py # Top-k, most similar first
document_index.py # Hashes, upsert, delete
catalog_reader.py # Products from the catalog, no duplicates
candidate_assessor.py # One candidate per call; probabilities in [0, 1]
application/
dtos.py # AskCommand, AnswerDTO, SourceDTO, UsageDTO, IndexReportDTO
dtos.py # Ask/Answer/Source/Usage/IndexReport/Candidate/Recommendation DTOs
use_cases/
ask_assistant.py # Embed → retrieve → generate, fallback without generation
index_catalog.py # Incremental reindex + orphan deletion
index_catalog.py # Incremental reindex + orphan deletion (before upsert)
recommend_candidate.py # Top-N, deadline, cancellation, select(), one log line
infrastructure/ # The only place anthropic, fastembed and pgvector live.
llm/
anthropic_errors.py # Shared transient-vs-configuration classification
anthropic_llm_client.py # Timeout, retries, error classification
claude_candidate_assessor.py # Rubric via structured output; 3 samples; per-call concurrency limit
embeddings/
fastembed_embedding_client.py
persistence/
models.py # ProductDocumentModel (vector(384), HNSW cosine index)
models.py # ProductDocumentModel (vector(384), HNSW, unique sku)
sqlalchemy_document_index.py
sqlalchemy_document_search.py
catalog/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""add catalog columns to product_documents

Revision ID: ff5e81205e1e
Revises: 4aa1b2bf2668
Create Date: 2026-10-27 22:38:12

"""
from collections.abc import Sequence

from alembic import op
import sqlalchemy as sa


revision: str = 'ff5e81205e1e'
down_revision: str | None = '4aa1b2bf2668'
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None


def upgrade() -> None:
op.add_column('product_documents', sa.Column('sku', sa.String(length=64), nullable=True))
op.add_column('product_documents', sa.Column('name', sa.String(length=200), nullable=True))
op.add_column('product_documents', sa.Column('price_cents', sa.Integer(), nullable=True))
op.add_column('product_documents', sa.Column('currency', sa.String(length=3), nullable=True))
op.create_index(op.f('ix_product_documents_sku'), 'product_documents', ['sku'], unique=True)


def downgrade() -> None:
op.drop_index(op.f('ix_product_documents_sku'), table_name='product_documents')
op.drop_column('product_documents', 'currency')
op.drop_column('product_documents', 'price_cents')
op.drop_column('product_documents', 'name')
op.drop_column('product_documents', 'sku')
Loading
Loading