File size: 23,977 Bytes
5c793cc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
BEGIN;

-- Derived post data. Source-of-truth permissions remain in the Go API.
ALTER TABLE public.post_embeddings
    ADD COLUMN IF NOT EXISTS author_type TEXT,
    ADD COLUMN IF NOT EXISTS author_id TEXT,
    ADD COLUMN IF NOT EXISTS published_at TIMESTAMPTZ,
    ADD COLUMN IF NOT EXISTS source_version BIGINT,
    ADD COLUMN IF NOT EXISTS indexed_at TIMESTAMPTZ NOT NULL DEFAULT now();

CREATE INDEX IF NOT EXISTS post_embeddings_active_published_idx
ON public.post_embeddings (published_at DESC, external_id DESC)
WHERE is_active = TRUE;

CREATE INDEX IF NOT EXISTS post_embeddings_author_idx
ON public.post_embeddings (author_type, author_id)
WHERE is_active = TRUE;

CREATE TABLE IF NOT EXISTS public.post_cluster_runs (
    id UUID PRIMARY KEY,
    algorithm TEXT NOT NULL CHECK (algorithm = 'kmeans'),
    algorithm_version TEXT NOT NULL,
    embedding_model TEXT NOT NULL,
    embedding_version TEXT NOT NULL,
    embedding_dimension INTEGER NOT NULL CHECK (embedding_dimension = 300),
    k INTEGER NOT NULL CHECK (k > 1),
    sample_size INTEGER NOT NULL CHECK (sample_size >= k),
    inertia DOUBLE PRECISION,
    silhouette_score DOUBLE PRECISION,
    status TEXT NOT NULL CHECK (status IN ('running', 'validated', 'active', 'failed', 'superseded')),
    parameters JSONB NOT NULL DEFAULT '{}'::jsonb,
    started_at TIMESTAMPTZ NOT NULL,
    completed_at TIMESTAMPTZ,
    activated_at TIMESTAMPTZ,
    error_message TEXT
);

CREATE UNIQUE INDEX IF NOT EXISTS post_cluster_runs_one_active_idx
ON public.post_cluster_runs ((status)) WHERE status = 'active';

CREATE TABLE IF NOT EXISTS public.post_clusters (
    run_id UUID NOT NULL REFERENCES public.post_cluster_runs(id) ON DELETE CASCADE,
    cluster_id INTEGER NOT NULL,
    centroid VECTOR(300) NOT NULL,
    label TEXT,
    size INTEGER NOT NULL CHECK (size >= 0),
    representative_post_ids TEXT[] NOT NULL DEFAULT '{}',
    metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
    PRIMARY KEY (run_id, cluster_id)
);

CREATE TABLE IF NOT EXISTS public.post_cluster_memberships (
    run_id UUID NOT NULL,
    post_id TEXT NOT NULL REFERENCES public.post_embeddings(external_id) ON DELETE CASCADE,
    cluster_id INTEGER NOT NULL,
    distance_to_centroid DOUBLE PRECISION NOT NULL CHECK (distance_to_centroid >= 0),
    assigned_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    PRIMARY KEY (run_id, post_id),
    FOREIGN KEY (run_id, cluster_id)
        REFERENCES public.post_clusters(run_id, cluster_id) ON DELETE CASCADE
);

CREATE INDEX IF NOT EXISTS post_cluster_memberships_cluster_idx
ON public.post_cluster_memberships (run_id, cluster_id, distance_to_centroid);

CREATE TABLE IF NOT EXISTS public.user_interest_embeddings (
    user_id TEXT PRIMARY KEY,
    embedding VECTOR(300) NOT NULL,
    weighted_sum VECTOR(300) NOT NULL,
    total_weight DOUBLE PRECISION NOT NULL DEFAULT 0 CHECK (total_weight >= 0),
    source_hash TEXT NOT NULL,
    profile_version TEXT NOT NULL,
    interaction_count INTEGER NOT NULL DEFAULT 0 CHECK (interaction_count >= 0),
    last_event_id BIGINT NOT NULL DEFAULT 0 CHECK (last_event_id >= 0),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

ALTER TABLE public.user_interest_embeddings
    ADD COLUMN IF NOT EXISTS weighted_sum VECTOR(300),
    ADD COLUMN IF NOT EXISTS total_weight DOUBLE PRECISION NOT NULL DEFAULT 0,
    ADD COLUMN IF NOT EXISTS last_event_id BIGINT NOT NULL DEFAULT 0;

UPDATE public.user_interest_embeddings
SET weighted_sum = embedding
WHERE weighted_sum IS NULL;

ALTER TABLE public.user_interest_embeddings
    ALTER COLUMN weighted_sum SET NOT NULL;

CREATE TABLE IF NOT EXISTS public.post_sync_checkpoints (
    consumer TEXT PRIMARY KEY,
    last_event_id BIGINT NOT NULL DEFAULT 0 CHECK (last_event_id >= 0),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE IF NOT EXISTS public.post_embedding_tombstones (
    post_id TEXT PRIMARY KEY,
    source_version BIGINT NOT NULL CHECK (source_version >= 0),
    deleted_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- Replace the legacy eight-argument function with the version-aware contract.
DROP FUNCTION IF EXISTS public.upsert_post_embedding(
    TEXT, TEXT, JSONB, VECTOR, TEXT, TEXT, TEXT, BOOLEAN
);

CREATE OR REPLACE FUNCTION public.upsert_post_embedding(
    p_external_id TEXT,
    p_document TEXT,
    p_metadata JSONB,
    p_embedding VECTOR(300),
    p_content_hash TEXT,
    p_embedding_model TEXT,
    p_embedding_version TEXT,
    p_is_active BOOLEAN,
    p_author_type TEXT,
    p_author_id TEXT,
    p_published_at TIMESTAMPTZ,
    p_source_version BIGINT
) RETURNS VOID
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
    IF EXISTS (
        SELECT 1
        FROM public.post_embedding_tombstones tombstone
        WHERE tombstone.post_id = p_external_id
          AND (p_source_version IS NULL OR tombstone.source_version >= p_source_version)
    ) THEN
        RETURN;
    END IF;

    IF p_source_version IS NOT NULL THEN
        DELETE FROM public.post_embedding_tombstones
        WHERE post_id = p_external_id
          AND source_version < p_source_version;
    END IF;

    INSERT INTO public.post_embeddings (
        external_id, document, metadata, embedding, content_hash,
        embedding_model, embedding_version, is_active, author_type,
        author_id, published_at, source_version, indexed_at, updated_at
    ) VALUES (
        p_external_id, p_document, p_metadata, p_embedding, p_content_hash,
        p_embedding_model, p_embedding_version, p_is_active, p_author_type,
        p_author_id, p_published_at, p_source_version, now(), now()
    )
    ON CONFLICT (external_id) DO UPDATE SET
        document = EXCLUDED.document,
        metadata = EXCLUDED.metadata,
        embedding = EXCLUDED.embedding,
        content_hash = EXCLUDED.content_hash,
        embedding_model = EXCLUDED.embedding_model,
        embedding_version = EXCLUDED.embedding_version,
        is_active = EXCLUDED.is_active,
        author_type = EXCLUDED.author_type,
        author_id = EXCLUDED.author_id,
        published_at = EXCLUDED.published_at,
        source_version = EXCLUDED.source_version,
        indexed_at = now(),
        updated_at = now()
    WHERE EXCLUDED.source_version IS NULL
       OR post_embeddings.source_version IS NULL
       OR EXCLUDED.source_version >= post_embeddings.source_version;
END;
$$;

CREATE OR REPLACE FUNCTION public.deactivate_post_embedding(
    p_external_id TEXT,
    p_source_version BIGINT
) RETURNS VOID
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
    INSERT INTO public.post_embedding_tombstones (post_id, source_version, deleted_at)
    VALUES (p_external_id, p_source_version, now())
    ON CONFLICT (post_id) DO UPDATE SET
        source_version = GREATEST(
            post_embedding_tombstones.source_version,
            EXCLUDED.source_version
        ),
        deleted_at = CASE
            WHEN EXCLUDED.source_version >= post_embedding_tombstones.source_version
            THEN now()
            ELSE post_embedding_tombstones.deleted_at
        END;

    UPDATE public.post_embeddings
    SET is_active = FALSE,
        source_version = GREATEST(COALESCE(source_version, 0), p_source_version),
        indexed_at = now(),
        updated_at = now()
    WHERE external_id = p_external_id
      AND (source_version IS NULL OR p_source_version >= source_version);
END;
$$;

CREATE OR REPLACE FUNCTION public.get_post_sync_checkpoint(p_consumer TEXT)
RETURNS BIGINT
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
    SELECT COALESCE(
        (SELECT last_event_id FROM public.post_sync_checkpoints WHERE consumer = p_consumer),
        0
    );
$$;

CREATE OR REPLACE FUNCTION public.save_post_sync_checkpoint(
    p_consumer TEXT,
    p_last_event_id BIGINT
) RETURNS VOID
LANGUAGE sql
SECURITY DEFINER
SET search_path = public
AS $$
    INSERT INTO public.post_sync_checkpoints (consumer, last_event_id, updated_at)
    VALUES (p_consumer, p_last_event_id, now())
    ON CONFLICT (consumer) DO UPDATE SET
        last_event_id = GREATEST(post_sync_checkpoints.last_event_id, EXCLUDED.last_event_id),
        updated_at = now();
$$;

CREATE OR REPLACE FUNCTION public.reset_post_sync_checkpoint(p_consumer TEXT)
RETURNS VOID
LANGUAGE sql
SECURITY DEFINER
SET search_path = public
AS $$
    DELETE FROM public.post_sync_checkpoints WHERE consumer = p_consumer;
$$;

CREATE OR REPLACE FUNCTION public.get_post_feed_features(
    p_user_id TEXT,
    p_post_ids TEXT[]
) RETURNS TABLE (
    external_id TEXT,
    semantic_similarity DOUBLE PRECISION,
    cluster_id INTEGER,
    cluster_run_id TEXT,
    cold_start BOOLEAN,
    post_embedding TEXT
)
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
    WITH requested AS (
        SELECT post_id, ordinality
        FROM unnest(p_post_ids) WITH ORDINALITY AS ids(post_id, ordinality)
    ), active_run AS (
        SELECT id FROM public.post_cluster_runs
        WHERE status = 'active'
        ORDER BY activated_at DESC NULLS LAST
        LIMIT 1
    ), profile AS (
        SELECT embedding FROM public.user_interest_embeddings WHERE user_id = p_user_id
    )
    SELECT requested.post_id,
           CASE WHEN profile.embedding IS NULL OR post.embedding IS NULL THEN 0.0
                ELSE 1 - (post.embedding <=> profile.embedding) END,
           membership.cluster_id,
           active_run.id::text,
           profile.embedding IS NULL,
           post.embedding::text
    FROM requested
    LEFT JOIN public.post_embeddings post
      ON post.external_id = requested.post_id AND post.is_active = TRUE
    LEFT JOIN profile ON TRUE
    LEFT JOIN active_run ON TRUE
    LEFT JOIN public.post_cluster_memberships membership
      ON membership.post_id = post.external_id AND membership.run_id = active_run.id
    ORDER BY requested.ordinality;
$$;

CREATE OR REPLACE FUNCTION public.get_post_embeddings_for_profile(p_post_ids TEXT[])
RETURNS TABLE (post_id TEXT, embedding VECTOR(300))
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
    SELECT external_id, embedding
    FROM public.post_embeddings
    WHERE external_id = ANY(p_post_ids) AND is_active = TRUE;
$$;

CREATE OR REPLACE FUNCTION public.get_user_interest_states(p_user_ids TEXT[])
RETURNS TABLE (
    user_id TEXT,
    embedding VECTOR(300),
    weighted_sum VECTOR(300),
    total_weight DOUBLE PRECISION,
    interaction_count INTEGER,
    last_event_id BIGINT
)
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
    SELECT profile.user_id, profile.embedding, profile.weighted_sum,
           profile.total_weight, profile.interaction_count, profile.last_event_id
    FROM public.user_interest_embeddings profile
    WHERE profile.user_id = ANY(p_user_ids);
$$;

CREATE OR REPLACE FUNCTION public.upsert_user_interest_state(
    p_user_id TEXT,
    p_embedding VECTOR(300),
    p_weighted_sum VECTOR(300),
    p_total_weight DOUBLE PRECISION,
    p_interaction_count INTEGER,
    p_last_event_id BIGINT,
    p_profile_version TEXT,
    p_source_hash TEXT
) RETURNS VOID
LANGUAGE sql
SECURITY DEFINER
SET search_path = public
AS $$
    INSERT INTO public.user_interest_embeddings (
        user_id, embedding, weighted_sum, total_weight, source_hash,
        profile_version, interaction_count, last_event_id, updated_at
    ) VALUES (
        p_user_id, p_embedding, p_weighted_sum, p_total_weight, p_source_hash,
        p_profile_version, p_interaction_count, p_last_event_id, now()
    )
    ON CONFLICT (user_id) DO UPDATE SET
        embedding = EXCLUDED.embedding,
        weighted_sum = EXCLUDED.weighted_sum,
        total_weight = EXCLUDED.total_weight,
        source_hash = EXCLUDED.source_hash,
        profile_version = EXCLUDED.profile_version,
        interaction_count = EXCLUDED.interaction_count,
        last_event_id = EXCLUDED.last_event_id,
        updated_at = now()
    WHERE EXCLUDED.last_event_id >= user_interest_embeddings.last_event_id;
$$;

CREATE OR REPLACE FUNCTION public.reset_user_interest_profiles(p_user_id TEXT)
RETURNS VOID
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
    IF p_user_id IS NULL THEN
        DELETE FROM public.user_interest_embeddings;
    ELSE
        DELETE FROM public.user_interest_embeddings WHERE user_id = p_user_id;
    END IF;
END;
$$;

CREATE OR REPLACE FUNCTION public.list_post_embeddings_for_clustering(
    p_lookback_days INTEGER,
    p_embedding_model TEXT,
    p_embedding_version TEXT
) RETURNS TABLE (post_id TEXT, embedding VECTOR(300))
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
    SELECT external_id, embedding
    FROM public.post_embeddings
    WHERE is_active = TRUE
      AND published_at >= now() - (p_lookback_days * interval '1 day')
      AND embedding_model = p_embedding_model
      AND embedding_version = p_embedding_version
    ORDER BY external_id;
$$;

CREATE OR REPLACE FUNCTION public.create_post_cluster_run(
    p_id UUID,
    p_algorithm_version TEXT,
    p_embedding_model TEXT,
    p_embedding_version TEXT,
    p_embedding_dimension INTEGER,
    p_k INTEGER,
    p_sample_size INTEGER,
    p_parameters JSONB
) RETURNS VOID
LANGUAGE sql
SECURITY DEFINER
SET search_path = public
AS $$
    INSERT INTO public.post_cluster_runs (
        id, algorithm, algorithm_version, embedding_model, embedding_version,
        embedding_dimension, k, sample_size, parameters, status, started_at
    ) VALUES (
        p_id, 'kmeans', p_algorithm_version, p_embedding_model,
        p_embedding_version, p_embedding_dimension, p_k, p_sample_size,
        p_parameters, 'running', now()
    );
$$;

CREATE OR REPLACE FUNCTION public.validate_post_cluster_run(
    p_run_id UUID,
    p_k INTEGER,
    p_inertia DOUBLE PRECISION,
    p_silhouette_score DOUBLE PRECISION,
    p_parameters JSONB
) RETURNS VOID
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
    UPDATE public.post_cluster_runs
    SET k = p_k,
        inertia = p_inertia,
        silhouette_score = p_silhouette_score,
        parameters = parameters || p_parameters,
        status = 'validated',
        completed_at = now(),
        error_message = NULL
    WHERE id = p_run_id AND status = 'running';
    IF NOT FOUND THEN
        RAISE EXCEPTION 'cluster run % no existe o no esta running', p_run_id;
    END IF;
END;
$$;

CREATE OR REPLACE FUNCTION public.fail_post_cluster_run(
    p_run_id UUID,
    p_error_message TEXT
) RETURNS VOID
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
    UPDATE public.post_cluster_runs
    SET status = 'failed',
        completed_at = now(),
        error_message = LEFT(p_error_message, 4000)
    WHERE id = p_run_id AND status IN ('running', 'validated');
END;
$$;

CREATE OR REPLACE FUNCTION public.replace_post_cluster_artifacts(
    p_run_id UUID,
    p_clusters JSONB,
    p_memberships JSONB
) RETURNS VOID
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
    DELETE FROM public.post_cluster_memberships WHERE run_id = p_run_id;
    DELETE FROM public.post_clusters WHERE run_id = p_run_id;

    IF NOT EXISTS (
        SELECT 1 FROM public.post_cluster_runs
        WHERE id = p_run_id AND status = 'validated'
    ) THEN
        RAISE EXCEPTION 'cluster run % no esta validado', p_run_id;
    END IF;

    INSERT INTO public.post_clusters (
        run_id, cluster_id, centroid, size, representative_post_ids
    )
    SELECT p_run_id, item.cluster_id, item.centroid::vector(300), item.size,
           COALESCE(item.representative_post_ids, '{}')
    FROM jsonb_to_recordset(p_clusters) AS item(
        cluster_id INTEGER, centroid TEXT, size INTEGER,
        representative_post_ids TEXT[]
    );

    INSERT INTO public.post_cluster_memberships (
        run_id, post_id, cluster_id, distance_to_centroid
    )
    SELECT p_run_id, item.post_id, item.cluster_id, item.distance
    FROM jsonb_to_recordset(p_memberships) AS item(
        post_id TEXT, cluster_id INTEGER, distance DOUBLE PRECISION
    );
END;
$$;

CREATE OR REPLACE FUNCTION public.activate_post_cluster_run(p_run_id UUID)
RETURNS VOID
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
    IF EXISTS (
        SELECT 1 FROM public.post_cluster_runs
        WHERE id = p_run_id AND status = 'active'
    ) THEN
        RETURN;
    END IF;
    IF NOT EXISTS (
        SELECT 1 FROM public.post_cluster_runs
        WHERE id = p_run_id AND status IN ('validated', 'superseded')
    ) THEN
        RAISE EXCEPTION 'cluster run % no existe o no es activable', p_run_id;
    END IF;
    UPDATE public.post_cluster_runs SET status = 'superseded' WHERE status = 'active';
    UPDATE public.post_cluster_runs
    SET status = 'active', activated_at = now()
    WHERE id = p_run_id;
END;
$$;

CREATE OR REPLACE FUNCTION public.get_post_cluster_run(p_run_id UUID)
RETURNS TABLE (
    run_id UUID,
    status TEXT,
    k INTEGER,
    sample_size INTEGER,
    inertia DOUBLE PRECISION,
    silhouette_score DOUBLE PRECISION,
    started_at TIMESTAMPTZ,
    completed_at TIMESTAMPTZ,
    activated_at TIMESTAMPTZ,
    error_message TEXT
)
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
    SELECT id, post_cluster_runs.status, post_cluster_runs.k,
           post_cluster_runs.sample_size, post_cluster_runs.inertia,
           post_cluster_runs.silhouette_score, post_cluster_runs.started_at,
           post_cluster_runs.completed_at, post_cluster_runs.activated_at,
           post_cluster_runs.error_message
    FROM public.post_cluster_runs
    WHERE id = p_run_id;
$$;

CREATE OR REPLACE FUNCTION public.assign_posts_to_active_cluster(p_post_ids TEXT[])
RETURNS INTEGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
    affected INTEGER;
BEGIN
    WITH active_run AS (
        SELECT id, embedding_model, embedding_version
        FROM public.post_cluster_runs
        WHERE status = 'active'
        ORDER BY activated_at DESC NULLS LAST
        LIMIT 1
    ), assignments AS (
        SELECT active_run.id AS run_id,
               post.external_id AS post_id,
               nearest.cluster_id,
               nearest.distance
        FROM active_run
        JOIN public.post_embeddings post
          ON post.external_id = ANY(p_post_ids)
         AND post.is_active = TRUE
         AND post.embedding_model = active_run.embedding_model
         AND post.embedding_version = active_run.embedding_version
        CROSS JOIN LATERAL (
            SELECT cluster.cluster_id,
                   (post.embedding <=> cluster.centroid) AS distance
            FROM public.post_clusters cluster
            WHERE cluster.run_id = active_run.id
            ORDER BY post.embedding <=> cluster.centroid
            LIMIT 1
        ) nearest
    )
    INSERT INTO public.post_cluster_memberships (
        run_id, post_id, cluster_id, distance_to_centroid, assigned_at
    )
    SELECT run_id, post_id, cluster_id, distance, now()
    FROM assignments
    ON CONFLICT (run_id, post_id) DO UPDATE SET
        cluster_id = EXCLUDED.cluster_id,
        distance_to_centroid = EXCLUDED.distance_to_centroid,
        assigned_at = now();
    GET DIAGNOSTICS affected = ROW_COUNT;
    RETURN affected;
END;
$$;

CREATE OR REPLACE FUNCTION public.get_post_cluster_status()
RETURNS TABLE (
    active_run_id UUID,
    k INTEGER,
    sample_size INTEGER,
    silhouette_score DOUBLE PRECISION,
    activated_at TIMESTAMPTZ
)
LANGUAGE sql
STABLE
SECURITY DEFINER
SET search_path = public
AS $$
    SELECT id, post_cluster_runs.k, post_cluster_runs.sample_size,
           post_cluster_runs.silhouette_score, post_cluster_runs.activated_at
    FROM public.post_cluster_runs
    WHERE status = 'active'
    ORDER BY activated_at DESC NULLS LAST
    LIMIT 1;
$$;

-- Least-privilege grants when the expected roles exist.
REVOKE ALL ON FUNCTION public.upsert_post_embedding(TEXT, TEXT, JSONB, VECTOR, TEXT, TEXT, TEXT, BOOLEAN, TEXT, TEXT, TIMESTAMPTZ, BIGINT) FROM PUBLIC;
REVOKE ALL ON FUNCTION public.deactivate_post_embedding(TEXT, BIGINT) FROM PUBLIC;
REVOKE ALL ON FUNCTION public.get_post_sync_checkpoint(TEXT) FROM PUBLIC;
REVOKE ALL ON FUNCTION public.save_post_sync_checkpoint(TEXT, BIGINT) FROM PUBLIC;
REVOKE ALL ON FUNCTION public.reset_post_sync_checkpoint(TEXT) FROM PUBLIC;
REVOKE ALL ON FUNCTION public.get_post_feed_features(TEXT, TEXT[]) FROM PUBLIC;
REVOKE ALL ON FUNCTION public.get_post_embeddings_for_profile(TEXT[]) FROM PUBLIC;
REVOKE ALL ON FUNCTION public.get_user_interest_states(TEXT[]) FROM PUBLIC;
REVOKE ALL ON FUNCTION public.upsert_user_interest_state(TEXT, VECTOR, VECTOR, DOUBLE PRECISION, INTEGER, BIGINT, TEXT, TEXT) FROM PUBLIC;
REVOKE ALL ON FUNCTION public.reset_user_interest_profiles(TEXT) FROM PUBLIC;
REVOKE ALL ON FUNCTION public.list_post_embeddings_for_clustering(INTEGER, TEXT, TEXT) FROM PUBLIC;
REVOKE ALL ON FUNCTION public.create_post_cluster_run(UUID, TEXT, TEXT, TEXT, INTEGER, INTEGER, INTEGER, JSONB) FROM PUBLIC;
REVOKE ALL ON FUNCTION public.validate_post_cluster_run(UUID, INTEGER, DOUBLE PRECISION, DOUBLE PRECISION, JSONB) FROM PUBLIC;
REVOKE ALL ON FUNCTION public.fail_post_cluster_run(UUID, TEXT) FROM PUBLIC;
REVOKE ALL ON FUNCTION public.replace_post_cluster_artifacts(UUID, JSONB, JSONB) FROM PUBLIC;
REVOKE ALL ON FUNCTION public.activate_post_cluster_run(UUID) FROM PUBLIC;
REVOKE ALL ON FUNCTION public.assign_posts_to_active_cluster(TEXT[]) FROM PUBLIC;
REVOKE ALL ON FUNCTION public.get_post_cluster_status() FROM PUBLIC;
REVOKE ALL ON FUNCTION public.get_post_cluster_run(UUID) FROM PUBLIC;

DO $$
BEGIN
    IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'nlp_reader') THEN
        GRANT EXECUTE ON FUNCTION public.get_post_feed_features(TEXT, TEXT[]) TO nlp_reader;
        GRANT EXECUTE ON FUNCTION public.get_post_cluster_status() TO nlp_reader;
        GRANT EXECUTE ON FUNCTION public.get_post_cluster_run(UUID) TO nlp_reader;
    END IF;
    IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'nlp_writer') THEN
        GRANT EXECUTE ON FUNCTION public.upsert_post_embedding(TEXT, TEXT, JSONB, VECTOR, TEXT, TEXT, TEXT, BOOLEAN, TEXT, TEXT, TIMESTAMPTZ, BIGINT) TO nlp_writer;
        GRANT EXECUTE ON FUNCTION public.deactivate_post_embedding(TEXT, BIGINT) TO nlp_writer;
        GRANT EXECUTE ON FUNCTION public.get_post_sync_checkpoint(TEXT) TO nlp_writer;
        GRANT EXECUTE ON FUNCTION public.save_post_sync_checkpoint(TEXT, BIGINT) TO nlp_writer;
        GRANT EXECUTE ON FUNCTION public.reset_post_sync_checkpoint(TEXT) TO nlp_writer;
        GRANT EXECUTE ON FUNCTION public.get_post_embeddings_for_profile(TEXT[]) TO nlp_writer;
        GRANT EXECUTE ON FUNCTION public.get_user_interest_states(TEXT[]) TO nlp_writer;
        GRANT EXECUTE ON FUNCTION public.upsert_user_interest_state(TEXT, VECTOR, VECTOR, DOUBLE PRECISION, INTEGER, BIGINT, TEXT, TEXT) TO nlp_writer;
        GRANT EXECUTE ON FUNCTION public.reset_user_interest_profiles(TEXT) TO nlp_writer;
        GRANT EXECUTE ON FUNCTION public.list_post_embeddings_for_clustering(INTEGER, TEXT, TEXT) TO nlp_writer;
        GRANT EXECUTE ON FUNCTION public.create_post_cluster_run(UUID, TEXT, TEXT, TEXT, INTEGER, INTEGER, INTEGER, JSONB) TO nlp_writer;
        GRANT EXECUTE ON FUNCTION public.validate_post_cluster_run(UUID, INTEGER, DOUBLE PRECISION, DOUBLE PRECISION, JSONB) TO nlp_writer;
        GRANT EXECUTE ON FUNCTION public.fail_post_cluster_run(UUID, TEXT) TO nlp_writer;
        GRANT EXECUTE ON FUNCTION public.replace_post_cluster_artifacts(UUID, JSONB, JSONB) TO nlp_writer;
        GRANT EXECUTE ON FUNCTION public.activate_post_cluster_run(UUID) TO nlp_writer;
        GRANT EXECUTE ON FUNCTION public.assign_posts_to_active_cluster(TEXT[]) TO nlp_writer;
        GRANT EXECUTE ON FUNCTION public.get_post_cluster_status() TO nlp_writer;
        GRANT EXECUTE ON FUNCTION public.get_post_cluster_run(UUID) TO nlp_writer;
    END IF;
END $$;

COMMIT;