ishaq101 commited on
Commit
027123c
·
1 Parent(s): 7045965

[KM-467] [DED][AI] Orchestration Agent - Init

Browse files

- https://bukittechnology.atlassian.net/browse/KM-467

This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitignore +36 -0
  2. Dockerfile +34 -0
  3. README.md +70 -4
  4. main.py +70 -0
  5. pyproject.toml +135 -0
  6. run.py +18 -0
  7. src/__init__.py +0 -0
  8. src/agents/__init__.py +0 -0
  9. src/agents/chatbot.py +75 -0
  10. src/agents/orchestration.py +74 -0
  11. src/api/v1/__init__.py +0 -0
  12. src/api/v1/chat.py +218 -0
  13. src/api/v1/document.py +193 -0
  14. src/api/v1/knowledge.py +25 -0
  15. src/api/v1/room.py +169 -0
  16. src/api/v1/users.py +78 -0
  17. src/config/__init__.py +0 -0
  18. src/config/agents/guardrails_prompt.md +7 -0
  19. src/config/agents/system_prompt.md +27 -0
  20. src/config/env_constant.py +9 -0
  21. src/config/settings.py +67 -0
  22. src/db/postgres/__init__.py +0 -0
  23. src/db/postgres/connection.py +52 -0
  24. src/db/postgres/init_db.py +23 -0
  25. src/db/postgres/models.py +83 -0
  26. src/db/postgres/vector_store.py +31 -0
  27. src/db/redis/__init__.py +0 -0
  28. src/db/redis/connection.py +16 -0
  29. src/document/__init__.py +0 -0
  30. src/document/document_service.py +108 -0
  31. src/knowledge/__init__.py +0 -0
  32. src/knowledge/processing_service.py +146 -0
  33. src/middlewares/__init__.py +0 -0
  34. src/middlewares/cors.py +14 -0
  35. src/middlewares/logging.py +66 -0
  36. src/middlewares/rate_limit.py +17 -0
  37. src/models/__init__.py +0 -0
  38. src/models/security.py +10 -0
  39. src/models/states.py +14 -0
  40. src/models/structured_output.py +21 -0
  41. src/models/user_info.py +15 -0
  42. src/observability/langfuse/__init__.py +0 -0
  43. src/observability/langfuse/langfuse.py +29 -0
  44. src/rag/__init__.py +0 -0
  45. src/rag/retriever.py +70 -0
  46. src/storage/az_blob/__init__.py +0 -0
  47. src/storage/az_blob/az_blob.py +76 -0
  48. src/tools/__init__.py +0 -0
  49. src/tools/search.py +46 -0
  50. src/users/__init__.py +0 -0
.gitignore ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ erd/
30
+ playground/
31
+ playground_retriever.py
32
+ playground_chat.py
33
+ playground_flush_cache.py
34
+ playground_create_user.py
35
+ API_CONTRACT.md
36
+ context_engineering/
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 pip install https://github.com/explosion/spacy-models/releases/download/en_core_web_lg-3.8.0/en_core_web_lg-3.8.0-py3-none-any.whl
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
@@ -1,11 +1,77 @@
1
  ---
2
  title: Agentic Service Data Eyond
3
- emoji: 📉
4
- colorFrom: purple
5
- colorTo: purple
6
  sdk: docker
7
  pinned: false
8
- short_description: agentic backend service Data Eyond
9
  ---
10
 
11
  Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  title: Agentic Service Data Eyond
3
+ emoji: 🏆
4
+ colorFrom: red
5
+ colorTo: blue
6
  sdk: docker
7
  pinned: false
 
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
+ "main:app",
67
+ host="0.0.0.0",
68
+ port=7860,
69
+ reload=True
70
+ )
pyproject.toml ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "agent-service-data-eyond"
7
+ version = "0.1.0"
8
+ description = "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, MessagesPlaceholder
5
+ from src.config.settings import settings
6
+ from src.middlewares.logging import get_logger
7
+ from src.models.structured_output import IntentClassification
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. You receive recent conversation history and the user's latest message.
26
+
27
+ Your task:
28
+ 1. Determine intent: question, greeting, goodbye, or other
29
+ 2. Decide whether to search the user's documents (needs_search)
30
+ 3. If search is needed, rewrite the user's message into a STANDALONE search query that incorporates necessary context from conversation history. If the user says "tell me more" or "how many papers?", the search_query must spell out the full topic explicitly from history.
31
+ 4. If no search needed, provide a short direct_response (plain text only, no markdown formatting).
32
+
33
+ Intent Routing:
34
+ - question -> needs_search=True, search_query=<standalone rewritten query>
35
+ - greeting -> needs_search=False, direct_response="Hello! How can I assist you today?"
36
+ - goodbye -> needs_search=False, direct_response="Goodbye! Have a great day!"
37
+ - other -> needs_search=True, search_query=<standalone rewritten query>
38
+ """),
39
+ MessagesPlaceholder(variable_name="history"),
40
+ ("user", "{message}")
41
+ ])
42
+
43
+ # with_structured_output uses function calling — guarantees valid schema regardless of LLM response style
44
+ self.chain = self.prompt | self.llm.with_structured_output(IntentClassification)
45
+
46
+ async def analyze_message(self, message: str, history: list = None) -> dict:
47
+ """Analyze user message and determine next actions.
48
+
49
+ Args:
50
+ message: The current user message.
51
+ history: Recent conversation as LangChain BaseMessage objects (oldest-first).
52
+ Used to rewrite ambiguous follow-ups into standalone search queries.
53
+ """
54
+ try:
55
+ logger.info(f"Analyzing message: {message[:50]}...")
56
+
57
+ history_messages = history or []
58
+ result: IntentClassification = await self.chain.ainvoke({"message": message, "history": history_messages})
59
+
60
+ logger.info(f"Intent: {result.intent}, Needs search: {result.needs_search}, Search query: {result.search_query[:50] if result.search_query else ''}")
61
+ return result.model_dump()
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,218 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Chat endpoint with streaming support."""
2
+
3
+ import asyncio
4
+ import uuid
5
+ from fastapi import APIRouter, Depends, HTTPException
6
+ from sqlalchemy.ext.asyncio import AsyncSession
7
+ from src.db.postgres.connection import get_db
8
+ from src.db.postgres.models import ChatMessage, MessageSource
9
+ from src.agents.orchestration import orchestrator
10
+ from src.agents.chatbot import chatbot
11
+ from src.rag.retriever import retriever
12
+ from src.db.redis.connection import get_redis
13
+ from src.config.settings import settings
14
+ from src.middlewares.logging import get_logger, log_execution
15
+ from sse_starlette.sse import EventSourceResponse
16
+ from langchain_core.messages import HumanMessage, AIMessage
17
+ from sqlalchemy import select
18
+ from pydantic import BaseModel
19
+ from typing import List, Dict, Any, Optional
20
+ import json
21
+
22
+ _GREETINGS = frozenset(["hi", "hello", "hey", "halo", "hai", "hei"])
23
+ _GOODBYES = frozenset(["bye", "goodbye", "thanks", "thank you", "terima kasih", "sampai jumpa"])
24
+
25
+
26
+ def _fast_intent(message: str) -> Optional[dict]:
27
+ """Bypass LLM orchestrator for obvious greetings and farewells."""
28
+ lower = message.lower().strip().rstrip("!.,?")
29
+ if lower in _GREETINGS:
30
+ return {"intent": "greeting", "needs_search": False,
31
+ "direct_response": "Hello! How can I assist you today?", "search_query": ""}
32
+ if lower in _GOODBYES:
33
+ return {"intent": "goodbye", "needs_search": False,
34
+ "direct_response": "Goodbye! Have a great day!", "search_query": ""}
35
+ return None
36
+
37
+ logger = get_logger("chat_api")
38
+
39
+ router = APIRouter(prefix="/api/v1", tags=["Chat"])
40
+
41
+
42
+ class ChatRequest(BaseModel):
43
+ user_id: str
44
+ room_id: str
45
+ message: str
46
+
47
+
48
+ def _format_context(results: List[Dict[str, Any]]) -> str:
49
+ """Format retrieval results as context string for the LLM."""
50
+ lines = []
51
+ for result in results:
52
+ filename = result["metadata"].get("filename", "Unknown")
53
+ page = result["metadata"].get("page_label")
54
+ source_label = f"{filename}, p.{page}" if page else filename
55
+ lines.append(f"[Source: {source_label}]\n{result['content']}\n")
56
+ return "\n".join(lines)
57
+
58
+
59
+ def _extract_sources(results: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
60
+ """Extract deduplicated source references from retrieval results."""
61
+ seen = set()
62
+ sources = []
63
+ for result in results:
64
+ meta = result["metadata"]
65
+ key = (meta.get("document_id"), meta.get("page_label"))
66
+ if key not in seen:
67
+ seen.add(key)
68
+ sources.append({
69
+ "document_id": meta.get("document_id"),
70
+ "filename": meta.get("filename", "Unknown"),
71
+ "page_label": meta.get("page_label"),
72
+ })
73
+ return sources
74
+
75
+
76
+ async def get_cached_response(redis, cache_key: str) -> Optional[str]:
77
+ cached = await redis.get(cache_key)
78
+ if cached:
79
+ return json.loads(cached)
80
+ return None
81
+
82
+
83
+ async def cache_response(redis, cache_key: str, response: str):
84
+ await redis.setex(cache_key, 86400, json.dumps(response))
85
+
86
+
87
+ async def load_history(db: AsyncSession, room_id: str, limit: int = 10) -> list:
88
+ """Load recent chat messages for a room as LangChain message objects (oldest-first)."""
89
+ result = await db.execute(
90
+ select(ChatMessage)
91
+ .where(ChatMessage.room_id == room_id)
92
+ .order_by(ChatMessage.created_at.asc())
93
+ .limit(limit)
94
+ )
95
+ rows = result.scalars().all()
96
+ return [
97
+ HumanMessage(content=row.content) if row.role == "user" else AIMessage(content=row.content)
98
+ for row in rows
99
+ ]
100
+
101
+
102
+ async def save_messages(
103
+ db: AsyncSession,
104
+ room_id: str,
105
+ user_content: str,
106
+ assistant_content: str,
107
+ sources: Optional[List[Dict[str, Any]]] = None,
108
+ ):
109
+ """Persist user and assistant messages, and attach sources to the assistant message."""
110
+ db.add(ChatMessage(id=str(uuid.uuid4()), room_id=room_id, role="user", content=user_content))
111
+ assistant_id = str(uuid.uuid4())
112
+ db.add(ChatMessage(id=assistant_id, room_id=room_id, role="assistant", content=assistant_content))
113
+ for src in (sources or []):
114
+ page = src.get("page_label")
115
+ db.add(MessageSource(
116
+ id=str(uuid.uuid4()),
117
+ message_id=assistant_id,
118
+ document_id=src.get("document_id"),
119
+ filename=src.get("filename"),
120
+ page_label=str(page) if page is not None else None,
121
+ ))
122
+ await db.commit()
123
+
124
+
125
+ @router.post("/chat/stream")
126
+ @log_execution(logger)
127
+ async def chat_stream(request: ChatRequest, db: AsyncSession = Depends(get_db)):
128
+ """Chat endpoint with streaming response.
129
+
130
+ SSE event sequence:
131
+ 1. sources — JSON array of {document_id, filename, page_label}
132
+ 2. chunk — text fragments of the answer
133
+ 3. done — signals end of stream
134
+ """
135
+ redis = await get_redis()
136
+
137
+ cache_key = f"{settings.redis_prefix}chat:{request.room_id}:{request.message}"
138
+ cached = await get_cached_response(redis, cache_key)
139
+ if cached:
140
+ logger.info("Returning cached response")
141
+
142
+ async def stream_cached():
143
+ yield {"event": "sources", "data": json.dumps([])}
144
+ for i in range(0, len(cached), 50):
145
+ yield {"event": "chunk", "data": cached[i:i + 50]}
146
+ yield {"event": "done", "data": ""}
147
+
148
+ return EventSourceResponse(stream_cached())
149
+
150
+ try:
151
+ # Step 1: Fast local intent check (skips LLM for greetings/farewells)
152
+ intent_result = _fast_intent(request.message)
153
+
154
+ context = ""
155
+ sources: List[Dict[str, Any]] = []
156
+
157
+ if intent_result is None:
158
+ # Step 2: Launch retrieval and history loading in parallel, then run orchestrator
159
+ retrieval_task = asyncio.create_task(
160
+ retriever.retrieve(request.message, request.user_id, db)
161
+ )
162
+ history_task = asyncio.create_task(
163
+ load_history(db, request.room_id, limit=6) # 6 msgs (3 pairs) for orchestrator
164
+ )
165
+ history = await history_task # fast DB query (<100ms), done before orchestrator finishes
166
+ intent_result = await orchestrator.analyze_message(request.message, history)
167
+
168
+ if not intent_result.get("needs_search"):
169
+ retrieval_task.cancel()
170
+ raw_results = []
171
+ else:
172
+ search_query = intent_result.get("search_query", request.message)
173
+ logger.info(f"Searching for: {search_query}")
174
+ if search_query != request.message:
175
+ retrieval_task.cancel()
176
+ raw_results = await retriever.retrieve(
177
+ query=search_query,
178
+ user_id=request.user_id,
179
+ db=db,
180
+ )
181
+ else:
182
+ raw_results = await retrieval_task
183
+
184
+ context = _format_context(raw_results)
185
+ sources = _extract_sources(raw_results)
186
+
187
+ # Step 3: Direct response for greetings / non-document intents
188
+ if intent_result.get("direct_response"):
189
+ response = intent_result["direct_response"]
190
+ await cache_response(redis, cache_key, response)
191
+ await save_messages(db, request.room_id, request.message, response, sources=[])
192
+
193
+ async def stream_direct():
194
+ yield {"event": "sources", "data": json.dumps([])}
195
+ yield {"event": "message", "data": response}
196
+
197
+ return EventSourceResponse(stream_direct())
198
+
199
+ # Step 4: Stream answer token-by-token as LLM generates it
200
+ # Load full history (10 msgs) for chatbot — richer context than the 6 used by orchestrator
201
+ full_history = await load_history(db, request.room_id, limit=10)
202
+ messages = full_history + [HumanMessage(content=request.message)]
203
+
204
+ async def stream_response():
205
+ full_response = ""
206
+ yield {"event": "sources", "data": json.dumps(sources)}
207
+ async for token in chatbot.astream_response(messages, context):
208
+ full_response += token
209
+ yield {"event": "chunk", "data": token}
210
+ yield {"event": "done", "data": ""}
211
+ await cache_response(redis, cache_key, full_response)
212
+ await save_messages(db, request.room_id, request.message, full_response, sources=sources)
213
+
214
+ return EventSourceResponse(stream_response())
215
+
216
+ except Exception as e:
217
+ logger.error("Chat failed", error=str(e))
218
+ 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,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 sqlalchemy.orm import selectinload
7
+ from src.db.postgres.connection import get_db
8
+ from src.db.postgres.models import Room, ChatMessage, MessageSource
9
+ from src.middlewares.logging import get_logger, log_execution
10
+ from pydantic import BaseModel
11
+ from typing import List, Optional
12
+ from datetime import datetime
13
+ import uuid
14
+
15
+ logger = get_logger("room_api")
16
+
17
+ router = APIRouter(prefix="/api/v1", tags=["Rooms"])
18
+
19
+
20
+ class MessageSourceResponse(BaseModel):
21
+ document_id: Optional[str]
22
+ filename: Optional[str]
23
+ page_label: Optional[str]
24
+
25
+
26
+ class ChatMessageResponse(BaseModel):
27
+ id: str
28
+ role: str
29
+ content: str
30
+ created_at: str
31
+ sources: List[MessageSourceResponse] = []
32
+
33
+
34
+ class RoomResponse(BaseModel):
35
+ id: str
36
+ title: str
37
+ created_at: str
38
+ updated_at: str | None
39
+ messages: List[ChatMessageResponse] = []
40
+
41
+
42
+ class CreateRoomRequest(BaseModel):
43
+ user_id: str
44
+ title: str = "New Chat"
45
+
46
+
47
+ @router.get("/rooms/{user_id}", response_model=List[RoomResponse])
48
+ @log_execution(logger)
49
+ async def list_rooms(
50
+ user_id: str,
51
+ db: AsyncSession = Depends(get_db)
52
+ ):
53
+ """List all rooms for a user."""
54
+ result = await db.execute(
55
+ select(Room)
56
+ .where(Room.user_id == user_id, Room.status == "active")
57
+ .order_by(Room.updated_at.desc())
58
+ )
59
+ rooms = result.scalars().all()
60
+
61
+ return [
62
+ RoomResponse(
63
+ id=room.id,
64
+ title=room.title,
65
+ created_at=room.created_at.isoformat(),
66
+ updated_at=room.updated_at.isoformat() if room.updated_at else None
67
+ )
68
+ for room in rooms
69
+ ]
70
+
71
+
72
+ @router.get("/room/{room_id}", response_model=RoomResponse)
73
+ @log_execution(logger)
74
+ async def get_room(
75
+ room_id: str,
76
+ db: AsyncSession = Depends(get_db)
77
+ ):
78
+ """Get a specific room with its chat history."""
79
+ result = await db.execute(
80
+ select(Room)
81
+ .where(Room.id == room_id)
82
+ .options(selectinload(Room.messages).selectinload(ChatMessage.sources))
83
+ )
84
+ room = result.scalars().first()
85
+
86
+ if not room:
87
+ raise HTTPException(
88
+ status_code=404,
89
+ detail="Room not found"
90
+ )
91
+
92
+ messages = sorted(room.messages, key=lambda m: m.created_at)
93
+
94
+ return RoomResponse(
95
+ id=room.id,
96
+ title=room.title,
97
+ created_at=room.created_at.isoformat(),
98
+ updated_at=room.updated_at.isoformat() if room.updated_at else None,
99
+ messages=[
100
+ ChatMessageResponse(
101
+ id=msg.id,
102
+ role=msg.role,
103
+ content=msg.content,
104
+ created_at=msg.created_at.isoformat(),
105
+ sources=[
106
+ MessageSourceResponse(
107
+ document_id=src.document_id,
108
+ filename=src.filename,
109
+ page_label=src.page_label,
110
+ )
111
+ for src in msg.sources
112
+ ],
113
+ )
114
+ for msg in messages
115
+ ]
116
+ )
117
+
118
+
119
+ @router.delete("/room/{room_id}")
120
+ @log_execution(logger)
121
+ async def delete_room(
122
+ room_id: str,
123
+ user_id: str,
124
+ db: AsyncSession = Depends(get_db)
125
+ ):
126
+ """Soft-delete a room by setting its status to inactive."""
127
+ result = await db.execute(
128
+ select(Room).where(Room.id == room_id)
129
+ )
130
+ room = result.scalars().first()
131
+
132
+ if not room:
133
+ raise HTTPException(status_code=404, detail="Room not found")
134
+
135
+ if room.user_id != user_id:
136
+ raise HTTPException(status_code=403, detail="Access denied")
137
+
138
+ room.status = "inactive"
139
+ await db.commit()
140
+
141
+ return {"status": "success", "message": "Room deleted successfully"}
142
+
143
+
144
+ @router.post("/room/create")
145
+ @log_execution(logger)
146
+ async def create_room(
147
+ request: CreateRoomRequest,
148
+ db: AsyncSession = Depends(get_db)
149
+ ):
150
+ """Create a new room."""
151
+ room = Room(
152
+ id=str(uuid.uuid4()),
153
+ user_id=request.user_id,
154
+ title=request.title
155
+ )
156
+ db.add(room)
157
+ await db.commit()
158
+ await db.refresh(room)
159
+
160
+ return {
161
+ "status": "success",
162
+ "message": "Room created successfully",
163
+ "data": RoomResponse(
164
+ id=room.id,
165
+ title=room.title,
166
+ created_at=room.created_at.isoformat(),
167
+ updated_at=None
168
+ )
169
+ }
src/api/v1/users.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ if user_profile.get("status") == "inactive":
55
+ raise HTTPException(
56
+ status_code=status.HTTP_403_FORBIDDEN,
57
+ detail="Account is inactive"
58
+ )
59
+
60
+ is_verified = verify_password(
61
+ password=payload.password,
62
+ hashed_password=user_profile.get("password")
63
+ )
64
+
65
+ if not is_verified:
66
+ raise HTTPException(
67
+ status_code=status.HTTP_401_UNAUTHORIZED,
68
+ detail="Email or password invalid"
69
+ )
70
+
71
+ user_profile.pop("password", None)
72
+
73
+ return {
74
+ "status": "success",
75
+ "message": "success",
76
+ "data": user_profile,
77
+ }
78
+
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,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ When the answer need markdown formating:
19
+ - Use valid and tidy formatting
20
+ - Avoid over-formating and emoji
21
+
22
+ Always be professional, helpful, and accurate.
23
+
24
+ You have access to the conversation history provided in the messages above. Use it to:
25
+ - Maintain context across multiple turns (resolve references like "it", "that", "them" using earlier messages)
26
+ - Avoid repeating information already established in the conversation
27
+ - Answer follow-up questions coherently without asking the user to restate prior context
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,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, User, MessageSource
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)
19
+
20
+ # Schema migrations (idempotent — safe to run on every startup)
21
+ await conn.execute(text(
22
+ "ALTER TABLE rooms ADD COLUMN IF NOT EXISTS status VARCHAR NOT NULL DEFAULT 'active'"
23
+ ))
src/db/postgres/models.py ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ class User(Base):
11
+ """User model."""
12
+ __tablename__ = "users"
13
+
14
+ id = Column(String, primary_key=True, default=lambda: str(uuid4()))
15
+ fullname = Column(String, nullable=False)
16
+ email = Column(String, nullable=False, unique=True, index=True)
17
+ password = Column(String, nullable=False) # bcrypt-hashed
18
+ company = Column(String)
19
+ company_size = Column(String)
20
+ function = Column(String)
21
+ site = Column(String)
22
+ role = Column(String)
23
+ status = Column(String, nullable=False, default="active") # active | inactive
24
+ created_at = Column(DateTime(timezone=True), server_default=func.now())
25
+
26
+
27
+ class Document(Base):
28
+ """Document model."""
29
+ __tablename__ = "documents"
30
+
31
+ id = Column(String, primary_key=True, default=lambda: str(uuid4()))
32
+ user_id = Column(String, nullable=False, index=True)
33
+ filename = Column(String, nullable=False)
34
+ blob_name = Column(String, nullable=False, unique=True)
35
+ file_size = Column(Integer)
36
+ file_type = Column(String) # pdf, docx, txt, etc.
37
+ status = Column(String, default="uploaded") # uploaded, processing, completed, failed
38
+ processed_at = Column(DateTime(timezone=True))
39
+ error_message = Column(Text)
40
+ created_at = Column(DateTime(timezone=True), server_default=func.now())
41
+
42
+
43
+ class Room(Base):
44
+ """Room model for chat sessions."""
45
+ __tablename__ = "rooms"
46
+
47
+ id = Column(String, primary_key=True, default=lambda: str(uuid4()))
48
+ user_id = Column(String, nullable=False, index=True)
49
+ title = Column(String, default="New Chat")
50
+ created_at = Column(DateTime(timezone=True), server_default=func.now())
51
+ updated_at = Column(DateTime(timezone=True), onupdate=func.now())
52
+
53
+ status = Column(String, nullable=False, default="active") # active | inactive
54
+
55
+ messages = relationship("ChatMessage", back_populates="room", cascade="all, delete-orphan")
56
+
57
+
58
+ class ChatMessage(Base):
59
+ """Chat message model."""
60
+ __tablename__ = "chat_messages"
61
+
62
+ id = Column(String, primary_key=True, default=lambda: str(uuid4()))
63
+ room_id = Column(String, ForeignKey("rooms.id"), nullable=False, index=True)
64
+ role = Column(String, nullable=False) # user, assistant
65
+ content = Column(Text, nullable=False)
66
+ created_at = Column(DateTime(timezone=True), server_default=func.now())
67
+
68
+ room = relationship("Room", back_populates="messages")
69
+ sources = relationship("MessageSource", back_populates="message", cascade="all, delete-orphan")
70
+
71
+
72
+ class MessageSource(Base):
73
+ """Sources (RAG references) attached to an assistant message."""
74
+ __tablename__ = "message_sources"
75
+
76
+ id = Column(String, primary_key=True, default=lambda: str(uuid4()))
77
+ message_id = Column(String, ForeignKey("chat_messages.id", ondelete="CASCADE"), nullable=False, index=True)
78
+ document_id = Column(String)
79
+ filename = Column(Text)
80
+ page_label = Column(Text)
81
+ created_at = Column(DateTime(timezone=True), server_default=func.now())
82
+
83
+ message = relationship("ChatMessage", back_populates="sources")
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
src/tools/search.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Search tool for agent."""
2
+
3
+ from langchain_core.tools import tool
4
+ from src.rag.retriever import retriever
5
+ from sqlalchemy.ext.asyncio import AsyncSession
6
+ from src.middlewares.logging import get_logger
7
+
8
+ logger = get_logger("search_tool")
9
+
10
+
11
+ @tool
12
+ async def search_documents(
13
+ query: str,
14
+ user_id: str,
15
+ db: AsyncSession,
16
+ num_results: int = 5
17
+ ) -> str:
18
+ """Search user's uploaded documents for relevant information.
19
+
20
+ Args:
21
+ query: The search query or question
22
+ user_id: The user's ID
23
+ db: Database session
24
+ num_results: Number of results to return (default: 5)
25
+
26
+ Returns:
27
+ Relevant document excerpts with source and page information
28
+ """
29
+ try:
30
+ results = await retriever.retrieve(query, user_id, db, num_results)
31
+
32
+ if not results:
33
+ return "No relevant information found in the documents."
34
+
35
+ formatted_results = []
36
+ for result in results:
37
+ filename = result["metadata"].get("filename", "Unknown")
38
+ page = result["metadata"].get("page_label")
39
+ source_label = f"{filename}, p.{page}" if page else filename
40
+ formatted_results.append(f"[Source: {source_label}]\n{result['content']}\n")
41
+
42
+ return "\n".join(formatted_results)
43
+
44
+ except Exception as e:
45
+ logger.error("Search failed", error=str(e))
46
+ return "Sorry, I encountered an error while searching the documents."
src/users/__init__.py ADDED
File without changes