ishaq101 commited on
Commit
bef5e76
·
1 Parent(s): 6700a7f

[NOTICKET] Demo agentic agent

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitignore +32 -0
  2. Dockerfile +34 -0
  3. README.md +67 -0
  4. main.py +70 -0
  5. playground_chat.py +79 -0
  6. playground_flush_cache.py +39 -0
  7. pyproject.toml +135 -0
  8. run.py +18 -0
  9. src/__init__.py +0 -0
  10. src/agents/__init__.py +0 -0
  11. src/agents/chatbot.py +75 -0
  12. src/agents/orchestration.py +74 -0
  13. src/api/v1/__init__.py +0 -0
  14. src/api/v1/chat.py +169 -0
  15. src/api/v1/document.py +193 -0
  16. src/api/v1/knowledge.py +25 -0
  17. src/api/v1/room.py +107 -0
  18. src/api/v1/users.py +74 -0
  19. src/config/__init__.py +0 -0
  20. src/config/agents/guardrails_prompt.md +7 -0
  21. src/config/agents/system_prompt.md +18 -0
  22. src/config/env_constant.py +9 -0
  23. src/config/settings.py +67 -0
  24. src/db/postgres/__init__.py +0 -0
  25. src/db/postgres/connection.py +52 -0
  26. src/db/postgres/init_db.py +18 -0
  27. src/db/postgres/models.py +53 -0
  28. src/db/postgres/vector_store.py +31 -0
  29. src/db/redis/__init__.py +0 -0
  30. src/db/redis/connection.py +16 -0
  31. src/document/__init__.py +0 -0
  32. src/document/document_service.py +108 -0
  33. src/knowledge/__init__.py +0 -0
  34. src/knowledge/processing_service.py +146 -0
  35. src/middlewares/__init__.py +0 -0
  36. src/middlewares/cors.py +14 -0
  37. src/middlewares/logging.py +66 -0
  38. src/middlewares/rate_limit.py +17 -0
  39. src/models/__init__.py +0 -0
  40. src/models/security.py +10 -0
  41. src/models/states.py +14 -0
  42. src/models/structured_output.py +21 -0
  43. src/models/user_info.py +15 -0
  44. src/observability/langfuse/__init__.py +0 -0
  45. src/observability/langfuse/langfuse.py +29 -0
  46. src/rag/__init__.py +0 -0
  47. src/rag/retriever.py +70 -0
  48. src/storage/az_blob/__init__.py +0 -0
  49. src/storage/az_blob/az_blob.py +76 -0
  50. 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