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:
@@ -15,9 +15,27 @@ use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::crawler::jobs::{self, JobPayload};
|
||||
use crate::domain::page_analysis::{ContentWarning, OcrKind, PageAnalysis, VisionAnalysis};
|
||||
use crate::domain::page_analysis::{
|
||||
ContentWarning, OcrKind, PageAnalysis, PageSearchItem, VisionAnalysis,
|
||||
};
|
||||
use crate::error::AppResult;
|
||||
|
||||
/// Filter set for [`page_search`]. `tags` are AND-ed (a page must carry
|
||||
/// every one, satisfiable by EITHER the user's page tag or a global auto
|
||||
/// tag); `text` ranks via the weighted tsvector; `cw_include` requires all
|
||||
/// listed warnings; `cw_exclude` rejects any. All string values arrive
|
||||
/// pre-normalized (tags lowercased, warnings validated) from the handler.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct PageSearchQuery {
|
||||
pub user_id: uuid::Uuid,
|
||||
pub tags: Vec<String>,
|
||||
pub text: Option<String>,
|
||||
pub cw_include: Vec<String>,
|
||||
pub cw_exclude: Vec<String>,
|
||||
pub limit: i64,
|
||||
pub offset: i64,
|
||||
}
|
||||
|
||||
/// Longest tag the shared `tags` table accepts (`upsert_by_name` enforces
|
||||
/// the same bound). Auto-tags over this are dropped here rather than
|
||||
/// aborting the whole page's analysis on one bad model output.
|
||||
@@ -245,6 +263,107 @@ pub async fn persist_analysis(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Content search over pages: multi-tag AND across (user page tags ∪
|
||||
/// global auto tags), optional weighted text search over the OCR/scene
|
||||
/// document, and content-warning include/exclude. Returns one row per
|
||||
/// matching page plus the total for pagination.
|
||||
///
|
||||
/// Empty `tags` makes the tag clause vacuously true (text- or
|
||||
/// warning-only search), mirroring the manga search's `unnest` idiom. The
|
||||
/// caller is responsible for requiring at least one positive filter so
|
||||
/// this never degenerates into "every page".
|
||||
pub async fn page_search(
|
||||
pool: &PgPool,
|
||||
q: &PageSearchQuery,
|
||||
) -> AppResult<(Vec<PageSearchItem>, i64)> {
|
||||
// `text` participates in three places ($3): the rank, the match
|
||||
// predicate, and the order key. Empty string disables text filtering.
|
||||
let text = q.text.as_deref().unwrap_or("").trim();
|
||||
|
||||
const WHERE: &str = r#"
|
||||
NOT EXISTS (
|
||||
SELECT 1 FROM unnest($2::text[]) AS req(name)
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM page_tags ut
|
||||
JOIN tags t ON t.id = ut.tag_id
|
||||
WHERE ut.page_id = p.id AND ut.user_id = $1 AND lower(t.name) = req.name
|
||||
UNION ALL
|
||||
SELECT 1 FROM page_auto_tags at
|
||||
JOIN tags t ON t.id = at.tag_id
|
||||
WHERE at.page_id = p.id AND lower(t.name) = req.name
|
||||
)
|
||||
)
|
||||
AND ($3 = '' OR pa.search_doc @@ plainto_tsquery('simple', $3))
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM unnest($4::text[]) AS req(w)
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM page_content_warnings pw
|
||||
WHERE pw.page_id = p.id AND pw.warning = req.w
|
||||
)
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM page_content_warnings pw
|
||||
WHERE pw.page_id = p.id AND pw.warning = ANY($5::text[])
|
||||
)
|
||||
"#;
|
||||
|
||||
let rows_sql = format!(
|
||||
r#"
|
||||
SELECT
|
||||
p.id AS page_id,
|
||||
p.chapter_id AS chapter_id,
|
||||
ch.manga_id AS manga_id,
|
||||
p.page_number AS page_number,
|
||||
ch.number AS chapter_number,
|
||||
ch.title AS chapter_title,
|
||||
m.title AS manga_title,
|
||||
p.storage_key AS storage_key,
|
||||
COALESCE(pa.is_nsfw, false) AS is_nsfw,
|
||||
COALESCE(
|
||||
(SELECT array_agg(pw.warning ORDER BY pw.warning)
|
||||
FROM page_content_warnings pw WHERE pw.page_id = p.id),
|
||||
ARRAY[]::text[]
|
||||
) AS content_warnings,
|
||||
COALESCE(ts_rank(pa.search_doc, plainto_tsquery('simple', $3)), 0)::real AS rank
|
||||
FROM pages p
|
||||
JOIN chapters ch ON ch.id = p.chapter_id
|
||||
JOIN mangas m ON m.id = ch.manga_id
|
||||
LEFT JOIN page_analysis pa ON pa.page_id = p.id
|
||||
WHERE {WHERE}
|
||||
ORDER BY (CASE WHEN $3 = '' THEN 0 ELSE 1 END) DESC, rank DESC, p.id
|
||||
LIMIT $6 OFFSET $7
|
||||
"#
|
||||
);
|
||||
let rows = sqlx::query_as::<_, PageSearchItem>(&rows_sql)
|
||||
.bind(q.user_id)
|
||||
.bind(&q.tags)
|
||||
.bind(text)
|
||||
.bind(&q.cw_include)
|
||||
.bind(&q.cw_exclude)
|
||||
.bind(q.limit)
|
||||
.bind(q.offset)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
let count_sql = format!(
|
||||
"SELECT count(*) FROM pages p \
|
||||
JOIN chapters ch ON ch.id = p.chapter_id \
|
||||
JOIN mangas m ON m.id = ch.manga_id \
|
||||
LEFT JOIN page_analysis pa ON pa.page_id = p.id \
|
||||
WHERE {WHERE}"
|
||||
);
|
||||
let (total,): (i64,) = sqlx::query_as(&count_sql)
|
||||
.bind(q.user_id)
|
||||
.bind(&q.tags)
|
||||
.bind(text)
|
||||
.bind(&q.cw_include)
|
||||
.bind(&q.cw_exclude)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok((rows, total))
|
||||
}
|
||||
|
||||
/// Append a token to a tsvector text bucket with a trailing space.
|
||||
fn push_token(bucket: &mut String, text: &str) {
|
||||
bucket.push_str(text);
|
||||
|
||||
Reference in New Issue
Block a user