Noor2623 commited on
Commit
dd74be9
·
1 Parent(s): d021e3a

Fix submit query flow

Browse files
app/main.py CHANGED
@@ -10,6 +10,7 @@ from app.core.database import Base
10
  from app.routes.auth import router as auth_router
11
  from app.routes.admin import router as admin_router
12
  from app.routes.departments import router as departments_router
 
13
 
14
 
15
  app = FastAPI(title=settings.app_name)
@@ -25,6 +26,7 @@ app.add_middleware(
25
  app.include_router(auth_router)
26
  app.include_router(admin_router)
27
  app.include_router(departments_router)
 
28
 
29
 
30
  @app.on_event("startup")
 
10
  from app.routes.auth import router as auth_router
11
  from app.routes.admin import router as admin_router
12
  from app.routes.departments import router as departments_router
13
+ from app.routes.queries import router as queries_router
14
 
15
 
16
  app = FastAPI(title=settings.app_name)
 
26
  app.include_router(auth_router)
27
  app.include_router(admin_router)
28
  app.include_router(departments_router)
29
+ app.include_router(queries_router)
30
 
31
 
32
  @app.on_event("startup")
app/models/__init__.py CHANGED
@@ -1,4 +1,5 @@
1
  from app.models.user import User
2
  from app.models.department import Department
 
3
 
4
- __all__ = ["User", "Department"]
 
1
  from app.models.user import User
2
  from app.models.department import Department
3
+ from app.models.query import Query
4
 
5
+ __all__ = ["User", "Department", "Query"]
app/models/department.py CHANGED
@@ -16,3 +16,4 @@ class Department(Base):
16
  query = Column(Text, nullable=True)
17
  is_active = Column(Boolean, default=True, nullable=False)
18
  users = relationship("User", back_populates="department")
 
 
16
  query = Column(Text, nullable=True)
17
  is_active = Column(Boolean, default=True, nullable=False)
18
  users = relationship("User", back_populates="department")
19
+ queries = relationship("Query", back_populates="department")
app/models/query.py CHANGED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import UTC, datetime
2
+
3
+ from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, Text
4
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
5
+
6
+ from app.core.database import Base
7
+
8
+
9
+ class Query(Base):
10
+ __tablename__ = "queries"
11
+
12
+ id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
13
+ student_id: Mapped[int] = mapped_column(
14
+ ForeignKey("users.id", ondelete="CASCADE"),
15
+ nullable=False,
16
+ index=True,
17
+ )
18
+ sender_email: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
19
+ message: Mapped[str] = mapped_column(Text, nullable=False)
20
+ department_id: Mapped[int | None] = mapped_column(
21
+ ForeignKey("departments.id", ondelete="SET NULL"),
22
+ nullable=True,
23
+ index=True,
24
+ )
25
+ status: Mapped[str] = mapped_column(String(32), default="Open", nullable=False)
26
+ priority: Mapped[str] = mapped_column(String(32), default="Normal", nullable=False)
27
+ confidence: Mapped[float] = mapped_column(Float, default=0.0, nullable=False)
28
+ created_at: Mapped[datetime] = mapped_column(
29
+ DateTime, default=lambda: datetime.now(UTC), nullable=False
30
+ )
31
+ updated_at: Mapped[datetime] = mapped_column(
32
+ DateTime, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC), nullable=False
33
+ )
34
+ responded_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
35
+
36
+ student = relationship("User", back_populates="queries")
37
+ department = relationship("Department", back_populates="queries")
38
+
39
+ @property
40
+ def subject(self) -> str:
41
+ first_line = self.message.strip().splitlines()[0] if self.message.strip() else ""
42
+ if not first_line:
43
+ return "New query"
44
+ if len(first_line) <= 72:
45
+ return first_line
46
+ return f"{first_line[:69].rstrip()}..."
47
+
48
+ @property
49
+ def snippet(self) -> str:
50
+ text = " ".join(self.message.split())
51
+ if len(text) <= 140:
52
+ return text
53
+ return f"{text[:137].rstrip()}..."
54
+
55
+ @property
56
+ def department_name(self) -> str | None:
57
+ if self.department is None:
58
+ return None
59
+ return self.department.name
app/models/user.py CHANGED
@@ -22,6 +22,7 @@ class User(Base):
22
  DateTime, default=lambda: datetime.now(UTC), nullable=False
23
  )
24
  department = relationship("Department", back_populates="users")
 
25
 
26
  @property
27
  def department_name(self) -> str | None:
 
22
  DateTime, default=lambda: datetime.now(UTC), nullable=False
23
  )
24
  department = relationship("Department", back_populates="users")
25
+ queries = relationship("Query", back_populates="student")
26
 
27
  @property
28
  def department_name(self) -> str | None:
app/routes/departments.py CHANGED
@@ -4,47 +4,10 @@ from sqlalchemy.orm import Session
4
  from app.core.database import get_db
5
  from app.models.department import Department
6
  from app.schemas.department import DepartmentCreate, DepartmentResponse
 
7
 
8
  router = APIRouter(prefix="/departments", tags=["departments"])
9
 
10
-
11
- def _normalize_keywords(value: str | None) -> set[str]:
12
- if not value:
13
- return set()
14
-
15
- return {
16
- keyword.strip().lower()
17
- for keyword in value.split(",")
18
- if keyword.strip()
19
- }
20
-
21
-
22
- def _score_department(department: Department, query: str) -> int:
23
- query_value = query.lower().strip()
24
- if not query_value:
25
- return 0
26
-
27
- score = 0
28
- searchable_fields = [
29
- department.name,
30
- department.code,
31
- department.description or "",
32
- department.keywords or "",
33
- ]
34
-
35
- for field in searchable_fields:
36
- field_value = field.lower()
37
- if query_value in field_value:
38
- score += 10
39
-
40
- query_words = {word for word in query_value.split() if word}
41
- department_keywords = _normalize_keywords(department.keywords)
42
-
43
- score += len(query_words & department_keywords) * 5
44
-
45
- return score
46
-
47
-
48
  @router.get("/", response_model=list[DepartmentResponse])
49
  def get_departments(db: Session = Depends(get_db)):
50
  return (
@@ -79,7 +42,7 @@ def search_departments(
79
  )
80
 
81
  ranked_departments = [
82
- (department, _score_department(department, q))
83
  for department in departments
84
  ]
85
 
@@ -106,7 +69,7 @@ def match_department(
106
  best_score = 0
107
 
108
  for department in departments:
109
- score = _score_department(department, q)
110
  if score > best_score:
111
  best_department = department
112
  best_score = score
 
4
  from app.core.database import get_db
5
  from app.models.department import Department
6
  from app.schemas.department import DepartmentCreate, DepartmentResponse
7
+ from app.services.routing_service import score_department
8
 
9
  router = APIRouter(prefix="/departments", tags=["departments"])
10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  @router.get("/", response_model=list[DepartmentResponse])
12
  def get_departments(db: Session = Depends(get_db)):
13
  return (
 
42
  )
43
 
44
  ranked_departments = [
45
+ (department, score_department(department, q))
46
  for department in departments
47
  ]
48
 
 
69
  best_score = 0
70
 
71
  for department in departments:
72
+ score = score_department(department, q)
73
  if score > best_score:
74
  best_department = department
75
  best_score = score
app/schemas/query.py CHANGED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import datetime
2
+
3
+ from pydantic import BaseModel, ConfigDict, Field
4
+
5
+
6
+ class QueryCreate(BaseModel):
7
+ message: str = Field(min_length=1, max_length=5000)
8
+
9
+
10
+ class QueryRead(BaseModel):
11
+ id: int
12
+ student_id: int
13
+ sender_email: str
14
+ message: str
15
+ subject: str
16
+ snippet: str
17
+ department_id: int | None
18
+ department_name: str | None
19
+ status: str
20
+ priority: str
21
+ confidence: float
22
+ created_at: datetime
23
+ updated_at: datetime
24
+ responded_at: datetime | None
25
+
26
+ model_config = ConfigDict(from_attributes=True)
app/services/query_service.py CHANGED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy.orm import Session, selectinload
2
+
3
+ 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:
10
+ if score <= 0:
11
+ return 0.5
12
+ return round(min(0.99, 0.55 + score / 20), 2)
13
+
14
+
15
+ def create_query(db: Session, *, current_user: User, payload: QueryCreate) -> Query:
16
+ message = payload.message.strip()
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,
24
+ message=message,
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()
32
+ db.refresh(query)
33
+ return query
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
+ )
app/services/routing_service.py CHANGED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+
3
+ from sqlalchemy.orm import Session
4
+
5
+ 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
21
+ searchable_fields = [
22
+ department.name,
23
+ department.code,
24
+ department.description or "",
25
+ department.keywords or "",
26
+ department.query or "",
27
+ ]
28
+
29
+ for field in searchable_fields:
30
+ field_value = field.lower()
31
+ if query_value in field_value:
32
+ score += 10
33
+
34
+ message_words = _normalize_words(query_value)
35
+ department_keywords = _normalize_words(department.keywords)
36
+ department_prompt_words = _normalize_words(department.query)
37
+
38
+ score += len(message_words & department_keywords) * 5
39
+ score += len(message_words & department_prompt_words) * 3
40
+
41
+ return score
42
+
43
+
44
+ def match_department(db: Session, message: str) -> tuple[Department | None, int]:
45
+ departments = (
46
+ db.query(Department)
47
+ .filter(Department.is_active.is_(True))
48
+ .all()
49
+ )
50
+
51
+ best_department = None
52
+ best_score = 0
53
+
54
+ for department in departments:
55
+ score = score_department(department, message)
56
+ if score > best_score:
57
+ best_department = department
58
+ best_score = score
59
+
60
+ if best_department is not None:
61
+ return best_department, best_score
62
+
63
+ fallback_department = next(
64
+ (department for department in departments if department.name.lower() == "admin"),
65
+ None,
66
+ )
67
+ if fallback_department is not None:
68
+ return fallback_department, 0
69
+
70
+ return (departments[0], 0) if departments else (None, 0)
tests/conftest.py CHANGED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ import sys
3
+
4
+
5
+ BACKEND_ROOT = Path(__file__).resolve().parents[1]
6
+ if str(BACKEND_ROOT) not in sys.path:
7
+ sys.path.insert(0, str(BACKEND_ROOT))
tests/test_queries.py CHANGED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy import create_engine
2
+ from sqlalchemy.orm import sessionmaker
3
+ from sqlalchemy.pool import StaticPool
4
+
5
+ from app.core.database import Base
6
+ from app.models.department import Department
7
+ from app.schemas.auth import SignupRequest
8
+ from app.schemas.query import QueryCreate
9
+ from app.services.auth_service import create_user
10
+ from app.services.query_service import create_query, list_queries_for_user
11
+
12
+
13
+ def _make_session():
14
+ engine = create_engine(
15
+ "sqlite+pysqlite:///:memory:",
16
+ connect_args={"check_same_thread": False},
17
+ poolclass=StaticPool,
18
+ )
19
+ TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
20
+ Base.metadata.create_all(bind=engine)
21
+ return engine, TestingSessionLocal()
22
+
23
+
24
+ def _create_student(db, email: str = "student@example.com"):
25
+ return create_user(
26
+ db,
27
+ SignupRequest(
28
+ email=email,
29
+ password="password123",
30
+ full_name="Student User",
31
+ department_name="Student",
32
+ ),
33
+ )
34
+
35
+
36
+ def test_create_query_routes_to_best_matching_department():
37
+ engine, db = _make_session()
38
+ try:
39
+ finance = Department(
40
+ name="Finance Department",
41
+ code="FIN",
42
+ description="Handles fees, refunds, and payment plans",
43
+ keywords="fee, payment, tuition, refund",
44
+ query="Fee payment and tuition questions",
45
+ )
46
+ admin = Department(
47
+ name="Admin",
48
+ code="ADMIN",
49
+ description="Fallback team",
50
+ keywords="general, other",
51
+ query="Fallback for anything else",
52
+ )
53
+ db.add_all([finance, admin])
54
+ db.commit()
55
+ db.refresh(finance)
56
+ db.refresh(admin)
57
+
58
+ student = _create_student(db)
59
+
60
+ query = create_query(
61
+ db,
62
+ current_user=student,
63
+ payload=QueryCreate(message="I need help with my fee payment"),
64
+ )
65
+
66
+ assert query.student_id == student.id
67
+ assert query.sender_email == student.email
68
+ assert query.department_id == finance.id
69
+ assert query.status == "Routed"
70
+ assert query.subject == "I need help with my fee payment"
71
+ assert query.snippet == "I need help with my fee payment"
72
+ finally:
73
+ db.close()
74
+ Base.metadata.drop_all(bind=engine)
75
+
76
+
77
+ def test_create_query_falls_back_to_admin_when_no_match_is_found():
78
+ engine, db = _make_session()
79
+ try:
80
+ admin = Department(
81
+ name="Admin",
82
+ code="ADMIN",
83
+ description="Fallback team",
84
+ keywords="general, other",
85
+ query="Fallback for anything else",
86
+ )
87
+ db.add(admin)
88
+ db.commit()
89
+ db.refresh(admin)
90
+
91
+ student = _create_student(db, email="fallback@example.com")
92
+
93
+ query = create_query(
94
+ db,
95
+ current_user=student,
96
+ payload=QueryCreate(message="Please route this unusual request"),
97
+ )
98
+
99
+ assert query.department_id == admin.id
100
+ assert query.status == "Routed"
101
+ assert query.confidence == 0.5
102
+ finally:
103
+ db.close()
104
+ Base.metadata.drop_all(bind=engine)
105
+
106
+
107
+ def test_create_query_rejects_empty_message():
108
+ engine, db = _make_session()
109
+ try:
110
+ student = _create_student(db)
111
+
112
+ try:
113
+ create_query(
114
+ db,
115
+ current_user=student,
116
+ payload=QueryCreate(message=" "),
117
+ )
118
+ raise AssertionError("Expected empty query message to fail")
119
+ except ValueError as exc:
120
+ assert "cannot be empty" in str(exc)
121
+ finally:
122
+ db.close()
123
+ Base.metadata.drop_all(bind=engine)
124
+
125
+
126
+ def test_list_queries_for_user_only_returns_that_users_queries():
127
+ engine, db = _make_session()
128
+ try:
129
+ finance = Department(
130
+ name="Finance Department",
131
+ code="FIN",
132
+ description="Handles fees, refunds, and payment plans",
133
+ keywords="fee, payment, tuition, refund",
134
+ )
135
+ db.add(finance)
136
+ db.commit()
137
+ db.refresh(finance)
138
+
139
+ student_one = _create_student(db, email="student1@example.com")
140
+ student_two = _create_student(db, email="student2@example.com")
141
+
142
+ first_query = create_query(
143
+ db,
144
+ current_user=student_one,
145
+ payload=QueryCreate(message="Question about tuition payment"),
146
+ )
147
+ create_query(
148
+ db,
149
+ current_user=student_two,
150
+ payload=QueryCreate(message="Different student's message"),
151
+ )
152
+
153
+ queries = list_queries_for_user(db, current_user=student_one)
154
+
155
+ assert len(queries) == 1
156
+ assert queries[0].id == first_query.id
157
+ assert queries[0].student_id == student_one.id
158
+ finally:
159
+ db.close()
160
+ Base.metadata.drop_all(bind=engine)