[NOTICKET] Demo agentic agent
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitignore +32 -0
- Dockerfile +34 -0
- README.md +67 -0
- main.py +70 -0
- playground_chat.py +79 -0
- playground_flush_cache.py +39 -0
- pyproject.toml +135 -0
- run.py +18 -0
- src/__init__.py +0 -0
- src/agents/__init__.py +0 -0
- src/agents/chatbot.py +75 -0
- src/agents/orchestration.py +74 -0
- src/api/v1/__init__.py +0 -0
- src/api/v1/chat.py +169 -0
- src/api/v1/document.py +193 -0
- src/api/v1/knowledge.py +25 -0
- src/api/v1/room.py +107 -0
- src/api/v1/users.py +74 -0
- src/config/__init__.py +0 -0
- src/config/agents/guardrails_prompt.md +7 -0
- src/config/agents/system_prompt.md +18 -0
- src/config/env_constant.py +9 -0
- src/config/settings.py +67 -0
- src/db/postgres/__init__.py +0 -0
- src/db/postgres/connection.py +52 -0
- src/db/postgres/init_db.py +18 -0
- src/db/postgres/models.py +53 -0
- src/db/postgres/vector_store.py +31 -0
- src/db/redis/__init__.py +0 -0
- src/db/redis/connection.py +16 -0
- src/document/__init__.py +0 -0
- src/document/document_service.py +108 -0
- src/knowledge/__init__.py +0 -0
- src/knowledge/processing_service.py +146 -0
- src/middlewares/__init__.py +0 -0
- src/middlewares/cors.py +14 -0
- src/middlewares/logging.py +66 -0
- src/middlewares/rate_limit.py +17 -0
- src/models/__init__.py +0 -0
- src/models/security.py +10 -0
- src/models/states.py +14 -0
- src/models/structured_output.py +21 -0
- src/models/user_info.py +15 -0
- src/observability/langfuse/__init__.py +0 -0
- src/observability/langfuse/langfuse.py +29 -0
- src/rag/__init__.py +0 -0
- src/rag/retriever.py +70 -0
- src/storage/az_blob/__init__.py +0 -0
- src/storage/az_blob/az_blob.py +76 -0
- src/tools/__init__.py +0 -0
.gitignore
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python-generated files
|
| 2 |
+
**/__pycache__/*
|
| 3 |
+
.env
|
| 4 |
+
__pycache__
|
| 5 |
+
agent-chat-ui
|
| 6 |
+
config.yaml
|
| 7 |
+
_archieved
|
| 8 |
+
|
| 9 |
+
__pycache__/
|
| 10 |
+
*.py[oc]
|
| 11 |
+
build/
|
| 12 |
+
dist/
|
| 13 |
+
wheels/
|
| 14 |
+
*.egg-info
|
| 15 |
+
asset_testing/
|
| 16 |
+
test/users/user_accounts.csv
|
| 17 |
+
.continue/
|
| 18 |
+
|
| 19 |
+
# Virtual environments
|
| 20 |
+
.venv
|
| 21 |
+
|
| 22 |
+
# env
|
| 23 |
+
.env
|
| 24 |
+
.env.dev
|
| 25 |
+
.env.uat
|
| 26 |
+
.env.prd
|
| 27 |
+
.env.example
|
| 28 |
+
|
| 29 |
+
playground_retriever.py
|
| 30 |
+
playground_chat.pyc
|
| 31 |
+
playground_flush_cache.pyc
|
| 32 |
+
API_CONTRACT.md
|
Dockerfile
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.12-slim-bookworm
|
| 2 |
+
|
| 3 |
+
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
| 4 |
+
|
| 5 |
+
WORKDIR /app
|
| 6 |
+
|
| 7 |
+
ENV PYTHONUNBUFFERED=1 \
|
| 8 |
+
UV_COMPILE_BYTECODE=1
|
| 9 |
+
|
| 10 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 11 |
+
build-essential \
|
| 12 |
+
libpq-dev \
|
| 13 |
+
gcc \
|
| 14 |
+
libgomp1 \
|
| 15 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 16 |
+
|
| 17 |
+
RUN addgroup --system app && \
|
| 18 |
+
adduser --system --group --home /home/app app
|
| 19 |
+
|
| 20 |
+
COPY pyproject.toml uv.lock ./
|
| 21 |
+
RUN uv sync --frozen --no-dev
|
| 22 |
+
|
| 23 |
+
# Download spaCy model required by presidio-analyzer
|
| 24 |
+
RUN uv run python -m spacy download en_core_web_lg
|
| 25 |
+
|
| 26 |
+
COPY . .
|
| 27 |
+
|
| 28 |
+
RUN chown -R app:app /app
|
| 29 |
+
|
| 30 |
+
USER app
|
| 31 |
+
|
| 32 |
+
EXPOSE 7860
|
| 33 |
+
|
| 34 |
+
CMD ["uv", "run", "--no-sync", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
|
@@ -8,3 +8,70 @@ pinned: false
|
|
| 8 |
---
|
| 9 |
|
| 10 |
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
How to run:
|
| 14 |
+
`uv run --no-sync uvicorn main:app --host 0.0.0.0 --port 7860`
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
Agent
|
| 18 |
+
Orchestrator : intent recognition, orchestrate, and plannings
|
| 19 |
+
Chatbot : have tools (retriever, and search), called by orchestrator
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
APIs
|
| 23 |
+
/api/v1/login -> login by email and password
|
| 24 |
+
|
| 25 |
+
/api/v1/documents/{user_id} -> list all documents
|
| 26 |
+
/api/v1/document/upload -> upload document
|
| 27 |
+
/api/v1/document/delete -> delete document
|
| 28 |
+
/api/v1/document/process -> extract document and ingest to vector index
|
| 29 |
+
|
| 30 |
+
/api/v1/chat/stream -> talk with agent chatbot, in streaming response
|
| 31 |
+
/api/v1/rooms/{user_id} -> list all room based on user id
|
| 32 |
+
/api/v1/room/{room_id} -> get room based on room id
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
Config
|
| 36 |
+
- Agent: system prompt, guardrails
|
| 37 |
+
- others config needed
|
| 38 |
+
|
| 39 |
+
DB
|
| 40 |
+
- using postgres as db
|
| 41 |
+
- we can use pg vector from this db also
|
| 42 |
+
- use redis for caching response, same question will not re-processed for 24 hour
|
| 43 |
+
|
| 44 |
+
Document
|
| 45 |
+
- service to manage document, upload, delete, log to db
|
| 46 |
+
|
| 47 |
+
Knowledge
|
| 48 |
+
- service to process document into vector, until ingestion to pg vector
|
| 49 |
+
|
| 50 |
+
Middleware
|
| 51 |
+
CORS:
|
| 52 |
+
- allow all
|
| 53 |
+
Rate limiting:
|
| 54 |
+
- upload document: 10 document per menit
|
| 55 |
+
Logging:
|
| 56 |
+
- create clear and strutured logging for better debuging
|
| 57 |
+
|
| 58 |
+
Models
|
| 59 |
+
- Data models
|
| 60 |
+
|
| 61 |
+
Observability
|
| 62 |
+
- Langfuse traceability
|
| 63 |
+
|
| 64 |
+
RAG
|
| 65 |
+
- retriever service to get relevant context from pg vector
|
| 66 |
+
|
| 67 |
+
storage
|
| 68 |
+
- storage functionality to communicate with storage provider
|
| 69 |
+
|
| 70 |
+
tools
|
| 71 |
+
- tools that can be use by agent
|
| 72 |
+
|
| 73 |
+
Users
|
| 74 |
+
- Users management, to get user indentity based on login information.
|
| 75 |
+
|
| 76 |
+
Utils
|
| 77 |
+
- Other functionality
|
main.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Main application entry point."""
|
| 2 |
+
|
| 3 |
+
from fastapi import FastAPI
|
| 4 |
+
from src.middlewares.logging import configure_logging, get_logger
|
| 5 |
+
from src.middlewares.cors import add_cors_middleware
|
| 6 |
+
from src.middlewares.rate_limit import limiter, _rate_limit_exceeded_handler
|
| 7 |
+
from slowapi.errors import RateLimitExceeded
|
| 8 |
+
from src.api.v1.document import router as document_router
|
| 9 |
+
from src.api.v1.chat import router as chat_router
|
| 10 |
+
from src.api.v1.room import router as room_router
|
| 11 |
+
from src.api.v1.users import router as users_router
|
| 12 |
+
from src.api.v1.knowledge import router as knowledge_router
|
| 13 |
+
from src.db.postgres.init_db import init_db
|
| 14 |
+
import uvicorn
|
| 15 |
+
|
| 16 |
+
# Configure logging
|
| 17 |
+
configure_logging()
|
| 18 |
+
logger = get_logger("main")
|
| 19 |
+
|
| 20 |
+
# Create FastAPI app
|
| 21 |
+
app = FastAPI(
|
| 22 |
+
title="DataEyond Agentic Service",
|
| 23 |
+
description="Multi-agent AI backend with RAG capabilities",
|
| 24 |
+
version="0.1.0"
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
# Add middleware
|
| 28 |
+
add_cors_middleware(app)
|
| 29 |
+
app.state.limiter = limiter
|
| 30 |
+
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
| 31 |
+
|
| 32 |
+
# Include routers
|
| 33 |
+
app.include_router(users_router)
|
| 34 |
+
app.include_router(document_router)
|
| 35 |
+
app.include_router(knowledge_router)
|
| 36 |
+
app.include_router(room_router)
|
| 37 |
+
app.include_router(chat_router)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@app.on_event("startup")
|
| 41 |
+
async def startup_event():
|
| 42 |
+
"""Initialize database on startup."""
|
| 43 |
+
logger.info("Starting application...")
|
| 44 |
+
await init_db()
|
| 45 |
+
logger.info("Database initialized")
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@app.get("/")
|
| 49 |
+
async def root():
|
| 50 |
+
"""Root endpoint."""
|
| 51 |
+
return {
|
| 52 |
+
"status": "ok",
|
| 53 |
+
"service": "DataEyond Agentic Service",
|
| 54 |
+
"version": "0.1.0"
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
@app.get("/health")
|
| 59 |
+
async def health_check():
|
| 60 |
+
"""Health check endpoint."""
|
| 61 |
+
return {"status": "healthy"}
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
if __name__ == "__main__":
|
| 65 |
+
uvicorn.run(
|
| 66 |
+
"src.main:app",
|
| 67 |
+
host="0.0.0.0",
|
| 68 |
+
port=8000,
|
| 69 |
+
reload=True
|
| 70 |
+
)
|
playground_chat.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Playground script for testing the full chat pipeline (Orchestrator → Retriever → Chatbot).
|
| 2 |
+
|
| 3 |
+
Usage:
|
| 4 |
+
uv run python playground_chat.py
|
| 5 |
+
|
| 6 |
+
Tweak the variables in the CONFIG section below and re-run.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import asyncio
|
| 10 |
+
import json
|
| 11 |
+
from langchain_core.messages import HumanMessage
|
| 12 |
+
|
| 13 |
+
from src.agents.orchestration import orchestrator
|
| 14 |
+
from src.agents.chatbot import chatbot
|
| 15 |
+
from src.rag.retriever import retriever
|
| 16 |
+
from src.api.v1.chat import _format_context, _extract_sources
|
| 17 |
+
|
| 18 |
+
# ──────────────────────────────────────────────
|
| 19 |
+
# CONFIG — change these freely
|
| 20 |
+
# ──────────────────────────────────────────────
|
| 21 |
+
MESSAGE = "Berapa digital rate card untuk Net Message?"
|
| 22 |
+
USER_ID = "8b6c18fd-8971-46e5-b106-35b7afb412e0"
|
| 23 |
+
TOP_K = 5 # number of chunks to retrieve
|
| 24 |
+
# ──────────────────────────────────────────────
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
async def main():
|
| 28 |
+
print(f"\n{'='*60}")
|
| 29 |
+
print(f"Message : {MESSAGE}")
|
| 30 |
+
print(f"User ID : {USER_ID}")
|
| 31 |
+
print(f"Top-K : {TOP_K}")
|
| 32 |
+
print(f"{'='*60}\n")
|
| 33 |
+
|
| 34 |
+
# Step 1: Orchestrator — intent analysis
|
| 35 |
+
print("── Step 1: Orchestrator ──────────────────────────────────")
|
| 36 |
+
intent_result = await orchestrator.analyze_message(MESSAGE)
|
| 37 |
+
print(json.dumps(intent_result, indent=2, ensure_ascii=False))
|
| 38 |
+
|
| 39 |
+
context = ""
|
| 40 |
+
sources = []
|
| 41 |
+
|
| 42 |
+
# Step 2: Retriever (only if orchestrator says so)
|
| 43 |
+
if intent_result.get("needs_search"):
|
| 44 |
+
search_query = intent_result.get("search_query", MESSAGE)
|
| 45 |
+
print(f"\n── Step 2: Retriever (query: {search_query!r}) ──────────────")
|
| 46 |
+
raw_results = await retriever.retrieve(
|
| 47 |
+
query=search_query,
|
| 48 |
+
user_id=USER_ID,
|
| 49 |
+
db=None,
|
| 50 |
+
k=TOP_K,
|
| 51 |
+
)
|
| 52 |
+
context = _format_context(raw_results)
|
| 53 |
+
sources = _extract_sources(raw_results)
|
| 54 |
+
|
| 55 |
+
print(f"Retrieved {len(raw_results)} chunk(s). Sources:")
|
| 56 |
+
for s in sources:
|
| 57 |
+
print(f" - {s['filename']} p.{s['page_label']}")
|
| 58 |
+
print(f"\nContext preview:\n{context[:500]}{'...' if len(context) > 500 else ''}")
|
| 59 |
+
else:
|
| 60 |
+
print("\n── Step 2: Retriever — skipped (no search needed)")
|
| 61 |
+
|
| 62 |
+
# Step 3: Direct response (greetings, etc.)
|
| 63 |
+
if intent_result.get("direct_response"):
|
| 64 |
+
print("\n── Step 3: Direct response (no LLM call) ────────────────")
|
| 65 |
+
print(intent_result["direct_response"])
|
| 66 |
+
return
|
| 67 |
+
|
| 68 |
+
# Step 4: Chatbot — generate answer
|
| 69 |
+
print("\n── Step 4: Chatbot response ──────────────────────────────")
|
| 70 |
+
messages = [HumanMessage(content=MESSAGE)]
|
| 71 |
+
response = await chatbot.generate_response(messages, context)
|
| 72 |
+
print(response)
|
| 73 |
+
|
| 74 |
+
print(f"\n── Sources ───────────────────────────────────────────────")
|
| 75 |
+
print(json.dumps(sources, indent=2, ensure_ascii=False))
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
if __name__ == "__main__":
|
| 79 |
+
asyncio.run(main())
|
playground_flush_cache.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""One-shot script to delete a specific chat cache key from Redis.
|
| 2 |
+
|
| 3 |
+
Usage:
|
| 4 |
+
uv run python playground_flush_cache.py
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import asyncio
|
| 8 |
+
from src.db.redis.connection import get_redis
|
| 9 |
+
from src.config.settings import settings
|
| 10 |
+
|
| 11 |
+
# ──────────────────────────────────────────────
|
| 12 |
+
# CONFIG
|
| 13 |
+
# ──────────────────────────────────────────────
|
| 14 |
+
USER_ID = "8b6c18fd-8971-46e5-b106-35b7afb412e0"
|
| 15 |
+
MESSAGE = "Berapa digital rate card untuk whatsapp?"
|
| 16 |
+
# Set to True to wipe ALL chat cache keys for the user instead
|
| 17 |
+
FLUSH_ALL_USER_CHAT = False
|
| 18 |
+
# ──────────────────────────────────────────────
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
async def main():
|
| 22 |
+
redis = await get_redis()
|
| 23 |
+
prefix = f"{settings.redis_prefix}chat:{USER_ID}:"
|
| 24 |
+
|
| 25 |
+
if FLUSH_ALL_USER_CHAT:
|
| 26 |
+
keys = await redis.keys(f"{prefix}*")
|
| 27 |
+
if keys:
|
| 28 |
+
await redis.delete(*keys)
|
| 29 |
+
print(f"Deleted {len(keys)} key(s) matching {prefix}*")
|
| 30 |
+
else:
|
| 31 |
+
print("No keys found.")
|
| 32 |
+
else:
|
| 33 |
+
key = f"{prefix}{MESSAGE}"
|
| 34 |
+
deleted = await redis.delete(key)
|
| 35 |
+
print(f"Deleted {deleted} key: {key!r}")
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
if __name__ == "__main__":
|
| 39 |
+
asyncio.run(main())
|
pyproject.toml
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[build-system]
|
| 2 |
+
requires = ["hatchling"]
|
| 3 |
+
build-backend = "hatchling.build"
|
| 4 |
+
|
| 5 |
+
[project]
|
| 6 |
+
name = "demo-agent-service"
|
| 7 |
+
version = "0.1.0"
|
| 8 |
+
description = "DEMO Agentic Service Data Eyond — Multi-Agent AI Backend"
|
| 9 |
+
requires-python = ">=3.12,<3.13"
|
| 10 |
+
|
| 11 |
+
dependencies = [
|
| 12 |
+
# --- Web Framework ---
|
| 13 |
+
"fastapi[standard]==0.115.6",
|
| 14 |
+
"uvicorn[standard]==0.32.1",
|
| 15 |
+
"python-multipart==0.0.12",
|
| 16 |
+
"starlette==0.41.3",
|
| 17 |
+
"sse-starlette==2.1.3",
|
| 18 |
+
# --- LangChain Core Ecosystem (NO LiteLLM) ---
|
| 19 |
+
"langchain==0.3.13",
|
| 20 |
+
"langchain-core==0.3.28",
|
| 21 |
+
"langchain-community==0.3.13",
|
| 22 |
+
"langchain-openai==0.2.14",
|
| 23 |
+
"langchain-postgres>=0.0.13",
|
| 24 |
+
"langgraph==0.2.60",
|
| 25 |
+
"langgraph-checkpoint-postgres==2.0.9",
|
| 26 |
+
# --- LLM / Azure OpenAI ---
|
| 27 |
+
"openai==1.58.1",
|
| 28 |
+
"tiktoken==0.8.0",
|
| 29 |
+
# --- Database ---
|
| 30 |
+
"sqlalchemy[asyncio]==2.0.36",
|
| 31 |
+
"asyncpg==0.30.0",
|
| 32 |
+
"psycopg[binary,pool]==3.2.3",
|
| 33 |
+
"pgvector==0.3.6",
|
| 34 |
+
"alembic==1.14.0",
|
| 35 |
+
# --- Azure ---
|
| 36 |
+
"azure-storage-blob==12.23.1",
|
| 37 |
+
"azure-identity==1.19.0",
|
| 38 |
+
"azure-ai-documentintelligence==1.0.0",
|
| 39 |
+
# --- Pydantic / Validation ---
|
| 40 |
+
"pydantic==2.10.3",
|
| 41 |
+
"pydantic-settings==2.7.0",
|
| 42 |
+
# --- Observability ---
|
| 43 |
+
"langfuse==2.57.4",
|
| 44 |
+
"structlog==24.4.0",
|
| 45 |
+
"prometheus-client==0.21.1",
|
| 46 |
+
# --- Security ---
|
| 47 |
+
"passlib[bcrypt]==1.7.4",
|
| 48 |
+
"cryptography==44.0.0",
|
| 49 |
+
# --- Rate Limiting ---
|
| 50 |
+
"slowapi==0.1.9",
|
| 51 |
+
"redis==5.2.1",
|
| 52 |
+
# --- Retry ---
|
| 53 |
+
"tenacity==9.0.0",
|
| 54 |
+
# --- Document Processing (for reading existing docs from blob) ---
|
| 55 |
+
"pypdf==5.1.0",
|
| 56 |
+
"python-docx==1.1.2",
|
| 57 |
+
"openpyxl==3.1.5",
|
| 58 |
+
"pandas==2.2.3",
|
| 59 |
+
# --- Chart/Visualization ---
|
| 60 |
+
"matplotlib==3.9.3",
|
| 61 |
+
"plotly==5.24.1",
|
| 62 |
+
"kaleido==0.2.1",
|
| 63 |
+
# --- MCP ---
|
| 64 |
+
"mcp==1.2.0",
|
| 65 |
+
# --- Advanced RAG ---
|
| 66 |
+
"rank-bm25==0.2.2",
|
| 67 |
+
"sentence-transformers==3.3.1",
|
| 68 |
+
# --- PII Detection (no LiteLLM) ---
|
| 69 |
+
"presidio-analyzer==2.2.355",
|
| 70 |
+
"presidio-anonymizer==2.2.355",
|
| 71 |
+
"spacy==3.8.3",
|
| 72 |
+
# --- Utilities ---
|
| 73 |
+
"httpx==0.28.1",
|
| 74 |
+
"anyio==4.7.0",
|
| 75 |
+
"python-dotenv==1.0.1",
|
| 76 |
+
"orjson==3.10.12",
|
| 77 |
+
"cachetools==5.5.0",
|
| 78 |
+
"apscheduler==3.10.4",
|
| 79 |
+
"jsonpatch>=1.33",
|
| 80 |
+
"pymongo>=4.14.0",
|
| 81 |
+
"psycopg2>=2.9.11",
|
| 82 |
+
]
|
| 83 |
+
|
| 84 |
+
[project.optional-dependencies]
|
| 85 |
+
dev = [
|
| 86 |
+
"pytest==8.3.4",
|
| 87 |
+
"pytest-asyncio==0.24.0",
|
| 88 |
+
"pytest-cov==6.0.0",
|
| 89 |
+
"httpx==0.28.1",
|
| 90 |
+
"ruff==0.8.4",
|
| 91 |
+
"mypy==1.13.0",
|
| 92 |
+
"pre-commit==4.0.1",
|
| 93 |
+
]
|
| 94 |
+
|
| 95 |
+
[tool.uv]
|
| 96 |
+
dev-dependencies = [
|
| 97 |
+
"pytest==8.3.4",
|
| 98 |
+
"pytest-asyncio==0.24.0",
|
| 99 |
+
"pytest-cov==6.0.0",
|
| 100 |
+
"ruff==0.8.4",
|
| 101 |
+
"mypy==1.13.0",
|
| 102 |
+
"pre-commit==4.0.1",
|
| 103 |
+
]
|
| 104 |
+
|
| 105 |
+
[tool.hatch.build.targets.wheel]
|
| 106 |
+
packages = ["src/agent_service"]
|
| 107 |
+
|
| 108 |
+
[tool.ruff]
|
| 109 |
+
target-version = "py312"
|
| 110 |
+
line-length = 100
|
| 111 |
+
|
| 112 |
+
[tool.ruff.lint]
|
| 113 |
+
select = ["E", "F", "I", "N", "UP", "S", "B", "A", "C4", "T20"]
|
| 114 |
+
ignore = [
|
| 115 |
+
"S101", # assert statements OK in tests
|
| 116 |
+
"S105", # hardcoded passwords — false positives in config
|
| 117 |
+
"S106",
|
| 118 |
+
"B008", # FastAPI Depends() calls OK in function args
|
| 119 |
+
]
|
| 120 |
+
|
| 121 |
+
[tool.ruff.lint.per-file-ignores]
|
| 122 |
+
"tests/**" = ["S101", "S105", "S106"]
|
| 123 |
+
|
| 124 |
+
[tool.mypy]
|
| 125 |
+
python_version = "3.12"
|
| 126 |
+
strict = true
|
| 127 |
+
ignore_missing_imports = true
|
| 128 |
+
plugins = ["pydantic.mypy"]
|
| 129 |
+
|
| 130 |
+
[tool.pytest.ini_options]
|
| 131 |
+
asyncio_mode = "auto"
|
| 132 |
+
testpaths = ["tests"]
|
| 133 |
+
filterwarnings = [
|
| 134 |
+
"ignore::DeprecationWarning",
|
| 135 |
+
]
|
run.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Entry point for running the app locally on Windows.
|
| 2 |
+
|
| 3 |
+
Sets WindowsSelectorEventLoopPolicy BEFORE uvicorn creates its event loop,
|
| 4 |
+
which is required for psycopg3 async mode compatibility.
|
| 5 |
+
Use this instead of calling uvicorn directly on Windows:
|
| 6 |
+
uv run --no-sync python run.py
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import sys
|
| 10 |
+
import asyncio
|
| 11 |
+
|
| 12 |
+
if sys.platform == "win32":
|
| 13 |
+
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
| 14 |
+
|
| 15 |
+
import uvicorn
|
| 16 |
+
|
| 17 |
+
if __name__ == "__main__":
|
| 18 |
+
uvicorn.run("main:app", host="0.0.0.0", port=7860, reload=False)
|
src/__init__.py
ADDED
|
File without changes
|
src/agents/__init__.py
ADDED
|
File without changes
|
src/agents/chatbot.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Chatbot agent with RAG capabilities."""
|
| 2 |
+
|
| 3 |
+
from langchain_openai import AzureChatOpenAI
|
| 4 |
+
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
|
| 5 |
+
from langchain_core.output_parsers import StrOutputParser
|
| 6 |
+
from src.config.settings import settings
|
| 7 |
+
from src.middlewares.logging import get_logger
|
| 8 |
+
from langchain_core.messages import HumanMessage, AIMessage
|
| 9 |
+
|
| 10 |
+
logger = get_logger("chatbot")
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class ChatbotAgent:
|
| 14 |
+
"""Chatbot agent with RAG capabilities."""
|
| 15 |
+
|
| 16 |
+
def __init__(self):
|
| 17 |
+
self.llm = AzureChatOpenAI(
|
| 18 |
+
azure_deployment=settings.azureai_deployment_name_4o,
|
| 19 |
+
openai_api_version=settings.azureai_api_version_4o,
|
| 20 |
+
azure_endpoint=settings.azureai_endpoint_url_4o,
|
| 21 |
+
api_key=settings.azureai_api_key_4o,
|
| 22 |
+
temperature=0.7
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
# Read system prompt
|
| 26 |
+
try:
|
| 27 |
+
with open("src/config/agents/system_prompt.md", "r") as f:
|
| 28 |
+
system_prompt = f.read()
|
| 29 |
+
except FileNotFoundError:
|
| 30 |
+
system_prompt = "You are a helpful AI assistant with access to user's uploaded documents."
|
| 31 |
+
|
| 32 |
+
# Create prompt template
|
| 33 |
+
self.prompt = ChatPromptTemplate.from_messages([
|
| 34 |
+
("system", system_prompt),
|
| 35 |
+
MessagesPlaceholder(variable_name="messages"),
|
| 36 |
+
("system", "Relevant documents:\n{context}")
|
| 37 |
+
])
|
| 38 |
+
|
| 39 |
+
# Create chain
|
| 40 |
+
self.chain = self.prompt | self.llm | StrOutputParser()
|
| 41 |
+
|
| 42 |
+
async def generate_response(
|
| 43 |
+
self,
|
| 44 |
+
messages: list,
|
| 45 |
+
context: str = ""
|
| 46 |
+
) -> str:
|
| 47 |
+
"""Generate response with optional RAG context."""
|
| 48 |
+
try:
|
| 49 |
+
logger.info("Generating chatbot response")
|
| 50 |
+
|
| 51 |
+
# Generate response
|
| 52 |
+
response = await self.chain.ainvoke({
|
| 53 |
+
"messages": messages,
|
| 54 |
+
"context": context
|
| 55 |
+
})
|
| 56 |
+
|
| 57 |
+
logger.info(f"Generated response: {response[:100]}...")
|
| 58 |
+
return response
|
| 59 |
+
|
| 60 |
+
except Exception as e:
|
| 61 |
+
logger.error("Response generation failed", error=str(e))
|
| 62 |
+
raise
|
| 63 |
+
|
| 64 |
+
async def astream_response(self, messages: list, context: str = ""):
|
| 65 |
+
"""Stream response tokens as they are generated."""
|
| 66 |
+
try:
|
| 67 |
+
logger.info("Streaming chatbot response")
|
| 68 |
+
async for token in self.chain.astream({"messages": messages, "context": context}):
|
| 69 |
+
yield token
|
| 70 |
+
except Exception as e:
|
| 71 |
+
logger.error("Response streaming failed", error=str(e))
|
| 72 |
+
raise
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
chatbot = ChatbotAgent()
|
src/agents/orchestration.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Orchestrator agent for intent recognition and planning."""
|
| 2 |
+
|
| 3 |
+
from langchain_openai import AzureChatOpenAI
|
| 4 |
+
from langchain_core.prompts import ChatPromptTemplate
|
| 5 |
+
from langchain_core.output_parsers import JsonOutputParser
|
| 6 |
+
from src.config.settings import settings
|
| 7 |
+
from src.middlewares.logging import get_logger
|
| 8 |
+
|
| 9 |
+
logger = get_logger("orchestrator")
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class OrchestratorAgent:
|
| 13 |
+
"""Orchestrator agent for intent recognition and planning."""
|
| 14 |
+
|
| 15 |
+
def __init__(self):
|
| 16 |
+
self.llm = AzureChatOpenAI(
|
| 17 |
+
azure_deployment=settings.azureai_deployment_name_4o,
|
| 18 |
+
openai_api_version=settings.azureai_api_version_4o,
|
| 19 |
+
azure_endpoint=settings.azureai_endpoint_url_4o,
|
| 20 |
+
api_key=settings.azureai_api_key_4o,
|
| 21 |
+
temperature=0
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
self.prompt = ChatPromptTemplate.from_messages([
|
| 25 |
+
("system", """You are an orchestrator agent. Analyze user's message and determine:
|
| 26 |
+
|
| 27 |
+
1. What is user's intent? (question, greeting, goodbye, other)
|
| 28 |
+
2. Do we need to search user's documents for relevant information?
|
| 29 |
+
3. If search is needed, what query should we use?
|
| 30 |
+
4. If no search needed, provide a direct response.
|
| 31 |
+
|
| 32 |
+
Respond in JSON format with these fields:
|
| 33 |
+
- intent: string (question, greeting, goodbye, other)
|
| 34 |
+
- needs_search: boolean
|
| 35 |
+
- search_query: string (if needed)
|
| 36 |
+
- direct_response: string (if no search needed)
|
| 37 |
+
|
| 38 |
+
Intent Routing:
|
| 39 |
+
- question -> needs_search=True, search_query=message
|
| 40 |
+
- greeting -> needs_search=False, direct_response="Hello! How can I assist you today?"
|
| 41 |
+
- goodbye -> needs_search=False, direct_response="Goodbye! Have a great day!"
|
| 42 |
+
- other -> needs_search=True, search_query=message
|
| 43 |
+
"""),
|
| 44 |
+
("user", "{message}")
|
| 45 |
+
])
|
| 46 |
+
|
| 47 |
+
self.chain = (
|
| 48 |
+
self.prompt
|
| 49 |
+
| self.llm
|
| 50 |
+
| JsonOutputParser()
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
async def analyze_message(self, message: str) -> dict:
|
| 54 |
+
"""Analyze user message and determine next actions."""
|
| 55 |
+
try:
|
| 56 |
+
logger.info(f"Analyzing message: {message[:50]}...")
|
| 57 |
+
|
| 58 |
+
result = await self.chain.ainvoke({"message": message})
|
| 59 |
+
|
| 60 |
+
logger.info(f"Intent: {result.get('intent')}, Needs search: {result.get('needs_search')}")
|
| 61 |
+
return result
|
| 62 |
+
|
| 63 |
+
except Exception as e:
|
| 64 |
+
logger.error("Message analysis failed", error=str(e))
|
| 65 |
+
# Fallback to treating everything as a question
|
| 66 |
+
return {
|
| 67 |
+
"intent": "question",
|
| 68 |
+
"needs_search": True,
|
| 69 |
+
"search_query": message,
|
| 70 |
+
"direct_response": None
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
orchestrator = OrchestratorAgent()
|
src/api/v1/__init__.py
ADDED
|
File without changes
|
src/api/v1/chat.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Chat endpoint with streaming support."""
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
from fastapi import APIRouter, Depends, HTTPException
|
| 5 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 6 |
+
from src.db.postgres.connection import get_db
|
| 7 |
+
from src.agents.orchestration import orchestrator
|
| 8 |
+
from src.agents.chatbot import chatbot
|
| 9 |
+
from src.rag.retriever import retriever
|
| 10 |
+
from src.db.redis.connection import get_redis
|
| 11 |
+
from src.config.settings import settings
|
| 12 |
+
from src.middlewares.logging import get_logger, log_execution
|
| 13 |
+
from sse_starlette.sse import EventSourceResponse
|
| 14 |
+
from langchain_core.messages import HumanMessage
|
| 15 |
+
from pydantic import BaseModel
|
| 16 |
+
from typing import List, Dict, Any, Optional
|
| 17 |
+
import json
|
| 18 |
+
|
| 19 |
+
_GREETINGS = frozenset(["hi", "hello", "hey", "halo", "hai", "hei"])
|
| 20 |
+
_GOODBYES = frozenset(["bye", "goodbye", "thanks", "thank you", "terima kasih", "sampai jumpa"])
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _fast_intent(message: str) -> Optional[dict]:
|
| 24 |
+
"""Bypass LLM orchestrator for obvious greetings and farewells."""
|
| 25 |
+
lower = message.lower().strip().rstrip("!.,?")
|
| 26 |
+
if lower in _GREETINGS:
|
| 27 |
+
return {"intent": "greeting", "needs_search": False,
|
| 28 |
+
"direct_response": "Hello! How can I assist you today?", "search_query": ""}
|
| 29 |
+
if lower in _GOODBYES:
|
| 30 |
+
return {"intent": "goodbye", "needs_search": False,
|
| 31 |
+
"direct_response": "Goodbye! Have a great day!", "search_query": ""}
|
| 32 |
+
return None
|
| 33 |
+
|
| 34 |
+
logger = get_logger("chat_api")
|
| 35 |
+
|
| 36 |
+
router = APIRouter(prefix="/api/v1", tags=["Chat"])
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class ChatRequest(BaseModel):
|
| 40 |
+
user_id: str
|
| 41 |
+
room_id: str
|
| 42 |
+
message: str
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _format_context(results: List[Dict[str, Any]]) -> str:
|
| 46 |
+
"""Format retrieval results as context string for the LLM."""
|
| 47 |
+
lines = []
|
| 48 |
+
for result in results:
|
| 49 |
+
filename = result["metadata"].get("filename", "Unknown")
|
| 50 |
+
page = result["metadata"].get("page_label")
|
| 51 |
+
source_label = f"{filename}, p.{page}" if page else filename
|
| 52 |
+
lines.append(f"[Source: {source_label}]\n{result['content']}\n")
|
| 53 |
+
return "\n".join(lines)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _extract_sources(results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
| 57 |
+
"""Extract deduplicated source references from retrieval results."""
|
| 58 |
+
seen = set()
|
| 59 |
+
sources = []
|
| 60 |
+
for result in results:
|
| 61 |
+
meta = result["metadata"]
|
| 62 |
+
key = (meta.get("document_id"), meta.get("page_label"))
|
| 63 |
+
if key not in seen:
|
| 64 |
+
seen.add(key)
|
| 65 |
+
sources.append({
|
| 66 |
+
"document_id": meta.get("document_id"),
|
| 67 |
+
"filename": meta.get("filename", "Unknown"),
|
| 68 |
+
"page_label": meta.get("page_label"),
|
| 69 |
+
})
|
| 70 |
+
return sources
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
async def get_cached_response(redis, cache_key: str) -> Optional[str]:
|
| 74 |
+
cached = await redis.get(cache_key)
|
| 75 |
+
if cached:
|
| 76 |
+
return json.loads(cached)
|
| 77 |
+
return None
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
async def cache_response(redis, cache_key: str, response: str):
|
| 81 |
+
await redis.setex(cache_key, 86400, json.dumps(response))
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
@router.post("/chat/stream")
|
| 85 |
+
@log_execution(logger)
|
| 86 |
+
async def chat_stream(request: ChatRequest, db: AsyncSession = Depends(get_db)):
|
| 87 |
+
"""Chat endpoint with streaming response.
|
| 88 |
+
|
| 89 |
+
SSE event sequence:
|
| 90 |
+
1. sources — JSON array of {document_id, filename, page_label}
|
| 91 |
+
2. chunk — text fragments of the answer
|
| 92 |
+
3. done — signals end of stream
|
| 93 |
+
"""
|
| 94 |
+
redis = await get_redis()
|
| 95 |
+
|
| 96 |
+
cache_key = f"{settings.redis_prefix}chat:{request.user_id}:{request.message}"
|
| 97 |
+
cached = await get_cached_response(redis, cache_key)
|
| 98 |
+
if cached:
|
| 99 |
+
logger.info("Returning cached response")
|
| 100 |
+
|
| 101 |
+
async def stream_cached():
|
| 102 |
+
yield {"event": "sources", "data": json.dumps([])}
|
| 103 |
+
for i in range(0, len(cached), 50):
|
| 104 |
+
yield {"event": "chunk", "data": cached[i:i + 50]}
|
| 105 |
+
yield {"event": "done", "data": ""}
|
| 106 |
+
|
| 107 |
+
return EventSourceResponse(stream_cached())
|
| 108 |
+
|
| 109 |
+
try:
|
| 110 |
+
# Step 1: Fast local intent check (skips LLM for greetings/farewells)
|
| 111 |
+
intent_result = _fast_intent(request.message)
|
| 112 |
+
|
| 113 |
+
context = ""
|
| 114 |
+
sources: List[Dict[str, Any]] = []
|
| 115 |
+
|
| 116 |
+
if intent_result is None:
|
| 117 |
+
# Step 2: Launch retrieval optimistically while orchestrator decides in parallel
|
| 118 |
+
retrieval_task = asyncio.create_task(
|
| 119 |
+
retriever.retrieve(request.message, request.user_id, db)
|
| 120 |
+
)
|
| 121 |
+
intent_result = await orchestrator.analyze_message(request.message)
|
| 122 |
+
|
| 123 |
+
if not intent_result.get("needs_search"):
|
| 124 |
+
retrieval_task.cancel()
|
| 125 |
+
raw_results = []
|
| 126 |
+
else:
|
| 127 |
+
search_query = intent_result.get("search_query", request.message)
|
| 128 |
+
logger.info(f"Searching for: {search_query}")
|
| 129 |
+
if search_query != request.message:
|
| 130 |
+
retrieval_task.cancel()
|
| 131 |
+
raw_results = await retriever.retrieve(
|
| 132 |
+
query=search_query,
|
| 133 |
+
user_id=request.user_id,
|
| 134 |
+
db=db,
|
| 135 |
+
)
|
| 136 |
+
else:
|
| 137 |
+
raw_results = await retrieval_task
|
| 138 |
+
|
| 139 |
+
context = _format_context(raw_results)
|
| 140 |
+
sources = _extract_sources(raw_results)
|
| 141 |
+
|
| 142 |
+
# Step 3: Direct response for greetings / non-document intents
|
| 143 |
+
if intent_result.get("direct_response"):
|
| 144 |
+
response = intent_result["direct_response"]
|
| 145 |
+
await cache_response(redis, cache_key, response)
|
| 146 |
+
|
| 147 |
+
async def stream_direct():
|
| 148 |
+
yield {"event": "sources", "data": json.dumps([])}
|
| 149 |
+
yield {"event": "message", "data": response}
|
| 150 |
+
|
| 151 |
+
return EventSourceResponse(stream_direct())
|
| 152 |
+
|
| 153 |
+
# Step 4: Stream answer token-by-token as LLM generates it
|
| 154 |
+
messages = [HumanMessage(content=request.message)]
|
| 155 |
+
|
| 156 |
+
async def stream_response():
|
| 157 |
+
full_response = ""
|
| 158 |
+
yield {"event": "sources", "data": json.dumps(sources)}
|
| 159 |
+
async for token in chatbot.astream_response(messages, context):
|
| 160 |
+
full_response += token
|
| 161 |
+
yield {"event": "chunk", "data": token}
|
| 162 |
+
yield {"event": "done", "data": ""}
|
| 163 |
+
await cache_response(redis, cache_key, full_response)
|
| 164 |
+
|
| 165 |
+
return EventSourceResponse(stream_response())
|
| 166 |
+
|
| 167 |
+
except Exception as e:
|
| 168 |
+
logger.error("Chat failed", error=str(e))
|
| 169 |
+
raise HTTPException(status_code=500, detail=f"Chat failed: {str(e)}")
|
src/api/v1/document.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Document management API endpoints."""
|
| 2 |
+
|
| 3 |
+
from fastapi import APIRouter, Depends, HTTPException, Request, UploadFile, File, status
|
| 4 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 5 |
+
from src.db.postgres.connection import get_db
|
| 6 |
+
from src.document.document_service import document_service
|
| 7 |
+
from src.knowledge.processing_service import knowledge_processor
|
| 8 |
+
from src.storage.az_blob.az_blob import blob_storage
|
| 9 |
+
from src.middlewares.logging import get_logger, log_execution
|
| 10 |
+
from src.middlewares.rate_limit import limiter
|
| 11 |
+
from pydantic import BaseModel
|
| 12 |
+
from typing import List
|
| 13 |
+
|
| 14 |
+
logger = get_logger("document_api")
|
| 15 |
+
|
| 16 |
+
router = APIRouter(prefix="/api/v1", tags=["Documents"])
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class DocumentResponse(BaseModel):
|
| 20 |
+
id: str
|
| 21 |
+
filename: str
|
| 22 |
+
status: str
|
| 23 |
+
file_size: int
|
| 24 |
+
file_type: str
|
| 25 |
+
created_at: str
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@router.get("/documents/{user_id}", response_model=List[DocumentResponse])
|
| 29 |
+
@log_execution(logger)
|
| 30 |
+
async def list_documents(
|
| 31 |
+
user_id: str,
|
| 32 |
+
db: AsyncSession = Depends(get_db)
|
| 33 |
+
):
|
| 34 |
+
"""List all documents for a user."""
|
| 35 |
+
documents = await document_service.get_user_documents(db, user_id)
|
| 36 |
+
return [
|
| 37 |
+
DocumentResponse(
|
| 38 |
+
id=doc.id,
|
| 39 |
+
filename=doc.filename,
|
| 40 |
+
status=doc.status,
|
| 41 |
+
file_size=doc.file_size or 0,
|
| 42 |
+
file_type=doc.file_type,
|
| 43 |
+
created_at=doc.created_at.isoformat()
|
| 44 |
+
)
|
| 45 |
+
for doc in documents
|
| 46 |
+
]
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
@router.post("/document/upload")
|
| 50 |
+
@limiter.limit("10/minute")
|
| 51 |
+
@log_execution(logger)
|
| 52 |
+
async def upload_document(
|
| 53 |
+
request: Request,
|
| 54 |
+
file: UploadFile = File(...),
|
| 55 |
+
user_id: str = None,
|
| 56 |
+
db: AsyncSession = Depends(get_db)
|
| 57 |
+
):
|
| 58 |
+
"""Upload a document."""
|
| 59 |
+
if not user_id:
|
| 60 |
+
raise HTTPException(
|
| 61 |
+
status_code=400,
|
| 62 |
+
detail="user_id is required"
|
| 63 |
+
)
|
| 64 |
+
|
| 65 |
+
try:
|
| 66 |
+
# Read file content
|
| 67 |
+
content = await file.read()
|
| 68 |
+
file_size = len(content)
|
| 69 |
+
|
| 70 |
+
# Get file type
|
| 71 |
+
filename = file.filename
|
| 72 |
+
file_type = filename.split('.')[-1].lower() if '.' in filename else 'txt'
|
| 73 |
+
|
| 74 |
+
if file_type not in ['pdf', 'docx', 'txt']:
|
| 75 |
+
raise HTTPException(
|
| 76 |
+
status_code=400,
|
| 77 |
+
detail="Unsupported file type. Supported: pdf, docx, txt"
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
# Upload to blob storage
|
| 81 |
+
blob_name = await blob_storage.upload_file(content, filename, user_id)
|
| 82 |
+
|
| 83 |
+
# Create document record
|
| 84 |
+
document = await document_service.create_document(
|
| 85 |
+
db=db,
|
| 86 |
+
user_id=user_id,
|
| 87 |
+
filename=filename,
|
| 88 |
+
blob_name=blob_name,
|
| 89 |
+
file_size=file_size,
|
| 90 |
+
file_type=file_type
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
return {
|
| 94 |
+
"status": "success",
|
| 95 |
+
"message": "Document uploaded successfully",
|
| 96 |
+
"data": {
|
| 97 |
+
"id": document.id,
|
| 98 |
+
"filename": document.filename,
|
| 99 |
+
"status": document.status
|
| 100 |
+
}
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
except Exception as e:
|
| 104 |
+
logger.error(f"Upload failed for user {user_id}", error=str(e))
|
| 105 |
+
raise HTTPException(
|
| 106 |
+
status_code=500,
|
| 107 |
+
detail=f"Upload failed: {str(e)}"
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
@router.delete("/document/delete")
|
| 112 |
+
@log_execution(logger)
|
| 113 |
+
async def delete_document(
|
| 114 |
+
document_id: str,
|
| 115 |
+
user_id: str,
|
| 116 |
+
db: AsyncSession = Depends(get_db)
|
| 117 |
+
):
|
| 118 |
+
"""Delete a document."""
|
| 119 |
+
document = await document_service.get_document(db, document_id)
|
| 120 |
+
|
| 121 |
+
if not document:
|
| 122 |
+
raise HTTPException(
|
| 123 |
+
status_code=404,
|
| 124 |
+
detail="Document not found"
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
if document.user_id != user_id:
|
| 128 |
+
raise HTTPException(
|
| 129 |
+
status_code=403,
|
| 130 |
+
detail="Access denied"
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
success = await document_service.delete_document(db, document_id)
|
| 134 |
+
|
| 135 |
+
if success:
|
| 136 |
+
return {"status": "success", "message": "Document deleted successfully"}
|
| 137 |
+
else:
|
| 138 |
+
raise HTTPException(
|
| 139 |
+
status_code=500,
|
| 140 |
+
detail="Failed to delete document"
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
@router.post("/document/process")
|
| 145 |
+
@log_execution(logger)
|
| 146 |
+
async def process_document(
|
| 147 |
+
document_id: str,
|
| 148 |
+
user_id: str,
|
| 149 |
+
db: AsyncSession = Depends(get_db)
|
| 150 |
+
):
|
| 151 |
+
"""Process document and ingest to vector index."""
|
| 152 |
+
document = await document_service.get_document(db, document_id)
|
| 153 |
+
|
| 154 |
+
if not document:
|
| 155 |
+
raise HTTPException(
|
| 156 |
+
status_code=404,
|
| 157 |
+
detail="Document not found"
|
| 158 |
+
)
|
| 159 |
+
|
| 160 |
+
if document.user_id != user_id:
|
| 161 |
+
raise HTTPException(
|
| 162 |
+
status_code=403,
|
| 163 |
+
detail="Access denied"
|
| 164 |
+
)
|
| 165 |
+
|
| 166 |
+
try:
|
| 167 |
+
# Update status to processing
|
| 168 |
+
await document_service.update_document_status(db, document_id, "processing")
|
| 169 |
+
|
| 170 |
+
# Process document
|
| 171 |
+
chunks_count = await knowledge_processor.process_document(document, db)
|
| 172 |
+
|
| 173 |
+
# Update status to completed
|
| 174 |
+
await document_service.update_document_status(db, document_id, "completed")
|
| 175 |
+
|
| 176 |
+
return {
|
| 177 |
+
"status": "success",
|
| 178 |
+
"message": "Document processed successfully",
|
| 179 |
+
"data": {
|
| 180 |
+
"document_id": document_id,
|
| 181 |
+
"chunks_processed": chunks_count
|
| 182 |
+
}
|
| 183 |
+
}
|
| 184 |
+
|
| 185 |
+
except Exception as e:
|
| 186 |
+
logger.error(f"Processing failed for document {document_id}", error=str(e))
|
| 187 |
+
await document_service.update_document_status(
|
| 188 |
+
db, document_id, "failed", str(e)
|
| 189 |
+
)
|
| 190 |
+
raise HTTPException(
|
| 191 |
+
status_code=500,
|
| 192 |
+
detail=f"Processing failed: {str(e)}"
|
| 193 |
+
)
|
src/api/v1/knowledge.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Knowledge base management API endpoints."""
|
| 2 |
+
|
| 3 |
+
from fastapi import APIRouter, Depends
|
| 4 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 5 |
+
from src.db.postgres.connection import get_db
|
| 6 |
+
from src.middlewares.logging import get_logger, log_execution
|
| 7 |
+
|
| 8 |
+
logger = get_logger("knowledge_api")
|
| 9 |
+
|
| 10 |
+
router = APIRouter(prefix="/api/v1", tags=["Knowledge"])
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@router.post("/knowledge/rebuild")
|
| 14 |
+
@log_execution(logger)
|
| 15 |
+
async def rebuild_vector_index(
|
| 16 |
+
user_id: str,
|
| 17 |
+
db: AsyncSession = Depends(get_db)
|
| 18 |
+
):
|
| 19 |
+
"""Rebuild vector index for a user (admin endpoint)."""
|
| 20 |
+
# This would re-process all documents
|
| 21 |
+
# For POC, we'll skip this complexity
|
| 22 |
+
return {
|
| 23 |
+
"status": "success",
|
| 24 |
+
"message": "Vector index rebuild initiated"
|
| 25 |
+
}
|
src/api/v1/room.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Room management API endpoints."""
|
| 2 |
+
|
| 3 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 4 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 5 |
+
from sqlalchemy import select
|
| 6 |
+
from src.db.postgres.connection import get_db
|
| 7 |
+
from src.db.postgres.models import Room
|
| 8 |
+
from src.middlewares.logging import get_logger, log_execution
|
| 9 |
+
from pydantic import BaseModel
|
| 10 |
+
from typing import List
|
| 11 |
+
from datetime import datetime
|
| 12 |
+
import uuid
|
| 13 |
+
|
| 14 |
+
logger = get_logger("room_api")
|
| 15 |
+
|
| 16 |
+
router = APIRouter(prefix="/api/v1", tags=["Rooms"])
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class RoomResponse(BaseModel):
|
| 20 |
+
id: str
|
| 21 |
+
title: str
|
| 22 |
+
created_at: str
|
| 23 |
+
updated_at: str | None
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class CreateRoomRequest(BaseModel):
|
| 27 |
+
user_id: str
|
| 28 |
+
title: str = "New Chat"
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@router.get("/rooms/{user_id}", response_model=List[RoomResponse])
|
| 32 |
+
@log_execution(logger)
|
| 33 |
+
async def list_rooms(
|
| 34 |
+
user_id: str,
|
| 35 |
+
db: AsyncSession = Depends(get_db)
|
| 36 |
+
):
|
| 37 |
+
"""List all rooms for a user."""
|
| 38 |
+
result = await db.execute(
|
| 39 |
+
select(Room)
|
| 40 |
+
.where(Room.user_id == user_id)
|
| 41 |
+
.order_by(Room.updated_at.desc())
|
| 42 |
+
)
|
| 43 |
+
rooms = result.scalars().all()
|
| 44 |
+
|
| 45 |
+
return [
|
| 46 |
+
RoomResponse(
|
| 47 |
+
id=room.id,
|
| 48 |
+
title=room.title,
|
| 49 |
+
created_at=room.created_at.isoformat(),
|
| 50 |
+
updated_at=room.updated_at.isoformat() if room.updated_at else None
|
| 51 |
+
)
|
| 52 |
+
for room in rooms
|
| 53 |
+
]
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
@router.get("/room/{room_id}", response_model=RoomResponse)
|
| 57 |
+
@log_execution(logger)
|
| 58 |
+
async def get_room(
|
| 59 |
+
room_id: str,
|
| 60 |
+
db: AsyncSession = Depends(get_db)
|
| 61 |
+
):
|
| 62 |
+
"""Get a specific room."""
|
| 63 |
+
result = await db.execute(
|
| 64 |
+
select(Room).where(Room.id == room_id)
|
| 65 |
+
)
|
| 66 |
+
room = result.scalars().first()
|
| 67 |
+
|
| 68 |
+
if not room:
|
| 69 |
+
raise HTTPException(
|
| 70 |
+
status_code=404,
|
| 71 |
+
detail="Room not found"
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
return RoomResponse(
|
| 75 |
+
id=room.id,
|
| 76 |
+
title=room.title,
|
| 77 |
+
created_at=room.created_at.isoformat(),
|
| 78 |
+
updated_at=room.updated_at.isoformat() if room.updated_at else None
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
@router.post("/room/create")
|
| 83 |
+
@log_execution(logger)
|
| 84 |
+
async def create_room(
|
| 85 |
+
request: CreateRoomRequest,
|
| 86 |
+
db: AsyncSession = Depends(get_db)
|
| 87 |
+
):
|
| 88 |
+
"""Create a new room."""
|
| 89 |
+
room = Room(
|
| 90 |
+
id=str(uuid.uuid4()),
|
| 91 |
+
user_id=request.user_id,
|
| 92 |
+
title=request.title
|
| 93 |
+
)
|
| 94 |
+
db.add(room)
|
| 95 |
+
await db.commit()
|
| 96 |
+
await db.refresh(room)
|
| 97 |
+
|
| 98 |
+
return {
|
| 99 |
+
"status": "success",
|
| 100 |
+
"message": "Room created successfully",
|
| 101 |
+
"data": RoomResponse(
|
| 102 |
+
id=room.id,
|
| 103 |
+
title=room.title,
|
| 104 |
+
created_at=room.created_at.isoformat(),
|
| 105 |
+
updated_at=None
|
| 106 |
+
)
|
| 107 |
+
}
|
src/api/v1/users.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pandas as pd
|
| 2 |
+
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from fastapi.responses import JSONResponse
|
| 5 |
+
from fastapi import APIRouter, HTTPException, status
|
| 6 |
+
from typing import Literal
|
| 7 |
+
from src.users.users import get_user, hash_password, verify_password
|
| 8 |
+
from src.middlewares.logging import get_logger
|
| 9 |
+
from pydantic import BaseModel
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class ILogin(BaseModel):
|
| 13 |
+
"""Login request model."""
|
| 14 |
+
email: str
|
| 15 |
+
password: str
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
logger = get_logger("users service")
|
| 19 |
+
|
| 20 |
+
router = APIRouter(
|
| 21 |
+
prefix="/api",
|
| 22 |
+
tags=["Users"],
|
| 23 |
+
)
|
| 24 |
+
|
| 25 |
+
from typing import Optional, Literal
|
| 26 |
+
|
| 27 |
+
@router.post(
|
| 28 |
+
"/login",
|
| 29 |
+
# response_model=IUserProfile,
|
| 30 |
+
summary="Login by email and password",
|
| 31 |
+
description="💡Authenticates a user with email and password (non hashed) from frontend and returns user data if successful."
|
| 32 |
+
)
|
| 33 |
+
async def login(payload: ILogin):
|
| 34 |
+
"""
|
| 35 |
+
Authenticates a user and returns their data if credentials are valid.
|
| 36 |
+
"""
|
| 37 |
+
try:
|
| 38 |
+
user_profile:dict | None= await get_user(payload.email)
|
| 39 |
+
except Exception as E:
|
| 40 |
+
print(f"❌ login error while fetching user: {E}")
|
| 41 |
+
# Return generic 500 to client
|
| 42 |
+
raise HTTPException(
|
| 43 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 44 |
+
detail="Internal server error"
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
if not user_profile:
|
| 48 |
+
# 404 or 401 – choose based on your security policy
|
| 49 |
+
raise HTTPException(
|
| 50 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 51 |
+
detail="Email not found"
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
user_profile.pop("_id", None)
|
| 55 |
+
|
| 56 |
+
is_verified = verify_password(
|
| 57 |
+
password=payload.password,
|
| 58 |
+
hashed_password=user_profile.get("password")
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
if not is_verified:
|
| 62 |
+
raise HTTPException(
|
| 63 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 64 |
+
detail="Email or password invalid"
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
user_profile.pop("password", None)
|
| 68 |
+
|
| 69 |
+
return {
|
| 70 |
+
"status": "success",
|
| 71 |
+
"message": "success",
|
| 72 |
+
"data": user_profile,
|
| 73 |
+
}
|
| 74 |
+
|
src/config/__init__.py
ADDED
|
File without changes
|
src/config/agents/guardrails_prompt.md
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
You must ensure all responses follow these guidelines:
|
| 2 |
+
|
| 3 |
+
1. Do not provide harmful, illegal, or dangerous information
|
| 4 |
+
2. Respect user privacy - don't ask for or store sensitive personal data
|
| 5 |
+
3. If asked to bypass safety measures, refuse politely
|
| 6 |
+
4. Be honest about limitations and uncertainties
|
| 7 |
+
5. Don't make up information - admit when you don't know something
|
src/config/agents/system_prompt.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
You are a helpful AI assistant with access to user's uploaded documents. Your role is to:
|
| 2 |
+
|
| 3 |
+
1. Answer questions based on provided document context
|
| 4 |
+
2. If no relevant information is found in documents, acknowledge this honestly
|
| 5 |
+
3. Be concise and direct in your responses
|
| 6 |
+
4. Cite source documents when providing information
|
| 7 |
+
5. If user's question is unclear, ask for clarification
|
| 8 |
+
|
| 9 |
+
When document context is provided:
|
| 10 |
+
- Use information from documents to answer accurately
|
| 11 |
+
- Reference source document name when appropriate
|
| 12 |
+
- If multiple documents contain relevant info, synthesize information
|
| 13 |
+
|
| 14 |
+
When no document context is provided:
|
| 15 |
+
- Provide general assistance
|
| 16 |
+
- Let the user know if you need more context to help better
|
| 17 |
+
|
| 18 |
+
Always be professional, helpful, and accurate.
|
src/config/env_constant.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Environment file path constants for existing users.py."""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class EnvFilepath:
|
| 7 |
+
"""Environment file path constants."""
|
| 8 |
+
|
| 9 |
+
ENVPATH = ".env"
|
src/config/settings.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Centralized configuration management using pydantic-settings."""
|
| 2 |
+
|
| 3 |
+
import os
|
| 4 |
+
from typing import Optional
|
| 5 |
+
from pydantic import Field
|
| 6 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class Settings(BaseSettings):
|
| 10 |
+
"""Application settings loaded from environment variables."""
|
| 11 |
+
|
| 12 |
+
model_config = SettingsConfigDict(
|
| 13 |
+
env_file=".env",
|
| 14 |
+
env_file_encoding="utf-8",
|
| 15 |
+
extra="allow",
|
| 16 |
+
case_sensitive=False,
|
| 17 |
+
)
|
| 18 |
+
|
| 19 |
+
# Database
|
| 20 |
+
postgres_connstring: str
|
| 21 |
+
|
| 22 |
+
# Redis
|
| 23 |
+
redis_url: str
|
| 24 |
+
redis_prefix: str = "dataeyond-agent-service_"
|
| 25 |
+
|
| 26 |
+
# Azure OpenAI - GPT-4o (map to .env names with double underscores)
|
| 27 |
+
azureai_api_key_4o: str = Field(alias="azureai__api_key__4o", default="")
|
| 28 |
+
azureai_endpoint_url_4o: str = Field(alias="azureai__endpoint__url__4o", default="")
|
| 29 |
+
azureai_deployment_name_4o: str = Field(alias="azureai__deployment__name__4o", default="")
|
| 30 |
+
azureai_api_version_4o: str = Field(alias="azureai__api__version__4o", default="")
|
| 31 |
+
|
| 32 |
+
# Azure OpenAI - Embeddings
|
| 33 |
+
azureai_api_key_embedding: str = Field(alias="azureai__api_key__embedding", default="")
|
| 34 |
+
azureai_endpoint_url_embedding: str = Field(alias="azureai__endpoint__url__embedding", default="")
|
| 35 |
+
azureai_deployment_name_embedding: str = Field(alias="azureai__deployment__name__embedding", default="")
|
| 36 |
+
azureai_api_version_embedding: str = Field(alias="azureai__api__version__embedding", default="")
|
| 37 |
+
|
| 38 |
+
# Azure Document Intelligence
|
| 39 |
+
azureai_docintel_endpoint: str = Field(alias="azureai__docintel__endpoint", default="")
|
| 40 |
+
azureai_docintel_key: str = Field(alias="azureai__docintel__key", default="")
|
| 41 |
+
|
| 42 |
+
# Azure Blob Storage
|
| 43 |
+
azureai_blob_sas: str = Field(alias="azureai__blob__sas", default="")
|
| 44 |
+
azureai_container_endpoint: str = Field(alias="azureai__container__endpoint", default="")
|
| 45 |
+
azureai_container_name: str = Field(alias="azureai__container__name", default="")
|
| 46 |
+
azureai_container_account_name: str = Field(alias="azureai__container__account__name", default="")
|
| 47 |
+
|
| 48 |
+
# Langfuse
|
| 49 |
+
LANGFUSE_PUBLIC_KEY: str
|
| 50 |
+
LANGFUSE_SECRET_KEY: str
|
| 51 |
+
LANGFUSE_HOST: str
|
| 52 |
+
|
| 53 |
+
# MongoDB (for users - existing)
|
| 54 |
+
emarcal_mongo_endpoint_url: str = Field(alias="emarcal--mongo--endpoint--url", default="")
|
| 55 |
+
emarcal_buma_mongo_dbname: str = Field(alias="emarcal--buma--mongo--dbname", default="")
|
| 56 |
+
|
| 57 |
+
# JWT (for users - existing)
|
| 58 |
+
emarcal_jwt_secret_key: str = Field(alias="emarcal--jwt--secret-key", default="")
|
| 59 |
+
emarcal_jwt_algorithm: str = Field(alias="emarcal--jwt--algorithm", default="HS256")
|
| 60 |
+
|
| 61 |
+
# Bcrypt salt (for users - existing)
|
| 62 |
+
emarcal_bcrypt_salt: str = Field(alias="emarcal--bcrypt--salt", default="")
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
# Singleton instance
|
| 66 |
+
settings = Settings()
|
| 67 |
+
|
src/db/postgres/__init__.py
ADDED
|
File without changes
|
src/db/postgres/connection.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Async PostgreSQL connection management."""
|
| 2 |
+
|
| 3 |
+
from sqlalchemy.engine import make_url
|
| 4 |
+
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
|
| 5 |
+
from sqlalchemy.orm import declarative_base
|
| 6 |
+
from src.config.settings import settings
|
| 7 |
+
|
| 8 |
+
# asyncpg doesn't support libpq query params like sslmode/channel_binding.
|
| 9 |
+
# Use SQLAlchemy's URL parser to strip all query params cleanly, then pass ssl via connect_args.
|
| 10 |
+
_url = make_url(settings.postgres_connstring).set(drivername="postgresql+asyncpg", query={})
|
| 11 |
+
|
| 12 |
+
# Separate asyncpg engine for PGVector with prepared_statement_cache_size=0.
|
| 13 |
+
# PGVector runs advisory_lock + CREATE EXTENSION as a single multi-statement string.
|
| 14 |
+
# asyncpg normally uses prepared statements which reject multi-statement SQL.
|
| 15 |
+
# Setting cache_size=0 forces asyncpg to use execute() instead of prepare(),
|
| 16 |
+
# which supports multiple statements — no psycopg3 needed, no ProactorEventLoop issue.
|
| 17 |
+
_pgvector_engine = create_async_engine(
|
| 18 |
+
_url,
|
| 19 |
+
pool_pre_ping=True,
|
| 20 |
+
connect_args={
|
| 21 |
+
"ssl": "require",
|
| 22 |
+
"prepared_statement_cache_size": 0,
|
| 23 |
+
},
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
engine = create_async_engine(
|
| 27 |
+
_url,
|
| 28 |
+
echo=False,
|
| 29 |
+
pool_pre_ping=True,
|
| 30 |
+
pool_size=5,
|
| 31 |
+
max_overflow=10,
|
| 32 |
+
connect_args={"ssl": "require"},
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
AsyncSessionLocal = async_sessionmaker(
|
| 36 |
+
engine,
|
| 37 |
+
class_=AsyncSession,
|
| 38 |
+
expire_on_commit=False,
|
| 39 |
+
autocommit=False,
|
| 40 |
+
autoflush=False
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
Base = declarative_base()
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
async def get_db():
|
| 47 |
+
"""Get database session dependency for FastAPI."""
|
| 48 |
+
async with AsyncSessionLocal() as session:
|
| 49 |
+
try:
|
| 50 |
+
yield session
|
| 51 |
+
finally:
|
| 52 |
+
await session.close()
|
src/db/postgres/init_db.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Database initialization."""
|
| 2 |
+
|
| 3 |
+
from sqlalchemy import text
|
| 4 |
+
from src.db.postgres.connection import engine, Base
|
| 5 |
+
from src.db.postgres.models import Document, Room, ChatMessage
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
async def init_db():
|
| 9 |
+
"""Initialize database tables and required extensions."""
|
| 10 |
+
async with engine.begin() as conn:
|
| 11 |
+
# Create pgvector extension using two separate statements.
|
| 12 |
+
# Must NOT be combined into one string — asyncpg rejects multi-statement
|
| 13 |
+
# prepared statements (langchain_postgres bug workaround via create_extension=False).
|
| 14 |
+
await conn.execute(text("SELECT pg_advisory_xact_lock(1573678846307946496)"))
|
| 15 |
+
await conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
|
| 16 |
+
|
| 17 |
+
# Create application tables
|
| 18 |
+
await conn.run_sync(Base.metadata.create_all)
|
src/db/postgres/models.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SQLAlchemy database models."""
|
| 2 |
+
|
| 3 |
+
from uuid import uuid4
|
| 4 |
+
from sqlalchemy import Column, String, DateTime, Text, Integer, ForeignKey
|
| 5 |
+
from sqlalchemy.orm import relationship
|
| 6 |
+
from sqlalchemy.sql import func
|
| 7 |
+
from src.db.postgres.connection import Base
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
# Note: Users are managed in MongoDB (src/users/users.py).
|
| 11 |
+
# user_id columns here are plain strings — no FK to a postgres users table.
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class Document(Base):
|
| 15 |
+
"""Document model."""
|
| 16 |
+
__tablename__ = "documents"
|
| 17 |
+
|
| 18 |
+
id = Column(String, primary_key=True, default=lambda: str(uuid4()))
|
| 19 |
+
user_id = Column(String, nullable=False, index=True)
|
| 20 |
+
filename = Column(String, nullable=False)
|
| 21 |
+
blob_name = Column(String, nullable=False, unique=True)
|
| 22 |
+
file_size = Column(Integer)
|
| 23 |
+
file_type = Column(String) # pdf, docx, txt, etc.
|
| 24 |
+
status = Column(String, default="uploaded") # uploaded, processing, completed, failed
|
| 25 |
+
processed_at = Column(DateTime(timezone=True))
|
| 26 |
+
error_message = Column(Text)
|
| 27 |
+
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class Room(Base):
|
| 31 |
+
"""Room model for chat sessions."""
|
| 32 |
+
__tablename__ = "rooms"
|
| 33 |
+
|
| 34 |
+
id = Column(String, primary_key=True, default=lambda: str(uuid4()))
|
| 35 |
+
user_id = Column(String, nullable=False, index=True)
|
| 36 |
+
title = Column(String, default="New Chat")
|
| 37 |
+
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
| 38 |
+
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
| 39 |
+
|
| 40 |
+
messages = relationship("ChatMessage", back_populates="room", cascade="all, delete-orphan")
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class ChatMessage(Base):
|
| 44 |
+
"""Chat message model."""
|
| 45 |
+
__tablename__ = "chat_messages"
|
| 46 |
+
|
| 47 |
+
id = Column(String, primary_key=True, default=lambda: str(uuid4()))
|
| 48 |
+
room_id = Column(String, ForeignKey("rooms.id"), nullable=False, index=True)
|
| 49 |
+
role = Column(String, nullable=False) # user, assistant
|
| 50 |
+
content = Column(Text, nullable=False)
|
| 51 |
+
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
| 52 |
+
|
| 53 |
+
room = relationship("Room", back_populates="messages")
|
src/db/postgres/vector_store.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PGVector store setup for document embeddings."""
|
| 2 |
+
|
| 3 |
+
from langchain_postgres import PGVector
|
| 4 |
+
from langchain_openai import AzureOpenAIEmbeddings
|
| 5 |
+
from src.config.settings import settings
|
| 6 |
+
from src.db.postgres.connection import _pgvector_engine
|
| 7 |
+
|
| 8 |
+
# Initialize embeddings
|
| 9 |
+
embeddings = AzureOpenAIEmbeddings(
|
| 10 |
+
azure_deployment=settings.azureai_deployment_name_embedding,
|
| 11 |
+
openai_api_version=settings.azureai_api_version_embedding,
|
| 12 |
+
azure_endpoint=settings.azureai_endpoint_url_embedding,
|
| 13 |
+
api_key=settings.azureai_api_key_embedding
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
# Use psycopg3 connection string (not asyncpg engine) with async_mode=True.
|
| 17 |
+
# psycopg3 supports multi-statement SQL, which PGVector needs for
|
| 18 |
+
# advisory_lock + CREATE EXTENSION vector. asyncpg rejects this as a prepared statement.
|
| 19 |
+
vector_store = PGVector(
|
| 20 |
+
embeddings=embeddings,
|
| 21 |
+
connection=_pgvector_engine,
|
| 22 |
+
collection_name="document_embeddings",
|
| 23 |
+
use_jsonb=True,
|
| 24 |
+
async_mode=True,
|
| 25 |
+
create_extension=False, # Extension pre-created in init_db.py (avoids multi-statement asyncpg bug)
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def get_vector_store():
|
| 30 |
+
"""Get the vector store instance."""
|
| 31 |
+
return vector_store
|
src/db/redis/__init__.py
ADDED
|
File without changes
|
src/db/redis/connection.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Redis connection for caching."""
|
| 2 |
+
|
| 3 |
+
import redis.asyncio as redis
|
| 4 |
+
from src.config.settings import settings
|
| 5 |
+
|
| 6 |
+
redis_client = redis.from_url(
|
| 7 |
+
settings.redis_url,
|
| 8 |
+
encoding="utf-8",
|
| 9 |
+
decode_responses=True,
|
| 10 |
+
ssl_cert_reqs=None
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
async def get_redis():
|
| 15 |
+
"""Get Redis client."""
|
| 16 |
+
return redis_client
|
src/document/__init__.py
ADDED
|
File without changes
|
src/document/document_service.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Service for managing documents."""
|
| 2 |
+
|
| 3 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 4 |
+
from sqlalchemy import select, delete
|
| 5 |
+
from src.db.postgres.models import Document
|
| 6 |
+
from src.storage.az_blob.az_blob import blob_storage
|
| 7 |
+
from src.middlewares.logging import get_logger
|
| 8 |
+
from typing import List, Optional
|
| 9 |
+
from datetime import datetime
|
| 10 |
+
|
| 11 |
+
logger = get_logger("document_service")
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class DocumentService:
|
| 15 |
+
"""Service for managing documents."""
|
| 16 |
+
|
| 17 |
+
async def create_document(
|
| 18 |
+
self,
|
| 19 |
+
db: AsyncSession,
|
| 20 |
+
user_id: str,
|
| 21 |
+
filename: str,
|
| 22 |
+
blob_name: str,
|
| 23 |
+
file_size: int,
|
| 24 |
+
file_type: str
|
| 25 |
+
) -> Document:
|
| 26 |
+
"""Create a new document record."""
|
| 27 |
+
import uuid
|
| 28 |
+
document = Document(
|
| 29 |
+
id=str(uuid.uuid4()),
|
| 30 |
+
user_id=user_id,
|
| 31 |
+
filename=filename,
|
| 32 |
+
blob_name=blob_name,
|
| 33 |
+
file_size=file_size,
|
| 34 |
+
file_type=file_type,
|
| 35 |
+
status="uploaded"
|
| 36 |
+
)
|
| 37 |
+
db.add(document)
|
| 38 |
+
await db.commit()
|
| 39 |
+
await db.refresh(document)
|
| 40 |
+
logger.info(f"Created document {document.id} for user {user_id}")
|
| 41 |
+
return document
|
| 42 |
+
|
| 43 |
+
async def get_user_documents(
|
| 44 |
+
self,
|
| 45 |
+
db: AsyncSession,
|
| 46 |
+
user_id: str
|
| 47 |
+
) -> List[Document]:
|
| 48 |
+
"""Get all documents for a user."""
|
| 49 |
+
result = await db.execute(
|
| 50 |
+
select(Document)
|
| 51 |
+
.where(Document.user_id == user_id)
|
| 52 |
+
.order_by(Document.created_at.desc())
|
| 53 |
+
)
|
| 54 |
+
return result.scalars().all()
|
| 55 |
+
|
| 56 |
+
async def get_document(
|
| 57 |
+
self,
|
| 58 |
+
db: AsyncSession,
|
| 59 |
+
document_id: str
|
| 60 |
+
) -> Optional[Document]:
|
| 61 |
+
"""Get a specific document."""
|
| 62 |
+
result = await db.execute(
|
| 63 |
+
select(Document).where(Document.id == document_id)
|
| 64 |
+
)
|
| 65 |
+
return result.scalars().first()
|
| 66 |
+
|
| 67 |
+
async def delete_document(
|
| 68 |
+
self,
|
| 69 |
+
db: AsyncSession,
|
| 70 |
+
document_id: str
|
| 71 |
+
) -> bool:
|
| 72 |
+
"""Delete a document (from DB and Blob storage)."""
|
| 73 |
+
document = await self.get_document(db, document_id)
|
| 74 |
+
if not document:
|
| 75 |
+
return False
|
| 76 |
+
|
| 77 |
+
# Delete from blob storage
|
| 78 |
+
await blob_storage.delete_file(document.blob_name)
|
| 79 |
+
|
| 80 |
+
# Delete from database
|
| 81 |
+
await db.execute(
|
| 82 |
+
delete(Document).where(Document.id == document_id)
|
| 83 |
+
)
|
| 84 |
+
await db.commit()
|
| 85 |
+
|
| 86 |
+
logger.info(f"Deleted document {document_id}")
|
| 87 |
+
return True
|
| 88 |
+
|
| 89 |
+
async def update_document_status(
|
| 90 |
+
self,
|
| 91 |
+
db: AsyncSession,
|
| 92 |
+
document_id: str,
|
| 93 |
+
status: str,
|
| 94 |
+
error_message: Optional[str] = None
|
| 95 |
+
) -> Document:
|
| 96 |
+
"""Update document processing status."""
|
| 97 |
+
document = await self.get_document(db, document_id)
|
| 98 |
+
if document:
|
| 99 |
+
document.status = status
|
| 100 |
+
document.processed_at = datetime.utcnow()
|
| 101 |
+
document.error_message = error_message
|
| 102 |
+
await db.commit()
|
| 103 |
+
await db.refresh(document)
|
| 104 |
+
logger.info(f"Updated document {document_id} status to {status}")
|
| 105 |
+
return document
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
document_service = DocumentService()
|
src/knowledge/__init__.py
ADDED
|
File without changes
|
src/knowledge/processing_service.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Service for processing documents and ingesting to vector store."""
|
| 2 |
+
|
| 3 |
+
from langchain.text_splitter import RecursiveCharacterTextSplitter
|
| 4 |
+
from langchain_core.documents import Document as LangChainDocument
|
| 5 |
+
from src.db.postgres.vector_store import get_vector_store
|
| 6 |
+
from src.storage.az_blob.az_blob import blob_storage
|
| 7 |
+
from src.db.postgres.models import Document as DBDocument
|
| 8 |
+
from src.config.settings import settings
|
| 9 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 10 |
+
from src.middlewares.logging import get_logger
|
| 11 |
+
from azure.ai.documentintelligence.aio import DocumentIntelligenceClient
|
| 12 |
+
from azure.core.credentials import AzureKeyCredential
|
| 13 |
+
from typing import List
|
| 14 |
+
import pypdf
|
| 15 |
+
import docx
|
| 16 |
+
from io import BytesIO
|
| 17 |
+
|
| 18 |
+
logger = get_logger("knowledge_processing")
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class KnowledgeProcessingService:
|
| 22 |
+
"""Service for processing documents and ingesting to vector store."""
|
| 23 |
+
|
| 24 |
+
def __init__(self):
|
| 25 |
+
self.text_splitter = RecursiveCharacterTextSplitter(
|
| 26 |
+
chunk_size=1000,
|
| 27 |
+
chunk_overlap=200,
|
| 28 |
+
length_function=len
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
async def process_document(self, db_doc: DBDocument, db: AsyncSession) -> int:
|
| 32 |
+
"""Process document and ingest to vector store.
|
| 33 |
+
|
| 34 |
+
Returns:
|
| 35 |
+
Number of chunks ingested
|
| 36 |
+
"""
|
| 37 |
+
try:
|
| 38 |
+
logger.info(f"Processing document {db_doc.id}")
|
| 39 |
+
content = await blob_storage.download_file(db_doc.blob_name)
|
| 40 |
+
|
| 41 |
+
if db_doc.file_type == "pdf":
|
| 42 |
+
documents = await self._build_pdf_documents(content, db_doc)
|
| 43 |
+
else:
|
| 44 |
+
text = self._extract_text(content, db_doc.file_type)
|
| 45 |
+
if not text.strip():
|
| 46 |
+
raise ValueError("No text extracted from document")
|
| 47 |
+
chunks = self.text_splitter.split_text(text)
|
| 48 |
+
documents = [
|
| 49 |
+
LangChainDocument(
|
| 50 |
+
page_content=chunk,
|
| 51 |
+
metadata={
|
| 52 |
+
"document_id": db_doc.id,
|
| 53 |
+
"user_id": db_doc.user_id,
|
| 54 |
+
"filename": db_doc.filename,
|
| 55 |
+
"chunk_index": i,
|
| 56 |
+
}
|
| 57 |
+
)
|
| 58 |
+
for i, chunk in enumerate(chunks)
|
| 59 |
+
]
|
| 60 |
+
|
| 61 |
+
if not documents:
|
| 62 |
+
raise ValueError("No text extracted from document")
|
| 63 |
+
|
| 64 |
+
vector_store = get_vector_store()
|
| 65 |
+
await vector_store.aadd_documents(documents)
|
| 66 |
+
|
| 67 |
+
logger.info(f"Processed {db_doc.id}: {len(documents)} chunks ingested")
|
| 68 |
+
return len(documents)
|
| 69 |
+
|
| 70 |
+
except Exception as e:
|
| 71 |
+
logger.error(f"Failed to process document {db_doc.id}", error=str(e))
|
| 72 |
+
raise
|
| 73 |
+
|
| 74 |
+
async def _build_pdf_documents(
|
| 75 |
+
self, content: bytes, db_doc: DBDocument
|
| 76 |
+
) -> List[LangChainDocument]:
|
| 77 |
+
"""Build LangChain documents from PDF with page_label metadata.
|
| 78 |
+
|
| 79 |
+
Uses Azure Document Intelligence (per-page) when credentials are present,
|
| 80 |
+
falls back to pypdf (also per-page) otherwise.
|
| 81 |
+
"""
|
| 82 |
+
documents: List[LangChainDocument] = []
|
| 83 |
+
|
| 84 |
+
if settings.azureai_docintel_endpoint and settings.azureai_docintel_key:
|
| 85 |
+
async with DocumentIntelligenceClient(
|
| 86 |
+
endpoint=settings.azureai_docintel_endpoint,
|
| 87 |
+
credential=AzureKeyCredential(settings.azureai_docintel_key),
|
| 88 |
+
) as client:
|
| 89 |
+
poller = await client.begin_analyze_document(
|
| 90 |
+
model_id="prebuilt-read",
|
| 91 |
+
body=BytesIO(content),
|
| 92 |
+
content_type="application/pdf",
|
| 93 |
+
)
|
| 94 |
+
result = await poller.result()
|
| 95 |
+
logger.info(f"Azure DI extracted {len(result.pages or [])} pages")
|
| 96 |
+
|
| 97 |
+
for page in result.pages or []:
|
| 98 |
+
page_text = "\n".join(
|
| 99 |
+
line.content for line in (page.lines or [])
|
| 100 |
+
)
|
| 101 |
+
if not page_text.strip():
|
| 102 |
+
continue
|
| 103 |
+
for chunk in self.text_splitter.split_text(page_text):
|
| 104 |
+
documents.append(LangChainDocument(
|
| 105 |
+
page_content=chunk,
|
| 106 |
+
metadata={
|
| 107 |
+
"document_id": db_doc.id,
|
| 108 |
+
"user_id": db_doc.user_id,
|
| 109 |
+
"filename": db_doc.filename,
|
| 110 |
+
"chunk_index": len(documents),
|
| 111 |
+
"page_label": page.page_number,
|
| 112 |
+
}
|
| 113 |
+
))
|
| 114 |
+
else:
|
| 115 |
+
logger.warning("Azure DI not configured, using pypdf")
|
| 116 |
+
pdf_reader = pypdf.PdfReader(BytesIO(content))
|
| 117 |
+
for page_num, page in enumerate(pdf_reader.pages, start=1):
|
| 118 |
+
page_text = page.extract_text() or ""
|
| 119 |
+
if not page_text.strip():
|
| 120 |
+
continue
|
| 121 |
+
for chunk in self.text_splitter.split_text(page_text):
|
| 122 |
+
documents.append(LangChainDocument(
|
| 123 |
+
page_content=chunk,
|
| 124 |
+
metadata={
|
| 125 |
+
"document_id": db_doc.id,
|
| 126 |
+
"user_id": db_doc.user_id,
|
| 127 |
+
"filename": db_doc.filename,
|
| 128 |
+
"chunk_index": len(documents),
|
| 129 |
+
"page_label": page_num,
|
| 130 |
+
}
|
| 131 |
+
))
|
| 132 |
+
|
| 133 |
+
return documents
|
| 134 |
+
|
| 135 |
+
def _extract_text(self, content: bytes, file_type: str) -> str:
|
| 136 |
+
"""Extract text from DOCX or TXT content."""
|
| 137 |
+
if file_type == "docx":
|
| 138 |
+
doc = docx.Document(BytesIO(content))
|
| 139 |
+
return "\n".join(p.text for p in doc.paragraphs)
|
| 140 |
+
elif file_type == "txt":
|
| 141 |
+
return content.decode("utf-8")
|
| 142 |
+
else:
|
| 143 |
+
raise ValueError(f"Unsupported file type: {file_type}")
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
knowledge_processor = KnowledgeProcessingService()
|
src/middlewares/__init__.py
ADDED
|
File without changes
|
src/middlewares/cors.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CORS middleware configuration."""
|
| 2 |
+
|
| 3 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def add_cors_middleware(app):
|
| 7 |
+
"""Add CORS middleware to allow all origins for POC."""
|
| 8 |
+
app.add_middleware(
|
| 9 |
+
CORSMiddleware,
|
| 10 |
+
allow_origins=["*"], # For POC - allow all
|
| 11 |
+
allow_credentials=True,
|
| 12 |
+
allow_methods=["*"],
|
| 13 |
+
allow_headers=["*"],
|
| 14 |
+
)
|
src/middlewares/logging.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Structured logging middleware with structlog."""
|
| 2 |
+
|
| 3 |
+
import structlog
|
| 4 |
+
from functools import wraps
|
| 5 |
+
from typing import Callable, Any
|
| 6 |
+
import time
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def configure_logging():
|
| 10 |
+
"""Configure structured logging."""
|
| 11 |
+
structlog.configure(
|
| 12 |
+
processors=[
|
| 13 |
+
structlog.stdlib.filter_by_level,
|
| 14 |
+
structlog.stdlib.add_logger_name,
|
| 15 |
+
structlog.stdlib.add_log_level,
|
| 16 |
+
structlog.stdlib.PositionalArgumentsFormatter(),
|
| 17 |
+
structlog.processors.TimeStamper(fmt="iso"),
|
| 18 |
+
structlog.processors.StackInfoRenderer(),
|
| 19 |
+
structlog.processors.format_exc_info,
|
| 20 |
+
structlog.processors.UnicodeDecoder(),
|
| 21 |
+
structlog.processors.JSONRenderer()
|
| 22 |
+
],
|
| 23 |
+
context_class=dict,
|
| 24 |
+
logger_factory=structlog.stdlib.LoggerFactory(),
|
| 25 |
+
cache_logger_on_first_use=True,
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def get_logger(name: str) -> structlog.stdlib.BoundLogger:
|
| 30 |
+
"""Get a configured logger."""
|
| 31 |
+
return structlog.get_logger(name)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def log_execution(logger: structlog.stdlib.BoundLogger):
|
| 35 |
+
"""Decorator to log function execution."""
|
| 36 |
+
def decorator(func: Callable) -> Callable:
|
| 37 |
+
@wraps(func)
|
| 38 |
+
async def async_wrapper(*args, **kwargs) -> Any:
|
| 39 |
+
start_time = time.time()
|
| 40 |
+
logger.info(f"Starting {func.__name__}")
|
| 41 |
+
try:
|
| 42 |
+
result = await func(*args, **kwargs)
|
| 43 |
+
duration = time.time() - start_time
|
| 44 |
+
logger.info(f"Completed {func.__name__}", duration=duration)
|
| 45 |
+
return result
|
| 46 |
+
except Exception as e:
|
| 47 |
+
duration = time.time() - start_time
|
| 48 |
+
logger.error(f"Error in {func.__name__}", error=str(e), duration=duration)
|
| 49 |
+
raise
|
| 50 |
+
|
| 51 |
+
@wraps(func)
|
| 52 |
+
def sync_wrapper(*args, **kwargs) -> Any:
|
| 53 |
+
start_time = time.time()
|
| 54 |
+
logger.info(f"Starting {func.__name__}")
|
| 55 |
+
try:
|
| 56 |
+
result = func(*args, **kwargs)
|
| 57 |
+
duration = time.time() - start_time
|
| 58 |
+
logger.info(f"Completed {func.__name__}", duration=duration)
|
| 59 |
+
return result
|
| 60 |
+
except Exception as e:
|
| 61 |
+
duration = time.time() - start_time
|
| 62 |
+
logger.error(f"Error in {func.__name__}", error=str(e), duration=duration)
|
| 63 |
+
raise
|
| 64 |
+
|
| 65 |
+
return async_wrapper if hasattr(func, '__call__') and hasattr(func, '__code__') and func.__code__.co_flags & 0x80 else sync_wrapper
|
| 66 |
+
return decorator
|
src/middlewares/rate_limit.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Rate limiting middleware using slowapi."""
|
| 2 |
+
|
| 3 |
+
from slowapi import Limiter, _rate_limit_exceeded_handler
|
| 4 |
+
from slowapi.util import get_remote_address
|
| 5 |
+
from slowapi.errors import RateLimitExceeded
|
| 6 |
+
from fastapi import Request
|
| 7 |
+
|
| 8 |
+
limiter = Limiter(key_func=get_remote_address)
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def get_user_id_from_request(request: Request) -> str:
|
| 12 |
+
"""Extract user ID from request for rate limiting."""
|
| 13 |
+
# For document upload, use user_id if available, otherwise IP
|
| 14 |
+
user_id = request.headers.get("X-User-ID")
|
| 15 |
+
if user_id:
|
| 16 |
+
return user_id
|
| 17 |
+
return get_remote_address(request)
|
src/models/__init__.py
ADDED
|
File without changes
|
src/models/security.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Security models for password validation."""
|
| 2 |
+
|
| 3 |
+
from pydantic import BaseModel
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class ValidatePassword(BaseModel):
|
| 7 |
+
"""Password validation response."""
|
| 8 |
+
status: int
|
| 9 |
+
data: bool
|
| 10 |
+
error: str | None
|
src/models/states.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""LangGraph state definitions for agent workflows."""
|
| 2 |
+
|
| 3 |
+
from typing import TypedDict, List, Annotated, Optional
|
| 4 |
+
from langgraph.graph.message import add_messages
|
| 5 |
+
from langchain_core.messages import BaseMessage
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class AgentState(TypedDict):
|
| 9 |
+
"""State for agent graph."""
|
| 10 |
+
messages: Annotated[List[BaseMessage], add_messages]
|
| 11 |
+
user_id: str
|
| 12 |
+
room_id: str
|
| 13 |
+
retrieved_docs: List[dict]
|
| 14 |
+
needs_search: bool
|
src/models/structured_output.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Structured output models for LLM."""
|
| 2 |
+
|
| 3 |
+
from pydantic import BaseModel, Field
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class IntentClassification(BaseModel):
|
| 7 |
+
"""Intent classification output."""
|
| 8 |
+
intent: str = Field(
|
| 9 |
+
description="The user's intent: 'question', 'greeting', 'goodbye', 'other'"
|
| 10 |
+
)
|
| 11 |
+
needs_search: bool = Field(
|
| 12 |
+
description="Whether document search is needed"
|
| 13 |
+
)
|
| 14 |
+
search_query: str = Field(
|
| 15 |
+
default="",
|
| 16 |
+
description="The query to use for document search if needed"
|
| 17 |
+
)
|
| 18 |
+
direct_response: str = Field(
|
| 19 |
+
default="",
|
| 20 |
+
description="Direct response if no search needed (for greetings, etc.)"
|
| 21 |
+
)
|
src/models/user_info.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""User info models for existing users.py."""
|
| 2 |
+
|
| 3 |
+
from pydantic import BaseModel
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class UserCreate(BaseModel):
|
| 7 |
+
"""User creation model."""
|
| 8 |
+
fullname: str
|
| 9 |
+
email: str
|
| 10 |
+
password: str
|
| 11 |
+
company: str | None = None
|
| 12 |
+
company_size: str | None = None
|
| 13 |
+
function: str | None = None
|
| 14 |
+
site: str | None = None
|
| 15 |
+
role: str | None = None
|
src/observability/langfuse/__init__.py
ADDED
|
File without changes
|
src/observability/langfuse/langfuse.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Langfuse observability integration."""
|
| 2 |
+
|
| 3 |
+
from langfuse import Langfuse
|
| 4 |
+
from src.config.settings import settings
|
| 5 |
+
from src.middlewares.logging import get_logger
|
| 6 |
+
|
| 7 |
+
logger = get_logger("langfuse")
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def get_langfuse():
|
| 11 |
+
"""Get Langfuse client."""
|
| 12 |
+
return Langfuse(
|
| 13 |
+
public_key=settings.LANGFUSE_PUBLIC_KEY,
|
| 14 |
+
secret_key=settings.LANGFUSE_SECRET_KEY,
|
| 15 |
+
host=settings.LANGFUSE_HOST
|
| 16 |
+
)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def trace_chat(user_id: str, room_id: str, query: str, response: str):
|
| 20 |
+
"""Trace a chat interaction."""
|
| 21 |
+
langfuse = get_langfuse()
|
| 22 |
+
|
| 23 |
+
langfuse.score(
|
| 24 |
+
name="chat_interaction",
|
| 25 |
+
value=1, # Placeholder for quality score
|
| 26 |
+
comment="Successful chat"
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
langfuse.flush()
|
src/rag/__init__.py
ADDED
|
File without changes
|
src/rag/retriever.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Service for retrieving relevant documents from vector store."""
|
| 2 |
+
|
| 3 |
+
import hashlib
|
| 4 |
+
import json
|
| 5 |
+
from src.db.postgres.vector_store import get_vector_store
|
| 6 |
+
from src.db.redis.connection import get_redis
|
| 7 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 8 |
+
from src.middlewares.logging import get_logger
|
| 9 |
+
from typing import List, Dict, Any
|
| 10 |
+
|
| 11 |
+
logger = get_logger("retriever")
|
| 12 |
+
|
| 13 |
+
_RETRIEVAL_CACHE_TTL = 3600 # 1 hour
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class RetrieverService:
|
| 17 |
+
"""Service for retrieving relevant documents."""
|
| 18 |
+
|
| 19 |
+
def __init__(self):
|
| 20 |
+
self.vector_store = get_vector_store()
|
| 21 |
+
|
| 22 |
+
async def retrieve(
|
| 23 |
+
self,
|
| 24 |
+
query: str,
|
| 25 |
+
user_id: str,
|
| 26 |
+
db: AsyncSession,
|
| 27 |
+
k: int = 5
|
| 28 |
+
) -> List[Dict[str, Any]]:
|
| 29 |
+
"""Retrieve relevant chunks for a query, scoped to the user's documents.
|
| 30 |
+
|
| 31 |
+
Returns:
|
| 32 |
+
List of dicts with keys: content, metadata
|
| 33 |
+
metadata includes: document_id, user_id, filename, chunk_index, page_label (if PDF)
|
| 34 |
+
"""
|
| 35 |
+
try:
|
| 36 |
+
redis = await get_redis()
|
| 37 |
+
query_hash = hashlib.md5(query.encode()).hexdigest()
|
| 38 |
+
cache_key = f"retrieval:{user_id}:{query_hash}:{k}"
|
| 39 |
+
|
| 40 |
+
cached = await redis.get(cache_key)
|
| 41 |
+
if cached:
|
| 42 |
+
logger.info("Returning cached retrieval results")
|
| 43 |
+
return json.loads(cached)
|
| 44 |
+
|
| 45 |
+
logger.info(f"Retrieving for user {user_id}, query: {query[:50]}...")
|
| 46 |
+
|
| 47 |
+
docs = await self.vector_store.asimilarity_search(
|
| 48 |
+
query=query,
|
| 49 |
+
k=k,
|
| 50 |
+
filter={"user_id": user_id}
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
results = [
|
| 54 |
+
{
|
| 55 |
+
"content": doc.page_content,
|
| 56 |
+
"metadata": doc.metadata,
|
| 57 |
+
}
|
| 58 |
+
for doc in docs
|
| 59 |
+
]
|
| 60 |
+
|
| 61 |
+
logger.info(f"Retrieved {len(results)} chunks")
|
| 62 |
+
await redis.setex(cache_key, _RETRIEVAL_CACHE_TTL, json.dumps(results))
|
| 63 |
+
return results
|
| 64 |
+
|
| 65 |
+
except Exception as e:
|
| 66 |
+
logger.error("Retrieval failed", error=str(e))
|
| 67 |
+
return []
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
retriever = RetrieverService()
|
src/storage/az_blob/__init__.py
ADDED
|
File without changes
|
src/storage/az_blob/az_blob.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Azure Blob Storage client wrapper."""
|
| 2 |
+
|
| 3 |
+
from azure.storage.blob.aio import BlobClient
|
| 4 |
+
from src.config.settings import settings
|
| 5 |
+
from src.middlewares.logging import get_logger
|
| 6 |
+
import uuid
|
| 7 |
+
|
| 8 |
+
logger = get_logger("azure_blob")
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class AzureBlobStorage:
|
| 12 |
+
"""Azure Blob Storage async client wrapper."""
|
| 13 |
+
|
| 14 |
+
def __init__(self):
|
| 15 |
+
self.container_name = settings.azureai_container_name
|
| 16 |
+
self.sas_token = settings.azureai_blob_sas
|
| 17 |
+
self.account_url = settings.azureai_container_endpoint.rstrip('/')
|
| 18 |
+
|
| 19 |
+
def _get_blob_client(self, blob_name: str) -> BlobClient:
|
| 20 |
+
"""Get async blob client with SAS token."""
|
| 21 |
+
sas_url = f"{self.account_url}/{self.container_name}/{blob_name}?{self.sas_token}"
|
| 22 |
+
return BlobClient.from_blob_url(sas_url)
|
| 23 |
+
|
| 24 |
+
async def upload_file(self, file_content: bytes, filename: str, user_id: str) -> str:
|
| 25 |
+
"""Upload file to Azure Blob Storage.
|
| 26 |
+
|
| 27 |
+
Returns:
|
| 28 |
+
blob_name: Unique blob name in storage
|
| 29 |
+
"""
|
| 30 |
+
try:
|
| 31 |
+
ext = filename.split('.')[-1] if '.' in filename else 'txt'
|
| 32 |
+
blob_name = f"{user_id}/{uuid.uuid4()}.{ext}"
|
| 33 |
+
|
| 34 |
+
async with self._get_blob_client(blob_name) as blob_client:
|
| 35 |
+
logger.info(f"Uploading file {filename} to blob {blob_name}")
|
| 36 |
+
await blob_client.upload_blob(file_content, overwrite=True)
|
| 37 |
+
|
| 38 |
+
logger.info(f"Successfully uploaded {blob_name}")
|
| 39 |
+
return blob_name
|
| 40 |
+
|
| 41 |
+
except Exception as e:
|
| 42 |
+
logger.error(f"Failed to upload file {filename}", error=str(e))
|
| 43 |
+
raise
|
| 44 |
+
|
| 45 |
+
async def download_file(self, blob_name: str) -> bytes:
|
| 46 |
+
"""Download file from Azure Blob Storage."""
|
| 47 |
+
try:
|
| 48 |
+
async with self._get_blob_client(blob_name) as blob_client:
|
| 49 |
+
logger.info(f"Downloading blob {blob_name}")
|
| 50 |
+
stream = await blob_client.download_blob()
|
| 51 |
+
content = await stream.readall()
|
| 52 |
+
|
| 53 |
+
logger.info(f"Successfully downloaded {blob_name}")
|
| 54 |
+
return content
|
| 55 |
+
|
| 56 |
+
except Exception as e:
|
| 57 |
+
logger.error(f"Failed to download blob {blob_name}", error=str(e))
|
| 58 |
+
raise
|
| 59 |
+
|
| 60 |
+
async def delete_file(self, blob_name: str) -> bool:
|
| 61 |
+
"""Delete file from Azure Blob Storage."""
|
| 62 |
+
try:
|
| 63 |
+
async with self._get_blob_client(blob_name) as blob_client:
|
| 64 |
+
logger.info(f"Deleting blob {blob_name}")
|
| 65 |
+
await blob_client.delete_blob()
|
| 66 |
+
|
| 67 |
+
logger.info(f"Successfully deleted {blob_name}")
|
| 68 |
+
return True
|
| 69 |
+
|
| 70 |
+
except Exception as e:
|
| 71 |
+
logger.error(f"Failed to delete blob {blob_name}", error=str(e))
|
| 72 |
+
return False
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
# Singleton instance
|
| 76 |
+
blob_storage = AzureBlobStorage()
|
src/tools/__init__.py
ADDED
|
File without changes
|