From 799ede5a0e7dae482eafbd0a870e41b88c5077c2 Mon Sep 17 00:00:00 2001 From: Accusys Date: Mon, 6 Jul 2026 08:56:56 +0800 Subject: [PATCH] feat: OCR independent chunks + TMDb seed with file_uuid - Rule 1 now creates OCR-only chunks instead of merging into ASRX - generate_seed_embeddings.py supports --file-uuid parameter - get_seeds() filters by file_uuid - identity_matcher.py uses file_uuid for seed matching - Push QDRANT_API_KEY to Python subprocesses - Face clustering uses frame+bbox matching instead of face_id - Portal uses JWT authentication - FilesView filter logic fixed --- portal/.env.development | 2 +- portal/src/api/client.ts | 23 +++++++++--- portal/src/views/LoginView.vue | 3 ++ run-server-3002.sh | 3 ++ run-worker-3002.sh | 3 ++ scripts/face_clustering_processor.py | 55 ++++++++++++++++++++++++---- scripts/generate_seed_embeddings.py | 49 +++++++++++++++++-------- scripts/identity_matcher.py | 4 +- scripts/utils/qdrant_faces.py | 15 +++++--- src/core/processor/executor.rs | 28 ++++++++++++++ 10 files changed, 147 insertions(+), 38 deletions(-) diff --git a/portal/.env.development b/portal/.env.development index c6991b3..9349b9e 100644 --- a/portal/.env.development +++ b/portal/.env.development @@ -1,4 +1,4 @@ # Portal Development Environment VITE_APP_TITLE=Momentry Portal (Development) -VITE_API_BASE_URL=http://127.0.0.1:3003 +VITE_API_BASE_URL=http://127.0.0.1:3002 VITE_API_KEY=muser_68600856036340bcafc01930eb4bd839_1774418104_97221b69 diff --git a/portal/src/api/client.ts b/portal/src/api/client.ts index 5c79c83..f4e1842 100644 --- a/portal/src/api/client.ts +++ b/portal/src/api/client.ts @@ -75,7 +75,7 @@ export interface UnregisterResponse { // ── Config (browser-only, stored in localStorage) ─────────────────────── const DEFAULT_CONFIG: PortalConfig = { - api_base_url: import.meta.env.VITE_API_BASE_URL || 'http://127.0.0.1:3003', + api_base_url: import.meta.env.VITE_API_BASE_URL || 'http://127.0.0.1:3002', api_key: import.meta.env.VITE_API_KEY || '', timeout_secs: 30, } @@ -99,13 +99,20 @@ export function saveConfig(config: PortalConfig): void { export async function logout(): Promise { try { const config = getConfig(); + const jwtToken = localStorage.getItem('momentry_jwt'); const apiKey = config.api_key || localStorage.getItem('momentry_api_key'); - if (apiKey) { + if (jwtToken || apiKey) { + const headers: Record = { 'Content-Type': 'application/json' }; + if (jwtToken) { + headers['Authorization'] = `Bearer ${jwtToken}`; + } else if (apiKey) { + headers['X-API-Key'] = apiKey; + } // Call logout API to invalidate session on server side (if implemented) // For now, just best effort await fetch(`${config.api_base_url}/api/v1/auth/logout`, { method: 'POST', - headers: { 'X-API-Key': apiKey } + headers }).catch(() => {}); // Ignore network errors } } catch (e) { @@ -129,6 +136,7 @@ function handleSessionExpired() { localStorage.removeItem('momentry_user'); localStorage.removeItem('portal_config'); localStorage.removeItem('momentry_api_key'); + localStorage.removeItem('momentry_jwt'); if (window.location.pathname !== '/login') { window.location.href = '/login'; } @@ -139,12 +147,15 @@ export async function httpFetch(url: string, options?: RequestInit, retries = // Re-read config to ensure we have the latest key if it changed const config = getConfig(); - // Fallback key check + // Use JWT token if available, fallback to API key + const jwtToken = localStorage.getItem('momentry_jwt'); const apiKey = config.api_key || localStorage.getItem('momentry_api_key') || ''; const headers = new Headers(options?.headers); headers.set('Content-Type', 'application/json'); - if (apiKey) { + if (jwtToken) { + headers.set('Authorization', `Bearer ${jwtToken}`); + } else if (apiKey) { headers.set('X-API-Key', apiKey); } @@ -156,7 +167,7 @@ export async function httpFetch(url: string, options?: RequestInit, retries = type: 'HTTP', method, url, - headers: { ...headers, 'X-API-Key': apiKey ? apiKey.substring(0, 10) + '...' : 'none' }, + headers: { ...headers, 'Authorization': jwtToken ? 'Bearer ***' : 'none', 'X-API-Key': apiKey ? apiKey.substring(0, 10) + '...' : 'none' }, body: options?.body ? JSON.parse(options.body as string) : null, status: 'loading', data: null, diff --git a/portal/src/views/LoginView.vue b/portal/src/views/LoginView.vue index efa1d03..80b47a0 100644 --- a/portal/src/views/LoginView.vue +++ b/portal/src/views/LoginView.vue @@ -116,6 +116,9 @@ const handleLogin = async () => { if (data.success) { localStorage.setItem('momentry_user', JSON.stringify(data.user)) localStorage.setItem('momentry_api_key', data.api_key) + if (data.jwt) { + localStorage.setItem('momentry_jwt', data.jwt) + } saveConfig({ ...config, api_key: data.api_key }) const redirect = (route.query.redirect as string) || '/home' router.push(redirect) diff --git a/run-server-3002.sh b/run-server-3002.sh index 3fb7058..950a5d2 100755 --- a/run-server-3002.sh +++ b/run-server-3002.sh @@ -12,6 +12,9 @@ export MOMENTRY_OUTPUT_DIR=/Users/accusys/momentry/output export DATABASE_SCHEMA=public export MOMENTRY_REDIS_PREFIX=momentry: export MOMENTRY_SERVER_PORT=3002 +# Qdrant credentials for Python subprocesses +export QDRANT_URL=http://127.0.0.1:6333 +export QDRANT_API_KEY=Test3200Test3200Test3200 # Kill existing server on port 3002 PID=$(lsof -ti :3002 2>/dev/null || true) diff --git a/run-worker-3002.sh b/run-worker-3002.sh index 4f82908..b37edbe 100755 --- a/run-worker-3002.sh +++ b/run-worker-3002.sh @@ -13,6 +13,9 @@ mkdir -p logs export MOMENTRY_OUTPUT_DIR=/Users/accusys/momentry/output export DATABASE_SCHEMA=public export MOMENTRY_REDIS_PREFIX=momentry: +# Qdrant credentials for Python subprocesses +export QDRANT_URL=http://127.0.0.1:6333 +export QDRANT_API_KEY=Test3200Test3200Test3200 # Kill existing worker via PID file if [ -f logs/worker_3002.pid ]; then diff --git a/scripts/face_clustering_processor.py b/scripts/face_clustering_processor.py index 438a022..1ab90d5 100644 --- a/scripts/face_clustering_processor.py +++ b/scripts/face_clustering_processor.py @@ -104,9 +104,14 @@ def main(): print(f"[FACE_CLUSTER] Loading embeddings from Qdrant for {UUID}...") try: import requests - qdrant_url = "http://localhost:6333" + qdrant_url = os.environ.get("QDRANT_URL", "http://localhost:6333") + qdrant_api_key = os.environ.get("QDRANT_API_KEY", "") collection = "_faces" + headers = {} + if qdrant_api_key: + headers["api-key"] = qdrant_api_key + # Query all embeddings for this file_uuid response = requests.post( f"{qdrant_url}/collections/{collection}/points/scroll", @@ -118,7 +123,8 @@ def main(): }, "limit": 10000, "with_vector": True - } + }, + headers=headers ) if response.status_code == 200: @@ -140,22 +146,57 @@ def main(): print(f"[FACE_CLUSTER] Failed to load embeddings from Qdrant: {e}") embedding_map = {} - # Use embeddings from Qdrant or face.json + # Use embeddings from Qdrant - match by frame + bbox embeddings = [] face_refs = [] print(f"🔍 Collecting face embeddings for {UUID}...") + # Build a lookup: (frame, bbox_center) -> embedding + # Use frame number and approximate bbox center for matching + qdrant_by_frame = {} + for point in points: + payload = point.get("payload", {}) + frame = payload.get("frame") + bbox = payload.get("bbox", {}) + vector = point.get("vector") + if frame is not None and vector: + # Use frame + bbox center as key + cx = bbox.get("x", 0) + bbox.get("width", 0) // 2 + cy = bbox.get("y", 0) + bbox.get("height", 0) // 2 + key = (frame, cx, cy) + if key not in qdrant_by_frame: + qdrant_by_frame[key] = vector + + print(f"[FACE_CLUSTER] Built Qdrant lookup with {len(qdrant_by_frame)} entries") + for frame_idx, frame_obj in enumerate(frames_list): + frame_num = frame_obj.get("frame", frame_idx) faces = frame_obj.get("faces", []) if not faces: continue for face_idx, face in enumerate(faces): - face_id = face.get("face_id") - if face_id and face_id in embedding_map: - embeddings.append(embedding_map[face_id]) - face_refs.append({"frame_idx": frame_idx, "face_idx": face_idx, "face_id": face_id}) + x = face.get("x", 0) + y = face.get("y", 0) + w = face.get("width", 0) + h = face.get("height", 0) + cx = x + w // 2 + cy = y + h // 2 + + # Try exact match first + key = (frame_num, cx, cy) + if key in qdrant_by_frame: + embeddings.append(qdrant_by_frame[key]) + face_refs.append({"frame_idx": frame_idx, "face_idx": face_idx}) + continue + + # Try approximate match (within 50 pixels) + for (qf, qx, qy), vec in qdrant_by_frame.items(): + if qf == frame_num and abs(qx - cx) < 50 and abs(qy - cy) < 50: + embeddings.append(vec) + face_refs.append({"frame_idx": frame_idx, "face_idx": face_idx}) + break if not embeddings: print("❌ No embeddings found in Qdrant.") diff --git a/scripts/generate_seed_embeddings.py b/scripts/generate_seed_embeddings.py index a8e88df..ae492b1 100755 --- a/scripts/generate_seed_embeddings.py +++ b/scripts/generate_seed_embeddings.py @@ -46,11 +46,12 @@ SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) FACENET_PATH = os.path.join(SCRIPT_DIR, "..", "models", "facenet512.mlpackage") -def get_tmdb_identities(limit: int = None) -> List[Dict]: +def get_tmdb_identities(limit: int = None, file_uuid: str = None) -> List[Dict]: """Query PG for TMDb identities with profile photos Args: limit: Max identities to process + file_uuid: Filter by file_uuid via file_identities table Returns: List of {id, uuid, name, tmdb_id, tmdb_profile} @@ -63,20 +64,34 @@ def get_tmdb_identities(limit: int = None) -> List[Dict]: if SCHEMA == "public": table = "identities" + file_table = "file_identities" else: table = f"{SCHEMA}.identities" + file_table = f"{SCHEMA}.file_identities" - query = f""" - SELECT id, uuid, name, tmdb_id, tmdb_profile - FROM {table} - WHERE source = 'tmdb' AND tmdb_profile IS NOT NULL - ORDER BY id - """ + if file_uuid: + query = f""" + SELECT DISTINCT i.id, i.uuid, i.name, i.tmdb_id, i.tmdb_profile + FROM {table} i + JOIN {file_table} fi ON fi.identity_id = i.id + WHERE i.source = 'tmdb' AND i.tmdb_profile IS NOT NULL + AND fi.file_uuid = %s + ORDER BY i.id + """ + if limit: + query += f" LIMIT {limit}" + cur.execute(query, (file_uuid,)) + else: + query = f""" + SELECT id, uuid, name, tmdb_id, tmdb_profile + FROM {table} + WHERE source = 'tmdb' AND tmdb_profile IS NOT NULL + ORDER BY id + """ + if limit: + query += f" LIMIT {limit}" + cur.execute(query) - if limit: - query += f" LIMIT {limit}" - - cur.execute(query) rows = cur.fetchall() cur.close() conn.close() @@ -180,7 +195,7 @@ def extract_face_embedding(image_path: str) -> Optional[List[float]]: return None -def generate_seed_embeddings(limit: int = None, dry_run: bool = False) -> Dict: +def generate_seed_embeddings(limit: int = None, dry_run: bool = False, file_uuid: str = None) -> Dict: """Generate embeddings for all TMDb identities Args: @@ -198,14 +213,14 @@ def generate_seed_embeddings(limit: int = None, dry_run: bool = False) -> Dict: "errors": [], } - identities = get_tmdb_identities(limit) + identities = get_tmdb_identities(limit, file_uuid) result["total"] = len(identities) if not identities: - print("[SEED] No TMDb identities with profile photos") + print(f"[SEED] No TMDb identities with profile photos{' for ' + file_uuid if file_uuid else ''}") return result - print(f"[SEED] Found {len(identities)} TMDb identities") + print(f"[SEED] Found {len(identities)} TMDb identities{' for ' + file_uuid if file_uuid else ''}") if not dry_run: ensure_seeds_collection() @@ -259,6 +274,7 @@ def generate_seed_embeddings(limit: int = None, dry_run: bool = False) -> Dict: name=name, embedding=embedding, source="tmdb", + file_uuid=file_uuid, tmdb_id=tmdb_id, ) result["success"] += 1 @@ -280,12 +296,13 @@ def main(): parser.add_argument("--dry-run", action="store_true", help="Don't push to Qdrant") parser.add_argument("--tmdb-api-key", help="TMDb API key (optional, for rate limiting)") parser.add_argument("--output", help="Output JSON file path") + parser.add_argument("--file-uuid", help="File UUID to generate seeds for") args = parser.parse_args() if args.tmdb_api_key: TMDB_API_KEY = args.tmdb_api_key - result = generate_seed_embeddings(args.limit, args.dry_run) + result = generate_seed_embeddings(args.limit, args.dry_run, args.file_uuid) output_json = json.dumps(result, indent=2, ensure_ascii=False) diff --git a/scripts/identity_matcher.py b/scripts/identity_matcher.py index a7d3858..277d682 100755 --- a/scripts/identity_matcher.py +++ b/scripts/identity_matcher.py @@ -79,10 +79,10 @@ def match_faces_round_1(file_uuid: str) -> dict: {trace_id: {identity_id, identity_uuid, name, score, suggested_by: 'tmdb'}} """ traces = get_trace_representatives(file_uuid) - seeds = get_seeds(source="tmdb") + seeds = get_seeds(source="tmdb", file_uuid=file_uuid) if not seeds: - print("[MATCH] No TMDb seeds available") + print(f"[MATCH] No TMDb seeds available for {file_uuid}") return {} suggestions = {} diff --git a/scripts/utils/qdrant_faces.py b/scripts/utils/qdrant_faces.py index f40aad5..5d64e53 100644 --- a/scripts/utils/qdrant_faces.py +++ b/scripts/utils/qdrant_faces.py @@ -493,11 +493,12 @@ def push_seed_embedding( raise RuntimeError(f"Qdrant seed push failed: HTTP {e.code} - {error_body}") -def get_seeds(source: str = None) -> list: +def get_seeds(source: str = None, file_uuid: str = None) -> list: """Get all seed points Args: source: Filter by source ('tmdb', 'manual', 'propagation'), or None for all + file_uuid: Filter by file_uuid, or None for all Returns: List of seed points with payload and vector @@ -514,12 +515,14 @@ def get_seeds(source: str = None) -> list: "with_vector": True, } + filters = [] if source: - body["filter"] = { - "must": [ - {"key": "source", "match": {"value": source}} - ] - } + filters.append({"key": "source", "match": {"value": source}}) + if file_uuid: + filters.append({"key": "file_uuid", "match": {"value": file_uuid}}) + + if filters: + body["filter"] = {"must": filters} if offset: body["offset"] = offset diff --git a/src/core/processor/executor.rs b/src/core/processor/executor.rs index 87d0b98..5c238f4 100644 --- a/src/core/processor/executor.rs +++ b/src/core/processor/executor.rs @@ -309,6 +309,13 @@ impl PythonExecutor { cmd.env("DATABASE_SCHEMA", &*DATABASE_SCHEMA); cmd.env("MOMENTRY_DB_SCHEMA", &*DATABASE_SCHEMA); cmd.env("MOMENTRY_REDIS_PREFIX", &*REDIS_KEY_PREFIX); + // Pass Qdrant credentials to Python subprocesses + if let Ok(qdrant_url) = std::env::var("QDRANT_URL") { + cmd.env("QDRANT_URL", qdrant_url); + } + if let Ok(qdrant_api_key) = std::env::var("QDRANT_API_KEY") { + cmd.env("QDRANT_API_KEY", qdrant_api_key); + } if let Some(u) = uuid { cmd.env("UUID", u); } @@ -450,6 +457,13 @@ impl PythonExecutor { cmd.env("DATABASE_SCHEMA", &*DATABASE_SCHEMA); cmd.env("MOMENTRY_DB_SCHEMA", &*DATABASE_SCHEMA); cmd.env("MOMENTRY_REDIS_PREFIX", &*REDIS_KEY_PREFIX); + // Pass Qdrant credentials to Python subprocesses + if let Ok(qdrant_url) = std::env::var("QDRANT_URL") { + cmd.env("QDRANT_URL", qdrant_url); + } + if let Ok(qdrant_api_key) = std::env::var("QDRANT_API_KEY") { + cmd.env("QDRANT_API_KEY", qdrant_api_key); + } if let Some(u) = uuid { cmd.env("UUID", u); } @@ -631,6 +645,13 @@ impl PythonExecutor { cmd.env("DATABASE_SCHEMA", &*DATABASE_SCHEMA); cmd.env("MOMENTRY_DB_SCHEMA", &*DATABASE_SCHEMA); cmd.env("MOMENTRY_REDIS_PREFIX", &*REDIS_KEY_PREFIX); + // Pass Qdrant credentials to Python subprocesses + if let Ok(qdrant_url) = std::env::var("QDRANT_URL") { + cmd.env("QDRANT_URL", qdrant_url); + } + if let Ok(qdrant_api_key) = std::env::var("QDRANT_API_KEY") { + cmd.env("QDRANT_API_KEY", qdrant_api_key); + } cmd.arg(&script_path); for arg in args { @@ -882,6 +903,13 @@ mod tests { cmd.env("DATABASE_SCHEMA", &*DATABASE_SCHEMA); cmd.env("MOMENTRY_DB_SCHEMA", &*DATABASE_SCHEMA); cmd.env("MOMENTRY_REDIS_PREFIX", &*REDIS_KEY_PREFIX); + // Pass Qdrant credentials to Python subprocesses + if let Ok(qdrant_url) = std::env::var("QDRANT_URL") { + cmd.env("QDRANT_URL", qdrant_url); + } + if let Ok(qdrant_api_key) = std::env::var("QDRANT_API_KEY") { + cmd.env("QDRANT_API_KEY", qdrant_api_key); + } cmd.args([ "-c", "import os; print(f'ENV_DATABASE_SCHEMA={os.environ.get(\"DATABASE_SCHEMA\",\"\")}'); print(f'ENV_MOMENTRY_DB_SCHEMA={os.environ.get(\"MOMENTRY_DB_SCHEMA\",\"\")}'); print(f'ENV_MOMENTRY_OUTPUT_DIR={os.environ.get(\"MOMENTRY_OUTPUT_DIR\",\"\")}'); print(f'ENV_MOMENTRY_REDIS_PREFIX={os.environ.get(\"MOMENTRY_REDIS_PREFIX\",\"\")}');",