Upload 2 files
Browse files- src/api/admin.py +38 -4
- src/api/agent.py +57 -3
src/api/admin.py
CHANGED
|
@@ -40,6 +40,15 @@ _CONFIG_DEFAULTS: Dict[str, Any] = {
|
|
| 40 |
|
| 41 |
admin_sessions: Dict[str, float] = {}
|
| 42 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
def _sessions_file() -> str:
|
| 45 |
cfg_path = os.environ.get("CONFIG_STORE_PATH", "")
|
|
@@ -138,16 +147,41 @@ class ConfigBulkUpdate(BaseModel):
|
|
| 138 |
# ββ Auth Endpoints βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 139 |
|
| 140 |
@router.post("/auth")
|
| 141 |
-
async def admin_auth(req: LoginRequest):
|
| 142 |
settings = get_settings()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
if not settings.admin_password or req.password != settings.admin_password:
|
| 144 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
token = secrets.token_hex(32)
|
| 146 |
admin_sessions[token] = time.time() + 86400
|
| 147 |
_save_sessions()
|
| 148 |
resp = Response(content='{"status":"ok"}', media_type="application/json")
|
| 149 |
resp.set_cookie(key="admin_session", value=token, httponly=True,
|
| 150 |
-
max_age=86400, samesite="lax")
|
| 151 |
return resp
|
| 152 |
|
| 153 |
|
|
@@ -208,7 +242,7 @@ async function doLogin(e){
|
|
| 208 |
const pw=document.getElementById('pw').value;
|
| 209 |
const r=await fetch('/admin/auth',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({password:pw})});
|
| 210 |
if(r.ok){window.location.href='/admin';}
|
| 211 |
-
else{document.getElementById('err').style.display='block';document.getElementById('pw').value='';document.getElementById('pw').focus();}
|
| 212 |
return false;
|
| 213 |
}
|
| 214 |
</script></body></html>""")
|
|
|
|
| 40 |
|
| 41 |
admin_sessions: Dict[str, float] = {}
|
| 42 |
|
| 43 |
+
# ββ Admin login lockout ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 44 |
+
# Tracks failed login attempts per IP.
|
| 45 |
+
# After _MAX_LOGIN_ATTEMPTS failures within _LOCKOUT_WINDOW seconds,
|
| 46 |
+
# that IP is locked out for _LOCKOUT_DURATION seconds.
|
| 47 |
+
_login_attempts: Dict[str, list] = {} # ip β [timestamp, ...]
|
| 48 |
+
_MAX_LOGIN_ATTEMPTS = 5
|
| 49 |
+
_LOCKOUT_WINDOW = 300 # 5 minutes β window in which attempts are counted
|
| 50 |
+
_LOCKOUT_DURATION = 900 # 15 minutes β lockout duration after max attempts
|
| 51 |
+
|
| 52 |
|
| 53 |
def _sessions_file() -> str:
|
| 54 |
cfg_path = os.environ.get("CONFIG_STORE_PATH", "")
|
|
|
|
| 147 |
# ββ Auth Endpoints βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 148 |
|
| 149 |
@router.post("/auth")
|
| 150 |
+
async def admin_auth(req: LoginRequest, request: Request):
|
| 151 |
settings = get_settings()
|
| 152 |
+
client_ip = request.client.host if request.client else "unknown"
|
| 153 |
+
now = time.time()
|
| 154 |
+
|
| 155 |
+
# ββ Lockout check βββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 156 |
+
attempts = _login_attempts.get(client_ip, [])
|
| 157 |
+
# Purge attempts outside the lockout window
|
| 158 |
+
attempts = [t for t in attempts if now - t < _LOCKOUT_WINDOW]
|
| 159 |
+
if len(attempts) >= _MAX_LOGIN_ATTEMPTS:
|
| 160 |
+
wait = int(_LOCKOUT_DURATION - (now - attempts[0]))
|
| 161 |
+
raise HTTPException(
|
| 162 |
+
status_code=429,
|
| 163 |
+
detail=f"Too many failed login attempts. Try again in {wait} seconds."
|
| 164 |
+
)
|
| 165 |
+
|
| 166 |
+
# ββ Password check ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 167 |
if not settings.admin_password or req.password != settings.admin_password:
|
| 168 |
+
attempts.append(now)
|
| 169 |
+
_login_attempts[client_ip] = attempts
|
| 170 |
+
remaining = _MAX_LOGIN_ATTEMPTS - len(attempts)
|
| 171 |
+
if remaining <= 0:
|
| 172 |
+
detail = f"Too many failed login attempts. Try again in {int(_LOCKOUT_DURATION / 60)} minutes."
|
| 173 |
+
else:
|
| 174 |
+
detail = f"Invalid password. {remaining} attempt{'s' if remaining != 1 else ''} remaining before lockout."
|
| 175 |
+
raise HTTPException(status_code=401, detail=detail)
|
| 176 |
+
|
| 177 |
+
# ββ Success β clear attempts and issue session βββββββββββββββββββββββββββββ
|
| 178 |
+
_login_attempts.pop(client_ip, None)
|
| 179 |
token = secrets.token_hex(32)
|
| 180 |
admin_sessions[token] = time.time() + 86400
|
| 181 |
_save_sessions()
|
| 182 |
resp = Response(content='{"status":"ok"}', media_type="application/json")
|
| 183 |
resp.set_cookie(key="admin_session", value=token, httponly=True,
|
| 184 |
+
max_age=86400, samesite="lax", secure=True)
|
| 185 |
return resp
|
| 186 |
|
| 187 |
|
|
|
|
| 242 |
const pw=document.getElementById('pw').value;
|
| 243 |
const r=await fetch('/admin/auth',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({password:pw})});
|
| 244 |
if(r.ok){window.location.href='/admin';}
|
| 245 |
+
else{const data=await r.json().catch(()=>({}));const msg=data.detail||'Incorrect password. Try again.';const e=document.getElementById('err');e.textContent=msg;e.style.display='block';document.getElementById('pw').value='';document.getElementById('pw').focus();}
|
| 246 |
return false;
|
| 247 |
}
|
| 248 |
</script></body></html>""")
|
src/api/agent.py
CHANGED
|
@@ -223,6 +223,39 @@ CURRENT_INFO_RE = re.compile(
|
|
| 223 |
r"|news|update|updated|prime minister|ceo|minister|king|ruler|who won)\b",
|
| 224 |
re.IGNORECASE,
|
| 225 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
SUMMARY_RE = re.compile(
|
| 227 |
r"\b(summar(y|ize|ise|izing|ising)|overview|explain|what is|what are|describe"
|
| 228 |
r"|tell me about|give me a summary|give me an overview|give me a brief"
|
|
@@ -1112,7 +1145,7 @@ async def _libbee_casual_response(question: str, history: List[ChatMessage], mod
|
|
| 1112 |
msgs = [{"role": "system", "content": (
|
| 1113 |
"You are LibBee, the Khalifa University Library AI Assistant. "
|
| 1114 |
"Respond warmly and naturally in 1-3 sentences as a friendly librarian. "
|
| 1115 |
-
"No markdown, no bullet points."
|
| 1116 |
)}]
|
| 1117 |
_cfg = _get_runtime_config()
|
| 1118 |
_ci = _cfg.get("custom_instructions", "").strip()
|
|
@@ -1137,7 +1170,8 @@ async def _rag_answer(question: str, rag_results: List[dict], history: List[Chat
|
|
| 1137 |
"Be concise, friendly, and use HTML <br> for line breaks where needed. "
|
| 1138 |
f"If the context does not contain enough information, say so and suggest Ask a Librarian at {ASK_LIBRARIAN_URL}. "
|
| 1139 |
"Never invent facts or URLs.\n\n"
|
| 1140 |
-
f"CONTEXT:\n{context}"
|
|
|
|
| 1141 |
)
|
| 1142 |
_cfg = _get_runtime_config()
|
| 1143 |
_ci = _cfg.get("custom_instructions", "").strip()
|
|
@@ -1168,7 +1202,8 @@ async def _llm_general_answer(question: str, history: List[ChatMessage], model:
|
|
| 1168 |
system = (
|
| 1169 |
"You are LibBee, the Khalifa University Library AI Assistant (Abu Dhabi, UAE). "
|
| 1170 |
"Answer factually and concisely in 3-5 sentences. Use HTML <br> for line breaks. "
|
| 1171 |
-
"KU means Khalifa University. If the topic is academic, briefly offer to help find library resources."
|
|
|
|
| 1172 |
)
|
| 1173 |
_cfg = _get_runtime_config()
|
| 1174 |
_ci = _cfg.get("custom_instructions", "").strip()
|
|
@@ -3765,6 +3800,25 @@ async def _agent_query_inner(
|
|
| 3765 |
start_time: float,
|
| 3766 |
) -> AgentResponse:
|
| 3767 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3768 |
if not question:
|
| 3769 |
follow_up_question, follow_up_suggestions = _social_follow_up()
|
| 3770 |
return AgentResponse(
|
|
|
|
| 223 |
r"|news|update|updated|prime minister|ceo|minister|king|ruler|who won)\b",
|
| 224 |
re.IGNORECASE,
|
| 225 |
)
|
| 226 |
+
|
| 227 |
+
# ββ Academic guardrail β appended to all LLM system prompts ββββββββββββββββββ
|
| 228 |
+
_GUARDRAIL = (
|
| 229 |
+
"IMPORTANT β GUARDRAILS: "
|
| 230 |
+
"You must never make negative, critical, or controversial statements about "
|
| 231 |
+
"Khalifa University, its leadership, staff, or policies; the UAE government, "
|
| 232 |
+
"its leaders, laws, or institutions; or any religion. "
|
| 233 |
+
"If asked for opinions on these topics, politely decline and redirect to library services. "
|
| 234 |
+
"Never generate content that is politically sensitive, religiously offensive, "
|
| 235 |
+
"or inappropriate for a university academic environment. "
|
| 236 |
+
"You may answer general factual questions β such as historical facts, science, "
|
| 237 |
+
"geography, or current affairs β but always from a neutral, academic perspective. "
|
| 238 |
+
"Never produce content that could embarrass Khalifa University or its library."
|
| 239 |
+
)
|
| 240 |
+
|
| 241 |
+
# ββ Prompt injection detection ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 242 |
+
_INJECTION_RE = re.compile(
|
| 243 |
+
r"\b(ignore\s+(previous|above|all|prior)\s+instructions?"
|
| 244 |
+
r"|disregard\s+(all|previous|above|prior)\s+instructions?"
|
| 245 |
+
r"|override\s+(system|instructions?|prompt)"
|
| 246 |
+
r"|new\s+instructions?\s*(are|follow|:|;)"
|
| 247 |
+
r"|you\s+are\s+now\s+(a|an|the|going)"
|
| 248 |
+
r"|act\s+as\s+(a|an|if|though|when)\b"
|
| 249 |
+
r"|act\s+like\s+(a|an)\s+\w"
|
| 250 |
+
r"|pretend\s+(you\s+are|to\s+be)"
|
| 251 |
+
r"|jailbreak|dan\s+mode|developer\s+mode|god\s+mode"
|
| 252 |
+
r"|system\s+prompt\s*(is|was|says|leak|show|reveal|print|output)"
|
| 253 |
+
r"|reveal\s+(your\s+)?(system\s+)?prompt"
|
| 254 |
+
r"|print\s+(your\s+)?(system\s+)?instructions?"
|
| 255 |
+
r"|forget\s+(you\s+are|your\s+role|everything)"
|
| 256 |
+
r"|do\s+anything\s+now|no\s+restrictions?)\b",
|
| 257 |
+
re.IGNORECASE,
|
| 258 |
+
)
|
| 259 |
SUMMARY_RE = re.compile(
|
| 260 |
r"\b(summar(y|ize|ise|izing|ising)|overview|explain|what is|what are|describe"
|
| 261 |
r"|tell me about|give me a summary|give me an overview|give me a brief"
|
|
|
|
| 1145 |
msgs = [{"role": "system", "content": (
|
| 1146 |
"You are LibBee, the Khalifa University Library AI Assistant. "
|
| 1147 |
"Respond warmly and naturally in 1-3 sentences as a friendly librarian. "
|
| 1148 |
+
"No markdown, no bullet points. " + _GUARDRAIL
|
| 1149 |
)}]
|
| 1150 |
_cfg = _get_runtime_config()
|
| 1151 |
_ci = _cfg.get("custom_instructions", "").strip()
|
|
|
|
| 1170 |
"Be concise, friendly, and use HTML <br> for line breaks where needed. "
|
| 1171 |
f"If the context does not contain enough information, say so and suggest Ask a Librarian at {ASK_LIBRARIAN_URL}. "
|
| 1172 |
"Never invent facts or URLs.\n\n"
|
| 1173 |
+
f"CONTEXT:\n{context}\n\n"
|
| 1174 |
+
+ _GUARDRAIL
|
| 1175 |
)
|
| 1176 |
_cfg = _get_runtime_config()
|
| 1177 |
_ci = _cfg.get("custom_instructions", "").strip()
|
|
|
|
| 1202 |
system = (
|
| 1203 |
"You are LibBee, the Khalifa University Library AI Assistant (Abu Dhabi, UAE). "
|
| 1204 |
"Answer factually and concisely in 3-5 sentences. Use HTML <br> for line breaks. "
|
| 1205 |
+
"KU means Khalifa University. If the topic is academic, briefly offer to help find library resources. "
|
| 1206 |
+
+ _GUARDRAIL
|
| 1207 |
)
|
| 1208 |
_cfg = _get_runtime_config()
|
| 1209 |
_ci = _cfg.get("custom_instructions", "").strip()
|
|
|
|
| 3800 |
start_time: float,
|
| 3801 |
) -> AgentResponse:
|
| 3802 |
|
| 3803 |
+
# ββ Prompt injection detection ββββββββββββββββββββββββββββββββββββββββββββ
|
| 3804 |
+
# Fires before any processing. Catches attempts to override system prompt,
|
| 3805 |
+
# jailbreak, or extract internal instructions.
|
| 3806 |
+
if question and _INJECTION_RE.search(question):
|
| 3807 |
+
_safe_metrics_bucket("intents", "injection_blocked")
|
| 3808 |
+
return AgentResponse(
|
| 3809 |
+
answer=(
|
| 3810 |
+
"I'm sorry, but I can't process that request. "
|
| 3811 |
+
"I'm LibBee, the KU Library AI Assistant β here to help with "
|
| 3812 |
+
"library services, research, and academic resources. "
|
| 3813 |
+
f'How can I help you today? <a href="{ASK_LIBRARIAN_URL}" target="_blank">Ask a Librarian</a>'
|
| 3814 |
+
),
|
| 3815 |
+
intent="blocked",
|
| 3816 |
+
tools_used=["injection_guard"],
|
| 3817 |
+
model_used=model,
|
| 3818 |
+
response_time=time.time() - start_time,
|
| 3819 |
+
)
|
| 3820 |
+
# βββββββββββββββββββββββββββββββοΏ½οΏ½βββββββββββββββββββββββββββββββββββββββββ
|
| 3821 |
+
|
| 3822 |
if not question:
|
| 3823 |
follow_up_question, follow_up_suggestions = _social_follow_up()
|
| 3824 |
return AgentResponse(
|