Noor2623 commited on
Commit
eeff6a0
·
1 Parent(s): 39a56a6

Add Gemini query routing and department-scoped ticket access

Browse files
app/schemas/department.py CHANGED
@@ -13,5 +13,4 @@ class DepartmentCreate(BaseModel):
13
  class DepartmentResponse(DepartmentCreate):
14
  id: int
15
  is_active: bool
16
-
17
  model_config = ConfigDict(from_attributes=True)
 
13
  class DepartmentResponse(DepartmentCreate):
14
  id: int
15
  is_active: bool
 
16
  model_config = ConfigDict(from_attributes=True)
app/services/classification_service.py CHANGED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import logging
3
+ import os
4
+
5
+ import httpx
6
+ from google import genai
7
+ from google.genai import errors, types
8
+ from pydantic import BaseModel, Field
9
+ from sqlalchemy.orm import Session
10
+
11
+ import app.config # Loads Backend/.env
12
+ from app.models.department import Department
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class ClassificationResult(BaseModel):
18
+ department_id: int | None
19
+ confidence: float = Field(ge=0, le=1)
20
+
21
+
22
+ def classify_query(
23
+ db: Session,
24
+ message: str,
25
+ ) -> tuple[Department, float] | None:
26
+ """Return a validated Gemini result, or None to use keyword routing."""
27
+ gemini_keys = [
28
+ os.getenv("GEMINI_API_KEY_1"),
29
+ os.getenv("GEMINI_API_KEY_2"),
30
+ os.getenv("GEMINI_API_KEY_3"),
31
+ os.getenv("GEMINI_API_KEY_4"),
32
+ os.getenv("GEMINI_API_KEY_5"),
33
+ os.getenv("GEMINI_API_KEY_6"),
34
+ os.getenv("GEMINI_API_KEY_7"),
35
+ os.getenv("GEMINI_API_KEY_8"),
36
+ os.getenv("GEMINI_API_KEY_9"),
37
+ ]
38
+
39
+
40
+ gemini_keys = [
41
+ key for key in gemini_keys if key
42
+ ]
43
+
44
+
45
+ api_key = gemini_keys[0] if gemini_keys else None
46
+ if not api_key:
47
+ logger.warning("Gemini key missing; using keyword routing.")
48
+ return None
49
+
50
+ departments = (
51
+ db.query(Department)
52
+ .filter(Department.is_active.is_(True))
53
+ .all()
54
+ )
55
+ if not departments:
56
+ return None
57
+
58
+ department_data = [
59
+ {
60
+ "id": department.id,
61
+ "name": department.name,
62
+ "code": department.code,
63
+ "description": department.description or "",
64
+ "keywords": department.keywords or "",
65
+ "example_queries": department.query or "",
66
+ }
67
+ for department in departments
68
+ ]
69
+
70
+ try:
71
+ with genai.Client(
72
+ api_key=api_key,
73
+ http_options=types.HttpOptions(
74
+ timeout=15000,
75
+ retry_options=types.HttpRetryOptions(attempts=1),
76
+ ),
77
+ ) as client:
78
+ response = client.models.generate_content(
79
+ model="gemini-3.6-flash",
80
+ contents=json.dumps({
81
+ "departments": department_data,
82
+ "student_message": message,
83
+ }),
84
+ config=types.GenerateContentConfig(
85
+ system_instruction=(
86
+ "You are an AI query routing assistant for a university. "
87
+ "Classify student queries and select the most suitable department. "
88
+ "Use only the supplied department information. "
89
+ "Return only the department_id and confidence score. "
90
+ "Do not answer the student query."
91
+ ),
92
+ response_mime_type="application/json",
93
+ response_schema=ClassificationResult,
94
+ automatic_function_calling=(
95
+ types.AutomaticFunctionCallingConfig(disable=True)
96
+ ),
97
+ ),
98
+ )
99
+
100
+ result = ClassificationResult.model_validate_json(
101
+ response.text or ""
102
+ )
103
+
104
+ department = next(
105
+ (
106
+ department
107
+ for department in departments
108
+ if department.id == result.department_id
109
+ ),
110
+ None,
111
+ )
112
+
113
+ if department is None:
114
+ logger.warning(
115
+ "Gemini returned no valid department; using keyword routing."
116
+ )
117
+ return None
118
+
119
+ return department, result.confidence
120
+
121
+ except (errors.APIError, httpx.HTTPError, ValueError) as exc:
122
+ # Log the error type without exposing credentials or query content.
123
+ logger.warning(
124
+ "Gemini classification failed (%s); using keyword routing.",
125
+ type(exc).__name__,
126
+ )
127
+ return None
app/services/query_service.py CHANGED
@@ -4,6 +4,7 @@ from app.models.query import Query
4
  from app.models.user import User
5
  from app.schemas.query import QueryCreate
6
  from app.services.routing_service import match_department
 
7
 
8
 
9
  def _confidence_from_score(score: int) -> float:
@@ -17,7 +18,14 @@ def create_query(db: Session, *, current_user: User, payload: QueryCreate) -> Qu
17
  if not message:
18
  raise ValueError("Query message cannot be empty")
19
 
20
- department, score = match_department(db, message)
 
 
 
 
 
 
 
21
  query = Query(
22
  student_id=current_user.id,
23
  sender_email=current_user.email,
@@ -25,7 +33,7 @@ def create_query(db: Session, *, current_user: User, payload: QueryCreate) -> Qu
25
  department_id=department.id if department is not None else None,
26
  status="Routed" if department is not None else "Open",
27
  priority="Normal",
28
- confidence=_confidence_from_score(score),
29
  )
30
  db.add(query)
31
  db.commit()
@@ -34,10 +42,19 @@ def create_query(db: Session, *, current_user: User, payload: QueryCreate) -> Qu
34
 
35
 
36
  def list_queries_for_user(db: Session, *, current_user: User) -> list[Query]:
 
 
 
 
 
 
 
 
 
 
 
37
  return (
38
- db.query(Query)
39
- .options(selectinload(Query.department))
40
- .filter(Query.student_id == current_user.id)
41
  .order_by(Query.created_at.desc(), Query.id.desc())
42
  .all()
43
  )
 
4
  from app.models.user import User
5
  from app.schemas.query import QueryCreate
6
  from app.services.routing_service import match_department
7
+ from app.services.classification_service import classify_query
8
 
9
 
10
  def _confidence_from_score(score: int) -> float:
 
18
  if not message:
19
  raise ValueError("Query message cannot be empty")
20
 
21
+ classification = classify_query(db, message)
22
+
23
+ if classification is None:
24
+ department, score = match_department(db, message)
25
+ confidence = _confidence_from_score(score)
26
+ else:
27
+ department, confidence = classification
28
+
29
  query = Query(
30
  student_id=current_user.id,
31
  sender_email=current_user.email,
 
33
  department_id=department.id if department is not None else None,
34
  status="Routed" if department is not None else "Open",
35
  priority="Normal",
36
+ confidence=confidence,
37
  )
38
  db.add(query)
39
  db.commit()
 
42
 
43
 
44
  def list_queries_for_user(db: Session, *, current_user: User) -> list[Query]:
45
+ queries = db.query(Query).options(selectinload(Query.department))
46
+ if current_user.is_admin:
47
+ return queries.order_by(Query.created_at.desc(), Query.id.desc()).all()
48
+ if current_user.role.strip().lower() == "student":
49
+ queries = queries.filter(Query.student_id == current_user.id)
50
+ elif current_user.department_id is not None:
51
+ queries = queries.filter(Query.department_id == current_user.department_id)
52
+ else:
53
+ # Staff without an assigned department cannot read unassigned queries.
54
+ return []
55
+
56
  return (
57
+ queries
 
 
58
  .order_by(Query.created_at.desc(), Query.id.desc())
59
  .all()
60
  )
app/services/routing_service.py CHANGED
@@ -6,15 +6,26 @@ from app.models.department import Department
6
 
7
 
8
  def _normalize_words(value: str | None) -> set[str]:
9
- if not value:
10
- return set()
11
-
12
- return {word for word in re.split(r"[^a-zA-Z0-9]+", value.lower()) if word}
 
 
 
 
 
 
 
 
 
 
 
13
 
14
 
15
  def score_department(department: Department, message: str) -> int:
16
  query_value = message.lower().strip()
17
- if not query_value:
18
  return 0
19
 
20
  score = 0
@@ -67,4 +78,4 @@ def match_department(db: Session, message: str) -> tuple[Department | None, int]
67
  if fallback_department is not None:
68
  return fallback_department, 0
69
 
70
- return (departments[0], 0) if departments else (None, 0)
 
6
 
7
 
8
  def _normalize_words(value: str | None) -> set[str]:
9
+ stop_words = {
10
+ "i", "me", "my", "we", "our", "you", "your",
11
+ "a", "an", "the", "is", "are", "am", "was", "were",
12
+ "be", "been", "have", "has", "had", "do", "does",
13
+ "and", "or", "but", "to", "of", "for", "in",
14
+ "on", "at", "with", "this", "that", "these", "those",
15
+ "please", "dear", "sir", "thank", "thanks", "also",
16
+ "want", "need", "can", "could", "would",
17
+ }
18
+
19
+ return {
20
+ word
21
+ for word in re.split(r"[^a-zA-Z0-9]+", (value or "").lower())
22
+ if word and word not in stop_words
23
+ }
24
 
25
 
26
  def score_department(department: Department, message: str) -> int:
27
  query_value = message.lower().strip()
28
+ if not _normalize_words(query_value):
29
  return 0
30
 
31
  score = 0
 
78
  if fallback_department is not None:
79
  return fallback_department, 0
80
 
81
+ return None, 0
requirements.txt CHANGED
@@ -4,3 +4,4 @@ sqlalchemy
4
  psycopg2-binary
5
  bcrypt
6
  PyJWT
 
 
4
  psycopg2-binary
5
  bcrypt
6
  PyJWT
7
+ google-genai
tests/test_permissions.py CHANGED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ from fastapi import FastAPI
3
+ from fastapi.testclient import TestClient
4
+ from sqlalchemy import create_engine
5
+ from sqlalchemy.orm import sessionmaker
6
+ from sqlalchemy.pool import StaticPool
7
+
8
+ from app.core.database import Base
9
+ from app.core.security import create_access_token, hash_password
10
+ from app.dependencies import get_db
11
+ from app.models.department import Department
12
+ from app.models.query import Query
13
+ from app.models.user import User
14
+ from app.routes import admin, auth, queries
15
+
16
+
17
+ @pytest.fixture(scope="module")
18
+ def password_hash():
19
+ return hash_password("test-password-123")
20
+
21
+
22
+ @pytest.fixture
23
+ def workspace(password_hash):
24
+ engine = create_engine(
25
+ "sqlite+pysqlite:///:memory:",
26
+ connect_args={"check_same_thread": False},
27
+ poolclass=StaticPool,
28
+ )
29
+ Base.metadata.create_all(engine)
30
+ with sessionmaker(bind=engine)() as db:
31
+ departments = [
32
+ Department(id=11, name="Examination Department", code="EXAM", keywords="exam"),
33
+ Department(id=22, name="Finance Department", code="FIN", keywords="fees"),
34
+ Department(id=33, name="Registrar", code="REG", keywords="transcript"),
35
+ Department(id=44, name="IT Dept", code="IT", keywords="wifi"),
36
+ Department(id=55, name="Instructor", code="INST", keywords="feedback"),
37
+ Department(id=66, name="Admin", code="ADMIN", keywords="general"),
38
+ ]
39
+ db.add_all(departments)
40
+ db.flush()
41
+ users = {
42
+ "student": User(email="student@example.com", role="STUDENT"),
43
+ "other_student": User(email="other@example.com", role="STUDENT"),
44
+ "admin": User(email="admin@example.com", role="ADMIN", is_admin=True),
45
+ "unassigned_staff": User(email="unassigned@example.com", role="STAFF"),
46
+ }
47
+ for department in departments:
48
+ users[department.code] = User(
49
+ email=f"staff{department.id}@example.com",
50
+ role=department.name,
51
+ department_id=department.id,
52
+ )
53
+ for user in users.values():
54
+ user.hashed_password = password_hash
55
+ db.add_all(users.values())
56
+ db.flush()
57
+ for query_id, department in enumerate(departments, start=101):
58
+ db.add(Query(
59
+ id=query_id, student_id=users["student"].id,
60
+ sender_email=users["student"].email,
61
+ message=f"Question for {department.name}",
62
+ department_id=department.id,
63
+ ))
64
+ db.add_all([
65
+ Query(id=107, student_id=users["other_student"].id, sender_email="other@example.com",
66
+ message="Another student's fees query", department_id=22),
67
+ Query(id=108, student_id=users["other_student"].id, sender_email="other@example.com",
68
+ message="Unassigned query"),
69
+ Query(id=109, student_id=users["FIN"].id, sender_email=users["FIN"].email,
70
+ message="Staff's own exam question", department_id=11),
71
+ ])
72
+ db.commit()
73
+
74
+ app = FastAPI()
75
+ for router in (auth.router, queries.router, admin.router):
76
+ app.include_router(router)
77
+
78
+ def test_db():
79
+ yield db
80
+
81
+ app.dependency_overrides[get_db] = test_db
82
+ with TestClient(app) as client:
83
+ yield client, db, users
84
+ engine.dispose()
85
+
86
+
87
+ def headers(user):
88
+ return {"Authorization": f"Bearer {create_access_token(str(user.id))}"}
89
+
90
+
91
+ @pytest.mark.parametrize(("account", "expected_ids"), [
92
+ ("EXAM", {101, 109}), ("FIN", {102, 107}), ("REG", {103}),
93
+ ("IT", {104}), ("INST", {105}), ("ADMIN", {106}),
94
+ ])
95
+ def test_department_staff_only_receive_assigned_queries(workspace, account, expected_ids):
96
+ client, _, users = workspace
97
+ response = client.get("/queries/", headers=headers(users[account]))
98
+ assert response.status_code == 200
99
+ assert {item["id"] for item in response.json()} == expected_ids
100
+
101
+
102
+ @pytest.mark.parametrize("path", ["/queries/", "/admin/queries"])
103
+ def test_admin_receives_all_departments_and_unassigned_queries(workspace, path):
104
+ client, _, users = workspace
105
+ response = client.get(path, headers=headers(users["admin"]))
106
+ assert response.status_code == 200
107
+ assert {item["id"] for item in response.json()} == set(range(101, 110))
108
+
109
+
110
+ @pytest.mark.parametrize(("account", "expected_ids"), [
111
+ ("student", {101, 102, 103, 104, 105, 106}), ("other_student", {107, 108}),
112
+ ])
113
+ def test_students_only_receive_their_own_queries(workspace, account, expected_ids):
114
+ client, _, users = workspace
115
+ response = client.get("/queries/", headers=headers(users[account]))
116
+ assert response.status_code == 200
117
+ assert {item["id"] for item in response.json()} == expected_ids
118
+
119
+
120
+ def test_student_role_does_not_grant_department_access(workspace):
121
+ client, db, users = workspace
122
+ users["student"].department_id = 22
123
+ db.commit()
124
+ response = client.get("/queries/", headers=headers(users["student"]))
125
+ assert {item["id"] for item in response.json()} == {101, 102, 103, 104, 105, 106}
126
+
127
+
128
+ def test_staff_without_department_cannot_see_unassigned_queries(workspace):
129
+ client, _, users = workspace
130
+ response = client.get("/queries/", headers=headers(users["unassigned_staff"]))
131
+ assert response.status_code == 200
132
+ assert response.json() == []
133
+
134
+
135
+ @pytest.mark.parametrize("account", ["EXAM", "FIN", "REG", "IT", "INST", "ADMIN", "student"])
136
+ def test_only_admin_can_use_all_queries_endpoint(workspace, account):
137
+ client, _, users = workspace
138
+ assert client.get("/admin/queries", headers=headers(users[account])).status_code == 403
139
+
140
+
141
+ @pytest.mark.parametrize("path", ["/queries/", "/admin/queries"])
142
+ def test_reading_queries_requires_login(workspace, path):
143
+ client, _, _ = workspace
144
+ assert client.get(path).status_code == 401
145
+
146
+
147
+ def test_request_parameters_cannot_change_a_staff_members_scope(workspace):
148
+ client, _, users = workspace
149
+ response = client.get("/queries/?department_id=11&role=Admin&is_admin=true",
150
+ headers=headers(users["FIN"]))
151
+ assert response.status_code == 200
152
+ assert {item["id"] for item in response.json()} == {102, 107}
153
+
154
+
155
+ @pytest.mark.parametrize(("keyword", "department_id", "staff_account"), [
156
+ ("exam", 11, "EXAM"), ("fees", 22, "FIN"), ("transcript", 33, "REG"),
157
+ ("wifi", 44, "IT"), ("feedback", 55, "INST"), ("general", 66, "ADMIN"),
158
+ ])
159
+ def test_submitted_query_is_visible_to_its_department_student_and_admin(workspace, keyword, department_id, staff_account):
160
+ client, _, users = workspace
161
+ response = client.post("/queries/", headers=headers(users["student"]), json={"message": keyword})
162
+ assert response.status_code == 201
163
+ query_id = response.json()["id"]
164
+ assert response.json()["department_id"] == department_id
165
+ for account in ("student", staff_account, "admin"):
166
+ response = client.get("/queries/", headers=headers(users[account]))
167
+ assert query_id in {item["id"] for item in response.json()}
168
+ for account in ("EXAM", "FIN", "REG", "IT", "INST", "ADMIN", "other_student"):
169
+ if account != staff_account:
170
+ response = client.get("/queries/", headers=headers(users[account]))
171
+ assert query_id not in {item["id"] for item in response.json()}
172
+
173
+
174
+ def test_login_uses_saved_account_role_for_query_access(workspace):
175
+ client, _, users = workspace
176
+ response = client.post("/auth/login", json={
177
+ "email": users["FIN"].email, "password": "test-password-123",
178
+ "selectedRole": "Admin", "is_admin": True,
179
+ })
180
+ assert response.status_code == 200
181
+ assert response.json()["user"]["is_admin"] is False
182
+ assert response.json()["user"]["department_id"] == 22
183
+ token_headers = {"Authorization": f"Bearer {response.json()['access_token']}"}
184
+ response = client.get("/queries/", headers=token_headers)
185
+ assert {item["id"] for item in response.json()} == {102, 107}
186
+ assert client.get("/admin/queries", headers=token_headers).status_code == 403
tests/test_routing.py CHANGED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pytest
2
+ from sqlalchemy import create_engine
3
+ from sqlalchemy.orm import Session
4
+
5
+ from app.core.database import Base
6
+ from app.models.department import Department
7
+ from app.services.routing_service import match_department
8
+
9
+
10
+ @pytest.mark.parametrize("include_finance", [False, True])
11
+ def test_unmatched_query_without_admin_stays_unassigned(include_finance):
12
+ engine = create_engine("sqlite+pysqlite:///:memory:")
13
+ try:
14
+ Base.metadata.create_all(engine)
15
+ with Session(engine) as db:
16
+ if include_finance:
17
+ db.add(Department(
18
+ name="Finance",
19
+ code="FIN",
20
+ keywords="tuition, fee, refund",
21
+ query="Sir i want refund my fee",
22
+ ))
23
+ db.commit()
24
+
25
+ result = match_department(
26
+ db,
27
+ "I have updated the syllabus. Please review the course outline.",
28
+ )
29
+
30
+ assert result == (None, 0)
31
+ finally:
32
+ engine.dispose()