Phase 5.1-5.4: PWA, Public Plugin Endpoints, Contacts Embedding, Search Coverage
Check Cross-Plugin Imports / check (push) Has been cancelled
Check Cross-Plugin Imports / check (push) Has been cancelled
5.1 Public Plugin Endpoints: - PluginRouteDef.is_public field in manifest.py - main.py: public routes mounted without auth dependency - permissions/public_routes.py: token-based share link access (info, verify, download) - permissions/plugin.py: public share route registered with is_public=True 5.2 PWA: - vite.config.ts: VitePWA plugin configured (autoUpdate, workbox, runtime caching) - frontend/public/manifest.json: PWA manifest with icons - index.html: theme-color, manifest link, apple-touch-icon, apple-mobile-web-app meta - Build generates sw.js + workbox (90 precache entries) 5.3 Contacts Embedding: - contact.py: embedding column (Vector(768)) added to Contact model - Migration 0002_embeddings.sql already exists (adds embedding + HNSW index) - ContactSearchProvider already queries embedding column 5.4 Search Coverage: - 5 new search providers: task, contactperson, tag, conversation, user - All providers implement FTS search with tenant_id + deleted_at filters - TagSearchProvider also supports vector search (384-dim embedding) - provider_registry.py: all 5 new providers auto-registered - Total: 10 search providers (was 5)
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
"""Task search provider — queries tasks table."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.plugins.builtins.unified_search.base_provider import BaseSearchProvider
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TaskSearchProvider(BaseSearchProvider):
|
||||
"""Search provider for Task entities."""
|
||||
|
||||
entity_type = "task"
|
||||
|
||||
async def _search_fts_filtered(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
tsquery: str,
|
||||
tenant_id: uuid.UUID,
|
||||
limit: int,
|
||||
visible_ids: set[uuid.UUID] | None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Full-text search on tasks.title and tasks.description."""
|
||||
if visible_ids is not None:
|
||||
sql = text(
|
||||
"""
|
||||
SELECT t.*, ts_rank(
|
||||
to_tsvector('pg_catalog.german', coalesce(t.title, '') || ' ' || coalesce(t.description, '')),
|
||||
to_tsquery('pg_catalog.german', :q)
|
||||
) AS rank
|
||||
FROM tasks t
|
||||
WHERE t.tenant_id = :tid
|
||||
AND t.deleted_at IS NULL
|
||||
AND to_tsvector('pg_catalog.german', coalesce(t.title, '') || ' ' || coalesce(t.description, '')) @@ to_tsquery('pg_catalog.german', :q)
|
||||
AND t.id = ANY(:visible_ids)
|
||||
ORDER BY rank DESC
|
||||
LIMIT :lim
|
||||
"""
|
||||
)
|
||||
result = await db.execute(
|
||||
sql,
|
||||
{
|
||||
"q": tsquery,
|
||||
"tid": tenant_id,
|
||||
"lim": limit,
|
||||
"visible_ids": list(visible_ids),
|
||||
},
|
||||
)
|
||||
else:
|
||||
sql = text(
|
||||
"""
|
||||
SELECT t.*, ts_rank(
|
||||
to_tsvector('pg_catalog.german', coalesce(t.title, '') || ' ' || coalesce(t.description, '')),
|
||||
to_tsquery('pg_catalog.german', :q)
|
||||
) AS rank
|
||||
FROM tasks t
|
||||
WHERE t.tenant_id = :tid
|
||||
AND t.deleted_at IS NULL
|
||||
AND to_tsvector('pg_catalog.german', coalesce(t.title, '') || ' ' || coalesce(t.description, '')) @@ to_tsquery('pg_catalog.german', :q)
|
||||
ORDER BY rank DESC
|
||||
LIMIT :lim
|
||||
"""
|
||||
)
|
||||
result = await db.execute(
|
||||
sql,
|
||||
{"q": tsquery, "tid": tenant_id, "lim": limit},
|
||||
)
|
||||
rows = result.mappings().all()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
async def _search_vector_filtered(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
embedding: list[float],
|
||||
tenant_id: uuid.UUID,
|
||||
limit: int,
|
||||
visible_ids: set[uuid.UUID] | None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Semantic search — tasks table has no embedding column yet."""
|
||||
return []
|
||||
|
||||
async def get_embedding_text(
|
||||
self, db: AsyncSession, entity_id: uuid.UUID, tenant_id: uuid.UUID
|
||||
) -> str:
|
||||
"""Get text for embedding generation."""
|
||||
sql = text(
|
||||
"""
|
||||
SELECT title, description
|
||||
FROM tasks
|
||||
WHERE id = :eid AND tenant_id = :tid
|
||||
"""
|
||||
)
|
||||
result = await db.execute(sql, {"eid": entity_id, "tid": tenant_id})
|
||||
row = result.mappings().first()
|
||||
if not row:
|
||||
return ""
|
||||
parts = [
|
||||
row.get("title", ""),
|
||||
row.get("description", ""),
|
||||
]
|
||||
return " ".join(str(p) for p in parts if p)
|
||||
|
||||
def to_search_result(self, entity: object) -> dict[str, Any]:
|
||||
"""Convert task to search result dict."""
|
||||
if isinstance(entity, dict):
|
||||
title = entity.get("title", "")
|
||||
description = entity.get("description", "") or ""
|
||||
entity_id = str(entity.get("id", ""))
|
||||
status = entity.get("status", "")
|
||||
else:
|
||||
title = getattr(entity, "title", "")
|
||||
description = getattr(entity, "description", "") or ""
|
||||
entity_id = str(getattr(entity, "id", ""))
|
||||
status = getattr(entity, "status", "")
|
||||
return {
|
||||
"entity_type": self.entity_type,
|
||||
"entity_id": entity_id,
|
||||
"title": title,
|
||||
"snippet": description[:200],
|
||||
"score": 0.0,
|
||||
"data": {"status": status},
|
||||
}
|
||||
Reference in New Issue
Block a user