Skip to content

Commit

Permalink
feat: ct substring search and midend done
Browse files Browse the repository at this point in the history
  • Loading branch information
harshkhandeparkar committed Nov 11, 2024
1 parent 51eac40 commit e3a1e5d
Show file tree
Hide file tree
Showing 4 changed files with 123 additions and 73 deletions.
15 changes: 7 additions & 8 deletions backend/src/db/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
use color_eyre::eyre::eyre;
use models::DBAdminDashboardQP;
pub use queries::ExamFilter;
use sqlx::{postgres::PgPoolOptions, prelude::FromRow, PgPool, Postgres, Transaction};
use std::time::Duration;

Expand Down Expand Up @@ -66,17 +67,15 @@ impl Database {
/// Searches for papers from a given query. Uses some voodoo black magic by @rajivharlalka
pub async fn search_papers(
&self,
query: &String,
exam: Option<Exam>,
query: &str,
exam_filter: ExamFilter,
exam_filter_str: String,
) -> Result<Vec<qp::SearchQP>, sqlx::Error> {
let exam_param = exam.map(String::from).unwrap_or("".into());
let use_exam = !exam_param.is_empty();

let query_sql = queries::get_qp_search_query(use_exam);
let (query_sql, use_exam_arg) = queries::get_qp_search_query(exam_filter);
let query = sqlx::query_as(&query_sql).bind(query);

let query = if use_exam {
query.bind(exam_param)
let query = if use_exam_arg {
query.bind(exam_filter_str)
} else {
query
};
Expand Down
138 changes: 88 additions & 50 deletions backend/src/db/queries.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
//!
//! Some of these are functions that return a query that is dynamically generated based on requirements.
use crate::qp::Exam;

/// Query to get similar papers. Matches `course_code` ($1) always. Other parameters are optional and can be enabled or disabled using the arguments to this function.
pub fn get_similar_papers_query(
year: bool,
Expand Down Expand Up @@ -80,62 +82,98 @@ pub fn get_all_unapproved_query() -> String {
format!("SELECT {} FROM iqps WHERE approve_status = false and is_deleted=false ORDER BY upload_timestamp ASC", ADMIN_DASHBOARD_QP_FIELDS)
}

/// An enum representing the exam filter for the search query
pub enum ExamFilter {
Exam(Exam), // Match an exact exam or use `ct` substring match
Any, // Match anything
MidEnd, // Midsem or endsem
}

impl TryFrom<&String> for ExamFilter {
type Error = color_eyre::eyre::Error;

fn try_from(value: &String) -> Result<Self, Self::Error> {
if value.is_empty() {
Ok(ExamFilter::Any)
} else if value == "midend" {
Ok(ExamFilter::MidEnd)
} else {
Ok(ExamFilter::Exam(Exam::try_from(value)?))
}
}
}

/// Returns the query for searching question papers. It is mostly voodoo, @Rajiv please update the documentation.
///
/// Optionally, the `exam` argument can be used to also add a clause to match the exam field.
pub fn get_qp_search_query(exam: bool) -> String {
let exam_filter = if exam {
"WHERE (exam = $2 OR exam = '')"
} else {
""
///
/// Query parameters:
/// $1 - Search query
/// $2 - Exam filter string (can be midsem, endsem, midend, or ct)
///
/// Returns the query and a boolean representing whether the second argument is required
pub fn get_qp_search_query(exam_filter: ExamFilter) -> (String, bool) {
let (exam_filter, use_exam_arg) = match exam_filter {
ExamFilter::Any => ("", false),
ExamFilter::MidEnd => (
"WHERE (exam = 'midsem' OR exam = 'endsem' OR exam = '')",
false,
),
ExamFilter::Exam(exam) => match exam {
Exam::CT(_) => ("WHERE (exam LIKE 'ct%' OR exam = '')", false),
_ => ("WHERE (exam = $2 OR exam = '')", true),
},
};

format!("
WITH filtered AS (
SELECT * from iqps {exam_filter}
),
fuzzy AS (
SELECT id,
similarity(course_code || ' ' || course_name, $1) AS sim_score,
row_number() OVER (ORDER BY similarity(course_code || ' ' || course_name, $1) DESC) AS rank_ix
FROM filtered
WHERE (course_code || ' ' || course_name) %>> $1 AND approve_status = true
ORDER BY rank_ix
LIMIT 30
),
full_text AS (
SELECT id,
ts_rank_cd(fts_course_details, websearch_to_tsquery($1)) AS rank_score,
row_number() OVER (ORDER BY ts_rank_cd(fts_course_details, websearch_to_tsquery($1)) DESC) AS rank_ix
FROM filtered
WHERE fts_course_details @@ websearch_to_tsquery($1) AND approve_status = true
ORDER BY rank_ix
LIMIT 30
),
partial_search AS (
SELECT id,
ts_rank_cd(fts_course_details, {to_tsquery}) AS rank_score,
row_number() OVER (ORDER BY ts_rank_cd(fts_course_details, {to_tsquery}) DESC) as rank_ix
FROM filtered
WHERE fts_course_details @@ {to_tsquery} AND approve_status = true
LIMIT 30
(
format!("
WITH filtered AS (
SELECT * from iqps {exam_filter}
),
fuzzy AS (
SELECT id,
similarity(course_code || ' ' || course_name, $1) AS sim_score,
row_number() OVER (ORDER BY similarity(course_code || ' ' || course_name, $1) DESC) AS rank_ix
FROM filtered
WHERE (course_code || ' ' || course_name) %>> $1 AND approve_status = true
ORDER BY rank_ix
LIMIT 30
),
full_text AS (
SELECT id,
ts_rank_cd(fts_course_details, websearch_to_tsquery($1)) AS rank_score,
row_number() OVER (ORDER BY ts_rank_cd(fts_course_details, websearch_to_tsquery($1)) DESC) AS rank_ix
FROM filtered
WHERE fts_course_details @@ websearch_to_tsquery($1) AND approve_status = true
ORDER BY rank_ix
LIMIT 30
),
partial_search AS (
SELECT id,
ts_rank_cd(fts_course_details, {to_tsquery}) AS rank_score,
row_number() OVER (ORDER BY ts_rank_cd(fts_course_details, {to_tsquery}) DESC) as rank_ix
FROM filtered
WHERE fts_course_details @@ {to_tsquery} AND approve_status = true
LIMIT 30
),
result AS (
SELECT {intermediate_fields}
FROM fuzzy
FULL OUTER JOIN full_text ON fuzzy.id = full_text.id
FULL OUTER JOIN partial_search ON coalesce(fuzzy.id, full_text.id) = partial_search.id
JOIN filtered ON coalesce(fuzzy.id, full_text.id, partial_search.id) = filtered.id
ORDER BY
coalesce(1.0 / (50 + fuzzy.rank_ix), 0.0) * 1 +
coalesce(1.0 / (50 + full_text.rank_ix), 0.0) * 1 +
coalesce(1.0 / (50 + partial_search.rank_ix), 0.0) * 1
DESC
) SELECT {search_qp_fields} FROM result",
search_qp_fields = SEARCH_QP_FIELDS,
to_tsquery = "to_tsquery('simple', websearch_to_tsquery('simple', $1)::text || ':*')",
exam_filter = exam_filter,
intermediate_fields = ADMIN_DASHBOARD_QP_FIELDS.split(", ").map(|field| format!("filtered.{}", field)).collect::<Vec<String>>().join(", ")
),
result AS (
SELECT {intermediate_fields}
FROM fuzzy
FULL OUTER JOIN full_text ON fuzzy.id = full_text.id
FULL OUTER JOIN partial_search ON coalesce(fuzzy.id, full_text.id) = partial_search.id
JOIN filtered ON coalesce(fuzzy.id, full_text.id, partial_search.id) = filtered.id
ORDER BY
coalesce(1.0 / (50 + fuzzy.rank_ix), 0.0) * 1 +
coalesce(1.0 / (50 + full_text.rank_ix), 0.0) * 1 +
coalesce(1.0 / (50 + partial_search.rank_ix), 0.0) * 1
DESC
) SELECT {search_qp_fields} FROM result",
search_qp_fields = SEARCH_QP_FIELDS,
to_tsquery = "to_tsquery('simple', websearch_to_tsquery('simple', $1)::text || ':*')",
exam_filter = exam_filter,
intermediate_fields = ADMIN_DASHBOARD_QP_FIELDS.split(", ").map(|field| format!("filtered.{}", field)).collect::<Vec<String>>().join(", ")
use_exam_arg
)
}

Expand Down
39 changes: 26 additions & 13 deletions backend/src/routing/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,19 +63,32 @@ pub async fn search(
Query(params): Query<HashMap<String, String>>,
) -> HandlerReturn<Vec<qp::SearchQP>> {
let response = if let Some(query) = params.get("query") {
let exam = params.get("exam").map(qp::Exam::try_from).transpose()?;

let papers = state.db.search_papers(query, exam).await?;

let papers = papers
.iter()
.map(|paper| paper.clone().with_url(&state.env_vars))
.collect::<Result<Vec<qp::SearchQP>, color_eyre::eyre::Error>>()?;

Ok(BackendResponse::ok(
format!("Successfully fetched {} papers.", papers.len()),
papers,
))
let exam_query_str = params
.get("exam")
.map(|value| value.to_owned())
.unwrap_or("".into());

if let Ok(exam_filter) = (&exam_query_str).try_into() {
let papers = state
.db
.search_papers(query, exam_filter, exam_query_str.to_owned())
.await?;

let papers = papers
.iter()
.map(|paper| paper.clone().with_url(&state.env_vars))
.collect::<Result<Vec<qp::SearchQP>, color_eyre::eyre::Error>>()?;

Ok(BackendResponse::ok(
format!("Successfully fetched {} papers.", papers.len()),
papers,
))
} else {
Ok(BackendResponse::error(
"Invalid `exam` URL parameter.".into(),
StatusCode::BAD_REQUEST,
))
}
} else {
Ok(BackendResponse::error(
"`query` URL parameter is required.".into(),
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/Search/SearchForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -93,10 +93,10 @@ function CourseSearchForm() {
id="exam"
options={[
{ value: '', title: 'Mid / End Semester / Class Test' },
// { value: 'midend', title: 'Mid / End Semester' },
{ value: 'midend', title: 'Mid / End Semester' },
{ value: 'midsem', title: 'Mid Semester' },
{ value: 'endsem', title: 'End Semester' },
// { value: 'ct', title: 'Class Test' }
{ value: 'ct', title: 'Class Test' }
]}
value={exam}
onInput={(e) => setExam(e.currentTarget.value as Exam)}
Expand Down

0 comments on commit e3a1e5d

Please sign in to comment.