Skip to content

Commit bed456c

Browse files
authored
Merge branch 'dev' into fix/issue-565-ingestion-concurrency
2 parents a4286cb + 378d7bf commit bed456c

30 files changed

Lines changed: 2235 additions & 249 deletions

backend/app/config.py

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
import os
66
from pydantic_settings import BaseSettings
77
from functools import lru_cache
8-
8+
from pydantic import model_validator
99

1010
class Settings(BaseSettings):
1111
# ── App ──────────────────────────────────────────────
@@ -158,20 +158,14 @@ def cors_origins(self) -> list[str]:
158158
return [o.strip() for o in self.ALLOWED_ORIGINS.split(",")]
159159
return ["*"]
160160

161-
def validate_production(self):
162-
"""Raises ValueError if dangerous defaults are active in production."""
163-
if self.ENVIRONMENT != "production":
164-
return
165-
if self.SECRET_KEY in ("change-me-in-production-please", "dev-secret-key-change-me"):
166-
raise ValueError(
167-
"SECRET_KEY must be overridden in production. "
168-
"Generate one with: python -c \"import secrets; print(secrets.token_urlsafe(32))\""
169-
)
170-
if not self.FIELD_ENCRYPTION_KEY:
161+
@model_validator(mode="after")
162+
def validate_vision_provider_keys(self) -> "Settings":
163+
provider = self.VISION_PROVIDER.lower() if self.VISION_PROVIDER else None
164+
if provider == "openai" and not self.OPENAI_API_KEY:
171165
raise ValueError(
172-
"FIELD_ENCRYPTION_KEY must be set in production. "
173-
"Generate one with: python -c \"import secrets; print(secrets.token_urlsafe(32))\""
166+
"ValidationError: OPENAI_API_KEY is required when VISION_PROVIDER is set to 'openai'."
174167
)
168+
return self
175169

176170
class Config:
177171
env_file = ".env"

backend/app/email_service.py

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,156 @@ def send_email(to: str, subject: str, body: str, html: str | None = None) -> Non
4040
subject,
4141
body,
4242
)
43+
44+
45+
def send_workspace_invite_email(
46+
to: str,
47+
workspace_name: str,
48+
invite_link: str,
49+
expires_in_hours: int,
50+
personal_message: str | None = None,
51+
) -> None:
52+
"""Send a workspace invitation email with an HTML body.
53+
54+
Builds a branded HTML email containing the workspace name, an optional
55+
personal message from the inviter, a prominent call-to-action button with
56+
the acceptance link, and an expiry notice. Falls back to plain-text if HTML
57+
is not supported by the client. Delegates delivery to :func:`send_email`.
58+
59+
Args:
60+
to: Recipient email address.
61+
workspace_name: Name of the workspace the recipient is invited to.
62+
invite_link: Fully-qualified URL the recipient must visit to accept.
63+
expires_in_hours: How many hours until the invite token expires.
64+
personal_message: Optional personal note from the inviting admin.
65+
"""
66+
subject = f"You're invited to join workspace '{workspace_name}'"
67+
68+
# ── Plain-text fallback ───────────────────────────────────────────────
69+
plain_lines = [
70+
"Hello,",
71+
"",
72+
f"You have been invited to join the workspace '{workspace_name}'.",
73+
]
74+
if personal_message:
75+
plain_lines += ["", personal_message]
76+
plain_lines += [
77+
"",
78+
"Accept your invitation by visiting the link below:",
79+
invite_link,
80+
"",
81+
f"This invitation expires in {expires_in_hours} hours.",
82+
"",
83+
"If you did not expect this email, you can safely ignore it.",
84+
]
85+
plain_body = "\n".join(plain_lines)
86+
87+
# ── HTML body ─────────────────────────────────────────────────────────
88+
personal_block = ""
89+
if personal_message:
90+
personal_block = f"""
91+
<tr>
92+
<td style="padding:0 32px 20px;">
93+
<p style="margin:0;padding:16px;background:#f0f4ff;border-left:4px solid #4f46e5;
94+
border-radius:4px;font-size:14px;color:#374151;line-height:1.6;">
95+
{personal_message}
96+
</p>
97+
</td>
98+
</tr>"""
99+
100+
html_body = f"""<!DOCTYPE html>
101+
<html lang="en">
102+
<head>
103+
<meta charset="UTF-8" />
104+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
105+
<title>{subject}</title>
106+
</head>
107+
<body style="margin:0;padding:0;background:#f3f4f6;font-family:'Segoe UI',Arial,sans-serif;">
108+
<table width="100%" cellpadding="0" cellspacing="0" style="background:#f3f4f6;padding:40px 0;">
109+
<tr>
110+
<td align="center">
111+
<table width="600" cellpadding="0" cellspacing="0"
112+
style="background:#ffffff;border-radius:12px;
113+
box-shadow:0 4px 24px rgba(0,0,0,0.08);overflow:hidden;max-width:600px;">
114+
115+
<!-- Header -->
116+
<tr>
117+
<td style="background:linear-gradient(135deg,#4f46e5 0%,#7c3aed 100%);
118+
padding:36px 32px;text-align:center;">
119+
<h1 style="margin:0;font-size:24px;font-weight:700;color:#ffffff;letter-spacing:-0.5px;">
120+
&#128196; PDF Assistant
121+
</h1>
122+
<p style="margin:8px 0 0;font-size:13px;color:#c7d2fe;">
123+
Workspace Invitation
124+
</p>
125+
</td>
126+
</tr>
127+
128+
<!-- Body -->
129+
<tr>
130+
<td style="padding:36px 32px 20px;">
131+
<h2 style="margin:0 0 12px;font-size:20px;font-weight:600;color:#111827;">
132+
You've been invited!
133+
</h2>
134+
<p style="margin:0;font-size:15px;color:#6b7280;line-height:1.6;">
135+
You have been invited to join the workspace
136+
<strong style="color:#111827;">'{workspace_name}'</strong>
137+
on PDF Assistant. Accept below to start collaborating.
138+
</p>
139+
</td>
140+
</tr>
141+
142+
{personal_block}
143+
144+
<!-- CTA Button -->
145+
<tr>
146+
<td style="padding:8px 32px 32px;text-align:center;">
147+
<a href="{invite_link}"
148+
style="display:inline-block;padding:14px 36px;
149+
background:linear-gradient(135deg,#4f46e5 0%,#7c3aed 100%);
150+
color:#ffffff;font-size:15px;font-weight:600;
151+
text-decoration:none;border-radius:8px;
152+
box-shadow:0 4px 12px rgba(79,70,229,0.4);">
153+
Accept Invitation &#8594;
154+
</a>
155+
<p style="margin:16px 0 0;font-size:12px;color:#9ca3af;">
156+
Or copy this link into your browser:<br/>
157+
<span style="color:#4f46e5;word-break:break-all;">{invite_link}</span>
158+
</p>
159+
</td>
160+
</tr>
161+
162+
<!-- Expiry notice -->
163+
<tr>
164+
<td style="padding:0 32px 24px;">
165+
<p style="margin:0;padding:12px 16px;background:#fef3c7;border-radius:6px;
166+
font-size:13px;color:#92400e;text-align:center;">
167+
&#9203; This invitation expires in <strong>{expires_in_hours} hours</strong>.
168+
</p>
169+
</td>
170+
</tr>
171+
172+
<!-- Footer -->
173+
<tr>
174+
<td style="background:#f9fafb;padding:20px 32px;border-top:1px solid #e5e7eb;
175+
text-align:center;">
176+
<p style="margin:0;font-size:12px;color:#9ca3af;line-height:1.6;">
177+
If you did not expect this invitation, you can safely ignore this email.<br/>
178+
This email was sent by PDF Assistant &middot; No reply
179+
</p>
180+
</td>
181+
</tr>
182+
183+
</table>
184+
</td>
185+
</tr>
186+
</table>
187+
</body>
188+
</html>"""
189+
190+
send_email(to, subject, plain_body, html=html_body)
191+
logger.info(
192+
"Workspace invite email dispatched to %s for workspace '%s'",
193+
to,
194+
workspace_name,
195+
)

backend/app/main.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -228,9 +228,15 @@ def db_health():
228228
except Exception:
229229
chroma_status = "down"
230230

231-
overall_status = "ok" if db_status == "up" and chroma_status == "up" else "degraded"
232-
return{
233-
"status": db_status,
231+
if db_status == "up" and chroma_status == "up":
232+
overall_status = "healthy"
233+
elif db_status == "down" and chroma_status == "down":
234+
overall_status = "unhealthy"
235+
else:
236+
overall_status = "degraded"
237+
238+
return {
239+
"status": overall_status,
234240
"chroma": chroma_status,
235241
"db": db_status
236242
}

backend/app/rag/agent.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from app.config import get_settings
1616
from app.rag.retriever import retrieve
1717
from app.rag.graph_retriever import get_entity_context
18-
from app.rag.prompts import AGENT_SYSTEM_PROMPT
18+
from app.rag.prompts import AGENT_SYSTEM_PROMPT, MULTI_DOC_COMPARISON_GUIDANCE
1919
from app.exceptions import ExternalServiceException
2020
from app.rag.security import MALFORMED_OUTPUT_MESSAGE, OutputParserError, parse_agent_output
2121
from app.rag.tools import PDFSearchTool, MathTool, WebSearchTool
@@ -61,14 +61,15 @@ def _format_chat_history(messages: List[Dict[str, str]]) -> str:
6161
def get_agent_executor(
6262
user_id: str,
6363
document_id: Optional[str] = None,
64+
document_ids: Optional[List[str]] = None,
6465
hf_token: Optional[str] = None,
6566
top_k: Optional[int] = None,
6667
chat_history: Optional[List[Dict[str, str]]] = None,
6768
):
6869
"""Initialize the LangChain ReAct agent executor."""
6970

7071
# Initialize tools
71-
pdf_tool = PDFSearchTool(user_id=user_id, document_id=document_id, top_k=top_k)
72+
pdf_tool = PDFSearchTool(user_id=user_id, document_id=document_id, document_ids=document_ids, top_k=top_k)
7273
tools = [pdf_tool, MathTool(), WebSearchTool()]
7374

7475
# Initialize LLM
@@ -90,7 +91,14 @@ def get_agent_executor(
9091
chat_llm = ChatHuggingFace(llm=llm)
9192

9293
# Setup Agent
93-
prompt = PromptTemplate.from_template(AGENT_SYSTEM_PROMPT)
94+
agent_prompt_text = AGENT_SYSTEM_PROMPT
95+
if document_ids and len(document_ids) > 1:
96+
agent_prompt_text = agent_prompt_text.replace(
97+
"Begin!",
98+
MULTI_DOC_COMPARISON_GUIDANCE + "\nBegin!",
99+
1,
100+
)
101+
prompt = PromptTemplate.from_template(agent_prompt_text)
94102
agent = create_react_agent(chat_llm, tools, prompt)
95103

96104
executor = AgentExecutor(
@@ -127,6 +135,7 @@ def generate_answer(
127135
question: str,
128136
user_id: str,
129137
document_id: Optional[str] = None,
138+
document_ids: Optional[List[str]] = None,
130139
hf_token: Optional[str] = None,
131140
top_k: Optional[int] = None,
132141
chat_history: Optional[List[Dict[str, str]]] = None,
@@ -154,7 +163,7 @@ def generate_answer(
154163

155164
# ── Run Agent ────────────────────────────────────
156165
try:
157-
executor, pdf_tool, formatted_history = get_agent_executor(user_id, document_id, hf_token, top_k, chat_history)
166+
executor, pdf_tool, formatted_history = get_agent_executor(user_id, document_id, document_ids, hf_token, top_k, chat_history)
158167
result = executor.invoke({"input": question, "chat_history": formatted_history})
159168

160169
raw_answer = result.get("output", "")
@@ -199,6 +208,7 @@ def generate_answer_stream(
199208
question: str,
200209
user_id: str,
201210
document_id: Optional[str] = None,
211+
document_ids: Optional[List[str]] = None,
202212
hf_token: Optional[str] = None,
203213
top_k: Optional[int] = None,
204214
chat_history: Optional[List[Dict[str, str]]] = None,
@@ -227,7 +237,7 @@ def generate_answer_stream(
227237

228238
# ── Run Agent ────────────────────────────────────
229239
try:
230-
executor, pdf_tool, formatted_history = get_agent_executor(user_id, document_id, hf_token, top_k, chat_history)
240+
executor, pdf_tool, formatted_history = get_agent_executor(user_id, document_id, document_ids, hf_token, top_k, chat_history)
231241

232242
sources_sent = False
233243

backend/app/rag/prompts.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,3 +87,11 @@
8787
{chat_history}
8888
Question: {input}
8989
Thought: {agent_scratchpad}"""
90+
91+
MULTI_DOC_COMPARISON_GUIDANCE = """
92+
MULTI-DOCUMENT MODE:
93+
You are answering across multiple documents at once. When findings differ or overlap between documents:
94+
- Attribute each finding to its specific source document using [Source: filename, Page X].
95+
- Explicitly note where documents agree and where they disagree or report different figures.
96+
- Do not blend numbers or claims from different documents without making the source of each clear.
97+
"""

backend/app/rag/retriever.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ def _merge_candidates(candidates: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
211211

212212
@trace_function(
213213
"retrieve",
214-
metadata_factory=lambda query, user_id, document_id=None, top_k=None: {
214+
metadata_factory=lambda query, user_id, document_id=None, document_ids=None, top_k=None: {
215215
"user_id": user_id,
216216
"document_id": document_id,
217217
"embedding_model": settings.EMBEDDING_MODEL,
@@ -226,6 +226,7 @@ def retrieve(
226226
query: str,
227227
user_id: str,
228228
document_id: Optional[str] = None,
229+
document_ids: Optional[List[str]] = None,
229230
top_k: Optional[int] = None,
230231
) -> List[Dict[str, Any]]:
231232
"""
@@ -250,6 +251,7 @@ def retrieve(
250251
query_embedding=query_vector,
251252
user_id=user_id,
252253
document_id=document_id,
254+
document_ids=document_ids,
253255
top_k=effective_top_k,
254256
)
255257

@@ -260,6 +262,7 @@ def retrieve(
260262
query=search_query,
261263
user_id=user_id,
262264
document_id=document_id,
265+
document_ids=document_ids,
263266
top_k=effective_top_k,
264267
)
265268
except Exception as exc:

backend/app/rag/summarizer.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,15 @@ def generate_document_summary(
6666
max_tokens=settings.SUMMARY_MAX_TOKENS,
6767
temperature=settings.LLM_TEMPERATURE,
6868
)
69-
summary = response.choices[0].message.content.strip() if response.choices else None
69+
70+
# Defensive check for malformed or empty response structures
71+
summary = None
72+
if response and getattr(response, "choices", None):
73+
first_choice = response.choices[0]
74+
message = getattr(first_choice, "message", None)
75+
content = getattr(message, "content", None)
76+
if content:
77+
summary = content.strip()
7078

7179
return summary or None
7280

backend/app/rag/tools.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ class PDFSearchTool(BaseTool):
156156

157157
user_id: str
158158
document_id: Optional[str] = None
159+
document_ids: Optional[List[str]] = None
159160
top_k: Optional[int] = None
160161
# We'll store sources here to retrieve them after agent execution
161162
last_sources: List[Dict[str, Any]] = []
@@ -167,6 +168,7 @@ def _run(self, query: str) -> str:
167168
query=query,
168169
user_id=self.user_id,
169170
document_id=self.document_id,
171+
document_ids=self.document_ids,
170172
top_k=self.top_k,
171173
)
172174

0 commit comments

Comments
 (0)