Skip to content

Commit 1686f7c

Browse files
Add AI RAG demo project with ArchUnitPy architecture tests
Example RAG pipeline (mock endpoints, vector store, LLM client) with layered architecture and PlantUML diagram. Architecture tests demonstrate ArchUnitPy catching real violations: api/bad_shortcut.py bypasses the service layer, shared/leaky.py creates an upward dependency. 10 passing tests + 4 intentional xfail violations showing the library in action. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 94fe6c2 commit 1686f7c

20 files changed

Lines changed: 497 additions & 0 deletions

examples/ai_rag_demo/__init__.py

Whitespace-only changes.

examples/ai_rag_demo/api/__init__.py

Whitespace-only changes.
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
"""BAD: API layer directly accessing retrieval layer, bypassing services.
2+
3+
This is an intentional architecture violation for demonstration.
4+
The API layer should only talk to the services layer, never directly
5+
to retrieval or LLM.
6+
"""
7+
8+
from retrieval.vector_store import VectorStore
9+
from retrieval.embedder import Embedder
10+
11+
12+
def quick_search(text: str) -> list[dict]:
13+
"""A 'shortcut' endpoint that bypasses the service layer.
14+
15+
This violates the layered architecture by reaching directly
16+
into the retrieval layer from the API layer.
17+
"""
18+
embedder = Embedder()
19+
store = VectorStore()
20+
embedding = embedder.embed(text)
21+
results = store.search(embedding, top_k=3)
22+
return [{"text": r.text, "score": r.score} for r in results]
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"""API endpoints for the RAG service (mock FastAPI-style)."""
2+
3+
from models.document import Document
4+
from models.query import Query, RAGResponse
5+
from services.rag_service import RAGService
6+
7+
8+
_rag_service = RAGService()
9+
10+
11+
def ingest_document(doc_id: str, content: str, source: str) -> dict:
12+
"""POST /ingest - Ingest a document into the RAG pipeline."""
13+
doc = Document(id=doc_id, content=content, source=source)
14+
count = _rag_service.ingest(doc)
15+
return {"status": "ok", "chunks_stored": count}
16+
17+
18+
def ask(question: str, top_k: int = 5) -> dict:
19+
"""POST /ask - Ask a question using RAG."""
20+
query = Query(text=question, top_k=top_k)
21+
response: RAGResponse = _rag_service.query(query)
22+
return {
23+
"answer": response.answer,
24+
"sources": [{"text": s.text[:100], "score": s.score} for s in response.sources],
25+
"model": response.model,
26+
"tokens_used": response.tokens_used,
27+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
@startuml
2+
component [api]
3+
component [services]
4+
component [models]
5+
component [retrieval]
6+
component [llm]
7+
component [shared]
8+
9+
[api] --> [services]
10+
[services] --> [retrieval]
11+
[services] --> [llm]
12+
[services] --> [models]
13+
[retrieval] --> [models]
14+
[retrieval] --> [shared]
15+
[llm] --> [models]
16+
[llm] --> [shared]
17+
[models] --> [shared]
18+
@enduml

examples/ai_rag_demo/llm/__init__.py

Whitespace-only changes.

examples/ai_rag_demo/llm/client.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
"""Mock LLM client."""
2+
3+
from models.query import RetrievalResult
4+
from shared.config import Config
5+
6+
7+
class LLMClient:
8+
"""Mock LLM client that generates fake responses."""
9+
10+
def __init__(self):
11+
self.model = Config.LLM_MODEL
12+
self.max_tokens = Config.LLM_MAX_TOKENS
13+
14+
def generate(self, prompt: str, context: list[RetrievalResult]) -> tuple[str, int]:
15+
"""Generate a mock response based on the prompt and retrieved context.
16+
17+
Returns:
18+
Tuple of (response_text, tokens_used).
19+
"""
20+
context_text = "\n".join(f"- {r.text[:100]}" for r in context)
21+
answer = (
22+
f"Based on {len(context)} retrieved documents, "
23+
f"here is the answer to '{prompt[:50]}...': "
24+
f"[Mock LLM response using {self.model}]"
25+
)
26+
tokens_used = len(answer.split()) * 2 # rough mock
27+
return answer, tokens_used

examples/ai_rag_demo/models/__init__.py

Whitespace-only changes.
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
"""Document models for the RAG pipeline."""
2+
3+
from dataclasses import dataclass, field
4+
5+
from shared.config import Config
6+
7+
8+
@dataclass
9+
class Chunk:
10+
text: str
11+
metadata: dict = field(default_factory=dict)
12+
embedding: list[float] = field(default_factory=list)
13+
14+
15+
@dataclass
16+
class Document:
17+
id: str
18+
content: str
19+
source: str
20+
chunks: list[Chunk] = field(default_factory=list)
21+
22+
def split_into_chunks(self) -> list[Chunk]:
23+
size = Config.CHUNK_SIZE
24+
overlap = Config.CHUNK_OVERLAP
25+
text = self.content
26+
chunks = []
27+
start = 0
28+
while start < len(text):
29+
end = start + size
30+
chunks.append(Chunk(text=text[start:end], metadata={"source": self.source}))
31+
start = end - overlap
32+
self.chunks = chunks
33+
return chunks
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
"""Query and response models."""
2+
3+
from dataclasses import dataclass, field
4+
5+
6+
@dataclass
7+
class Query:
8+
text: str
9+
top_k: int = 5
10+
filters: dict = field(default_factory=dict)
11+
12+
13+
@dataclass
14+
class RetrievalResult:
15+
text: str
16+
score: float
17+
source: str
18+
19+
20+
@dataclass
21+
class RAGResponse:
22+
answer: str
23+
sources: list[RetrievalResult]
24+
model: str
25+
tokens_used: int

0 commit comments

Comments
 (0)