feat(search): page content-search endpoint (multi-tag AND + text + warnings)
New GET /v1/me/page-search surfacing the analysis worker's output: - repo::page_analysis::page_search + PageSearchQuery: multi-tag AND across (user page_tags ∪ global page_auto_tags) via the unnest double-negative idiom, weighted OCR/scene text ranking (ts_rank over search_doc), and content-warning include/exclude. One row per page with is_nsfw + deduped content_warnings + rank. - domain::PageSearchItem. - api::page_tags: /me/page-search handler with CSV tag/warning parsing (parse_tags_csv reuses normalize_tag; parse_warnings_csv validates the closed vocabulary), requiring at least one positive filter (422 else). This is where the reserved OCR text search lands for pages. Tests: multi-tag AND user∪auto, speech>sfx ranking, cw include/exclude (+ row flags), text-only (no tags), missing-filter 422, unknown-warning 422, auth required. Note: the /me/page-tags/chapters|mangas aggregations keep single-tag behavior + the reserved text=501 for now; page-level search is the primary text surface. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -14,12 +14,14 @@ use uuid::Uuid;
|
||||
use crate::api::pagination::PagedResponse;
|
||||
use crate::app::AppState;
|
||||
use crate::auth::extractor::CurrentUser;
|
||||
use crate::domain::page_analysis::{ContentWarning, PageSearchItem};
|
||||
use crate::domain::page_tag::{
|
||||
NewPageTag, PageTagSummary, TaggedChapterAggregate, TaggedMangaAggregate,
|
||||
TaggedPageItem,
|
||||
};
|
||||
use crate::error::{AppError, AppResult};
|
||||
use crate::repo;
|
||||
use crate::repo::page_analysis::PageSearchQuery;
|
||||
use crate::repo::page_tag::Order;
|
||||
|
||||
pub fn routes() -> Router<AppState> {
|
||||
@@ -31,6 +33,7 @@ pub fn routes() -> Router<AppState> {
|
||||
// though the cookie already implies it.
|
||||
.route("/pages/:id/my-tags", get(list_for_page))
|
||||
.route("/me/page-tags", get(list_mine))
|
||||
.route("/me/page-search", get(page_search))
|
||||
.route("/me/page-tags/distinct", get(list_distinct_mine))
|
||||
.route("/me/page-tags/chapters", get(list_chapters_for_tag))
|
||||
.route("/me/page-tags/mangas", get(list_mangas_for_tag))
|
||||
@@ -248,6 +251,109 @@ async fn list_mine(
|
||||
Ok(Json(PagedResponse::with_total(items, limit, offset, total)))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PageSearchParams {
|
||||
#[serde(default = "default_limit")]
|
||||
pub limit: i64,
|
||||
#[serde(default)]
|
||||
pub offset: i64,
|
||||
/// Comma-separated tags, AND-ed. Matched against the caller's page
|
||||
/// tags ∪ the global auto-tags.
|
||||
#[serde(default)]
|
||||
pub tags: Option<String>,
|
||||
/// Free-text query over the OCR + scene-description search document.
|
||||
#[serde(default)]
|
||||
pub text: Option<String>,
|
||||
/// Comma-separated content warnings the page must carry (all of them).
|
||||
#[serde(default)]
|
||||
pub cw_include: Option<String>,
|
||||
/// Comma-separated content warnings the page must NOT carry (any of).
|
||||
#[serde(default)]
|
||||
pub cw_exclude: Option<String>,
|
||||
}
|
||||
|
||||
/// Split a comma-separated tag list into normalized, deduped tag names.
|
||||
fn parse_tags_csv(raw: Option<&str>) -> AppResult<Vec<String>> {
|
||||
let Some(raw) = raw else { return Ok(Vec::new()) };
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut out = Vec::new();
|
||||
for part in raw.split(',') {
|
||||
if part.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let norm = normalize_tag(part)?;
|
||||
if seen.insert(norm.clone()) {
|
||||
out.push(norm);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Split + validate a comma-separated content-warning list against the
|
||||
/// closed vocabulary, returning canonical lowercase names. Unknown values
|
||||
/// are a 422 rather than a silent drop so a typo'd filter is visible.
|
||||
fn parse_warnings_csv(raw: Option<&str>) -> AppResult<Vec<String>> {
|
||||
let Some(raw) = raw else { return Ok(Vec::new()) };
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut out = Vec::new();
|
||||
for part in raw.split(',') {
|
||||
let t = part.trim();
|
||||
if t.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let w = ContentWarning::parse_strict(t).ok_or_else(|| AppError::ValidationFailed {
|
||||
message: format!("unknown content warning {t:?}"),
|
||||
details: json!({ "content_warning": "must be one of sexual|nudity|gore|violence|disturbing" }),
|
||||
})?;
|
||||
let canon = format!("{w:?}").to_lowercase();
|
||||
if seen.insert(canon.clone()) {
|
||||
out.push(canon);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Content search over the caller's reachable pages: multi-tag AND (own
|
||||
/// page tags ∪ global auto-tags), weighted OCR/scene text ranking, and
|
||||
/// content-warning include/exclude. At least one positive filter (tags,
|
||||
/// text, or cw_include) is required so the endpoint never dumps every page.
|
||||
async fn page_search(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
Query(params): Query<PageSearchParams>,
|
||||
) -> AppResult<Json<PagedResponse<PageSearchItem>>> {
|
||||
let limit = params.limit.clamp(1, 200);
|
||||
let offset = params.offset.max(0);
|
||||
let tags = parse_tags_csv(params.tags.as_deref())?;
|
||||
let cw_include = parse_warnings_csv(params.cw_include.as_deref())?;
|
||||
let cw_exclude = parse_warnings_csv(params.cw_exclude.as_deref())?;
|
||||
let text = params
|
||||
.text
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_string);
|
||||
|
||||
if tags.is_empty() && text.is_none() && cw_include.is_empty() {
|
||||
return Err(AppError::ValidationFailed {
|
||||
message: "at least one of tags, text, or cw_include is required".into(),
|
||||
details: json!({ "filter": "required" }),
|
||||
});
|
||||
}
|
||||
|
||||
let query = PageSearchQuery {
|
||||
user_id: user.id,
|
||||
tags,
|
||||
text,
|
||||
cw_include,
|
||||
cw_exclude,
|
||||
limit,
|
||||
offset,
|
||||
};
|
||||
let (items, total) = repo::page_analysis::page_search(&state.db, &query).await?;
|
||||
Ok(Json(PagedResponse::with_total(items, limit, offset, total)))
|
||||
}
|
||||
|
||||
async fn list_distinct_mine(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
|
||||
Reference in New Issue
Block a user