Skip to content

Commit f7d0368

Browse files
added guardrails
1 parent b80fa66 commit f7d0368

17 files changed

Lines changed: 377 additions & 51 deletions

README.md

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -41,20 +41,22 @@ Full setup guide: [docs/RUNBOOK.md](docs/RUNBOOK.md)
4141
| `POST` | `/v1/auth/logout` | Authenticated |
4242
| `GET` | `/v1/auth/me` | Authenticated |
4343
| `GET` | `/v1/auth/me/sessions` | Authenticated |
44+
| `GET` | `/v1/auth/sessions/company` | Admin (own company) or Super Admin |
4445
| `POST` | `/v1/companies/` | Super Admin |
4546
| `GET` | `/v1/companies/` | Super Admin |
4647
| `GET` | `/v1/companies/{id}` | Super Admin |
4748
| `PUT` | `/v1/companies/{id}` | Super Admin |
4849
| `DELETE` | `/v1/companies/{id}` | Super Admin |
49-
| `POST` | `/v1/users/` | Admin (own company) |
50-
| `GET` | `/v1/users/` | Admin (own company) |
51-
| `GET` | `/v1/users/{id}` | Admin (own company) |
52-
| `PUT` | `/v1/users/{id}` | Admin (own company) |
53-
| `DELETE` | `/v1/users/{id}` | Admin (own company) |
54-
| `POST` | `/v1/documents/upload` | Admin (own company) |
55-
| `GET` | `/v1/documents/` | Admin (own company) |
56-
| `GET` | `/v1/documents/{id}` | Admin (own company) |
57-
| `DELETE` | `/v1/documents/{id}` | Admin (own company) |
50+
| `POST` | `/v1/users/` | Admin (own company) or Super Admin |
51+
| `GET` | `/v1/users/` | Admin (own company) or Super Admin |
52+
| `GET` | `/v1/users/{id}` | Admin (own company) or Super Admin |
53+
| `PUT` | `/v1/users/{id}` | Admin (own company) or Super Admin |
54+
| `DELETE` | `/v1/users/{id}` | Admin (own company) or Super Admin |
55+
| `POST` | `/v1/documents/upload` | Admin (own company) or Super Admin (with company_id) |
56+
| `GET` | `/v1/documents/` | Admin (own company) or Super Admin |
57+
| `GET` | `/v1/documents/{id}` | Admin (own company) or Super Admin |
58+
| `DELETE` | `/v1/documents/{id}` | Admin (own company) or Super Admin |
59+
| `POST` | `/v1/chat/warmup` | Authenticated |
5860
| `POST` | `/v1/chat/invoke` | Admin or Employee |
5961

6062
---
@@ -101,6 +103,7 @@ ACCESS_TOKEN_EXPIRE_MINUTES=60
101103
EMBEDDING_MODEL=all-MiniLM-L6-v2
102104
CHUNK_SIZE=1000
103105
CHUNK_OVERLAP=200
106+
AGENT_WARMUP_ON_STARTUP=true
104107
105108
# Encoding
106109
ENCODING=utf-8

app/agent/graph.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ def call_model(state: GraphState):
4646
" Do NOT just describe data in text.\n"
4747
"- First use 'search_documents' to gather the numbers if needed,"
4848
" then call 'generate_graph' with the data.\n"
49+
"- Never call 'search_documents' repeatedly with the same query in a loop."
50+
" If you already called it and have results, produce a final answer.\n"
4951
"- After calling 'generate_graph', keep your final text response brief (e.g. 'Here is your chart.'). "
5052
"Do NOT re-list all the data points in your text answer — the chart already shows them."
5153
)

app/agent/tools/vector_search.py

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
"""LangGraph tool: semantic search over document chunks using pgvector cosine distance."""
22

3+
from contextvars import ContextVar, Token
4+
35
from langchain_core.tools import tool
46
from langchain_huggingface import HuggingFaceEmbeddings
57
from sqlalchemy import select
68
from sqlalchemy.exc import SQLAlchemyError
79

810
from app.core.config import settings
911
from app.core.logger import get_logger
10-
from app.db.models import DocumentChunk
12+
from app.db.models import Document, DocumentChunk
1113
from app.db.session import AsyncSessionLocal
1214

1315
logger = get_logger(__name__)
@@ -17,6 +19,8 @@
1719
# every module. Eager loading here would load the model twice (once per process)
1820
# even though only the worker process ever runs queries.
1921
_embeddings_model: HuggingFaceEmbeddings | None = None
22+
_search_company_id: ContextVar[str | None] = ContextVar("search_company_id", default=None)
23+
_search_query_counts: ContextVar[dict[str, int] | None] = ContextVar("search_query_counts", default=None)
2024

2125

2226
def _get_embeddings_model() -> HuggingFaceEmbeddings:
@@ -29,13 +33,66 @@ def _get_embeddings_model() -> HuggingFaceEmbeddings:
2933
return _embeddings_model
3034

3135

36+
def warmup_vector_search() -> bool:
37+
"""Preload the embedding model and return True when this call performed the initial load."""
38+
was_cold = _embeddings_model is None
39+
_get_embeddings_model()
40+
return was_cold
41+
42+
43+
def set_search_company_scope(company_id: str | None) -> Token:
44+
"""Set the tenant scope for vector search in the current request/task context."""
45+
return _search_company_id.set(company_id)
46+
47+
48+
def reset_search_company_scope(token: Token) -> None:
49+
"""Reset the tenant scope for vector search in the current request/task context."""
50+
_search_company_id.reset(token)
51+
52+
53+
def set_search_query_state() -> Token:
54+
"""Initialise per-request duplicate-query counters for vector search."""
55+
return _search_query_counts.set({})
56+
57+
58+
def reset_search_query_state(token: Token) -> None:
59+
"""Reset per-request duplicate-query counters for vector search."""
60+
_search_query_counts.reset(token)
61+
62+
3263
@tool
3364
async def search_documents(query: str) -> str:
3465
"""
3566
Search the database for relevant document chunks based on a semantic query.
3667
Use this tool whenever you need to find factual information from the user's uploaded documents.
3768
"""
38-
logger.info("Vector search invoked", extra={"query_preview": query[:120]})
69+
company_id = _search_company_id.get()
70+
if not company_id:
71+
logger.error("Vector search blocked — missing company scope")
72+
return "Search context is unavailable for this request."
73+
74+
logger.info(
75+
"Vector search invoked",
76+
extra={"query_preview": query[:120], "company_id": company_id},
77+
)
78+
79+
normalized_query = " ".join(query.lower().split())
80+
query_counts = _search_query_counts.get()
81+
if query_counts is None:
82+
query_counts = {}
83+
_search_query_counts.set(query_counts)
84+
85+
repeat_count = query_counts.get(normalized_query, 0)
86+
if repeat_count >= 1:
87+
logger.warning(
88+
"Vector search blocked — duplicate query loop detected",
89+
extra={"query_preview": query[:120], "company_id": company_id, "repeat_count": repeat_count},
90+
)
91+
return (
92+
"You already ran this same search multiple times. "
93+
"Do not call search_documents again for this query; provide your best final answer now."
94+
)
95+
query_counts[normalized_query] = repeat_count + 1
3996

4097
try:
4198
query_vector = _get_embeddings_model().embed_query(query)
@@ -47,6 +104,8 @@ async def search_documents(query: str) -> str:
47104
async with AsyncSessionLocal() as db:
48105
stmt = (
49106
select(DocumentChunk.text_content)
107+
.join(Document, Document.id == DocumentChunk.document_id)
108+
.where(Document.company_id == company_id)
50109
.order_by(DocumentChunk.embedding.cosine_distance(query_vector))
51110
.limit(5)
52111
)
@@ -60,5 +119,5 @@ async def search_documents(query: str) -> str:
60119
logger.info("Vector search returned no results", extra={"query_preview": query[:120]})
61120
return "No relevant information found in the documents."
62121

63-
logger.info("Vector search completed", extra={"results": len(chunks)})
122+
logger.info("Vector search completed", extra={"results": len(chunks), "company_id": company_id})
64123
return "\n\n---\n\n".join(chunks)

app/api/v1/endpoints/auth.py

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,17 @@
33
import uuid
44
from datetime import UTC, datetime, timedelta
55

6-
from fastapi import APIRouter, Depends, HTTPException, Request, status
6+
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
77
from fastapi.security import OAuth2PasswordRequestForm
88
from jose import JWTError, jwt
99
from sqlalchemy import select
1010
from sqlalchemy.ext.asyncio import AsyncSession
1111

12-
from app.api.dependencies import get_current_user, oauth2_scheme
12+
from app.api.dependencies import get_current_user, oauth2_scheme, require_admin_or_super_admin
1313
from app.core.config import settings
1414
from app.core.logger import get_logger
1515
from app.core.security import create_access_token, verify_password
16-
from app.db.models import TokenSession, User
16+
from app.db.models import TokenSession, User, UserRole
1717
from app.db.session import get_db
1818
from app.schemas.auth import LogoutResponse, MeResponse, TokenResponse
1919
from app.schemas.sessions import SessionResponse
@@ -157,3 +157,56 @@ async def list_my_sessions(
157157
extra={"user_id": current_user.id, "session_count": len(sessions)},
158158
)
159159
return sessions
160+
161+
162+
@router.get("/sessions/company", response_model=list[SessionResponse])
163+
async def list_company_sessions(
164+
company_id: str | None = Query(
165+
default=None,
166+
description="Filter by company UUID. Admin users are always restricted to their own company.",
167+
),
168+
current_user: User = Depends(require_admin_or_super_admin),
169+
db: AsyncSession = Depends(get_db),
170+
):
171+
"""
172+
List token sessions for a company.
173+
174+
**admin**: sees sessions for their own company only.
175+
**super_admin**: can list all company sessions or filter by ``company_id``.
176+
"""
177+
target_company_id = company_id
178+
if current_user.role == UserRole.ADMIN:
179+
if company_id and company_id != current_user.company_id:
180+
logger.warning(
181+
"Company sessions access denied",
182+
extra={
183+
"user_id": current_user.id,
184+
"actor_company": current_user.company_id,
185+
"requested_company": company_id,
186+
},
187+
)
188+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Access denied")
189+
target_company_id = current_user.company_id
190+
191+
stmt = select(TokenSession).join(User, User.id == TokenSession.user_id)
192+
193+
if target_company_id:
194+
stmt = stmt.where(User.company_id == target_company_id)
195+
else:
196+
# "All company sessions" intentionally excludes super_admin accounts (company_id is NULL).
197+
stmt = stmt.where(User.company_id.is_not(None))
198+
199+
stmt = stmt.order_by(TokenSession.issued_at.desc())
200+
result = await db.execute(stmt)
201+
sessions = result.scalars().all()
202+
203+
logger.info(
204+
"Company sessions listed",
205+
extra={
206+
"actor": current_user.id,
207+
"actor_role": str(current_user.role),
208+
"target_company_id": target_company_id or "all_companies",
209+
"session_count": len(sessions),
210+
},
211+
)
212+
return sessions

app/api/v1/endpoints/chat.py

Lines changed: 79 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,68 @@
11
"""Chat endpoint — submits a query to the LangGraph RAG agent."""
22

3+
import asyncio
34
import json
45
import time
56

6-
from fastapi import APIRouter, Depends
7+
from fastapi import APIRouter, Depends, HTTPException, status
78
from langchain_core.messages import HumanMessage, ToolMessage
9+
from langgraph.errors import GraphRecursionError
810

911
from app.agent.graph import app_graph
10-
from app.api.dependencies import require_company_user
12+
from app.agent.tools.vector_search import (
13+
reset_search_company_scope,
14+
reset_search_query_state,
15+
set_search_company_scope,
16+
set_search_query_state,
17+
warmup_vector_search,
18+
)
19+
from app.api.dependencies import get_current_user, require_company_user
1120
from app.core.exceptions import AgentError
1221
from app.core.logger import get_logger
1322
from app.db.models import User
14-
from app.schemas.chat import ChatRequest, ChatResponse
23+
from app.schemas.chat import ChatRequest, ChatResponse, WarmupResponse
1524

1625
logger = get_logger(__name__)
1726
router = APIRouter()
1827

1928

29+
@router.post(
30+
"/warmup",
31+
response_model=WarmupResponse,
32+
responses={
33+
502: {"description": "Warmup failed — embedding model could not be loaded."},
34+
},
35+
)
36+
async def warmup_agent(current_user: User = Depends(get_current_user)):
37+
"""Warm up agent resources so the next chat request avoids embedding-model cold start latency."""
38+
start = time.monotonic()
39+
try:
40+
loaded_now = await asyncio.to_thread(warmup_vector_search)
41+
except Exception as exc:
42+
elapsed = time.monotonic() - start
43+
logger.error(
44+
"Agent warmup failed",
45+
extra={"user_id": current_user.id, "elapsed_s": round(elapsed, 3)},
46+
exc_info=exc,
47+
)
48+
raise AgentError("Agent warmup failed. Please try again.") from exc
49+
50+
elapsed = time.monotonic() - start
51+
logger.info(
52+
"Agent warmup completed",
53+
extra={
54+
"user_id": current_user.id,
55+
"elapsed_s": round(elapsed, 3),
56+
"embeddings_loaded_now": loaded_now,
57+
},
58+
)
59+
return WarmupResponse(
60+
message="Agent warmup completed",
61+
embeddings_loaded_now=loaded_now,
62+
elapsed_seconds=round(elapsed, 3),
63+
)
64+
65+
2066
def _extract_graph_payload(messages) -> dict | None:
2167
for msg in reversed(messages):
2268
if isinstance(msg, ToolMessage) and msg.name == "generate_graph":
@@ -61,10 +107,36 @@ async def invoke_agent(
61107
},
62108
)
63109

110+
if not current_user.company_id:
111+
logger.warning(
112+
"Chat blocked — user has no company scope",
113+
extra={"user_id": current_user.id, "role": str(current_user.role)},
114+
)
115+
raise HTTPException(
116+
status_code=status.HTTP_403_FORBIDDEN,
117+
detail="You do not have permission to perform this action.",
118+
)
119+
64120
start = time.monotonic()
121+
search_scope_token = set_search_company_scope(current_user.company_id)
122+
search_query_state_token = set_search_query_state()
65123
try:
66124
initial_state = {"messages": [HumanMessage(content=request.query)]}
67-
final_state = await app_graph.ainvoke(initial_state)
125+
final_state = await app_graph.ainvoke(initial_state, config={"recursion_limit": 8})
126+
except GraphRecursionError as exc:
127+
elapsed = time.monotonic() - start
128+
logger.error(
129+
"Agent halted by recursion limit",
130+
extra={
131+
"user_id": current_user.id,
132+
"company_id": current_user.company_id,
133+
"elapsed_s": round(elapsed, 3),
134+
},
135+
exc_info=exc,
136+
)
137+
raise AgentError(
138+
"The agent could not converge on an answer. Please rephrase your question with more specific details."
139+
) from exc
68140
except Exception as exc:
69141
elapsed = time.monotonic() - start
70142
logger.error(
@@ -77,6 +149,9 @@ async def invoke_agent(
77149
exc_info=exc,
78150
)
79151
raise AgentError("The agent failed to process your request. Please try again.") from exc
152+
finally:
153+
reset_search_query_state(search_query_state_token)
154+
reset_search_company_scope(search_scope_token)
80155

81156
elapsed = time.monotonic() - start
82157
final_message = final_state["messages"][-1].content

0 commit comments

Comments
 (0)