pilotswarm-horizon-store 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +154 -0
- package/dist/src/agent-tools.d.ts +32 -0
- package/dist/src/agent-tools.d.ts.map +1 -0
- package/dist/src/agent-tools.js +263 -0
- package/dist/src/agent-tools.js.map +1 -0
- package/dist/src/config.d.ts +93 -0
- package/dist/src/config.d.ts.map +1 -0
- package/dist/src/config.js +120 -0
- package/dist/src/config.js.map +1 -0
- package/dist/src/db-retry.d.ts +21 -0
- package/dist/src/db-retry.d.ts.map +1 -0
- package/dist/src/db-retry.js +94 -0
- package/dist/src/db-retry.js.map +1 -0
- package/dist/src/embedding-client.d.ts +13 -0
- package/dist/src/embedding-client.d.ts.map +1 -0
- package/dist/src/embedding-client.js +79 -0
- package/dist/src/embedding-client.js.map +1 -0
- package/dist/src/graph-model.d.ts +49 -0
- package/dist/src/graph-model.d.ts.map +1 -0
- package/dist/src/graph-model.js +112 -0
- package/dist/src/graph-model.js.map +1 -0
- package/dist/src/graph-queries.d.ts +83 -0
- package/dist/src/graph-queries.d.ts.map +1 -0
- package/dist/src/graph-queries.js +727 -0
- package/dist/src/graph-queries.js.map +1 -0
- package/dist/src/graph-store.d.ts +57 -0
- package/dist/src/graph-store.d.ts.map +1 -0
- package/dist/src/graph-store.js +327 -0
- package/dist/src/graph-store.js.map +1 -0
- package/dist/src/horizon-migrator.d.ts +49 -0
- package/dist/src/horizon-migrator.d.ts.map +1 -0
- package/dist/src/horizon-migrator.js +231 -0
- package/dist/src/horizon-migrator.js.map +1 -0
- package/dist/src/horizon-store.d.ts +116 -0
- package/dist/src/horizon-store.d.ts.map +1 -0
- package/dist/src/horizon-store.js +563 -0
- package/dist/src/horizon-store.js.map +1 -0
- package/dist/src/index.d.ts +13 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +21 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/preconditions.d.ts +16 -0
- package/dist/src/preconditions.d.ts.map +1 -0
- package/dist/src/preconditions.js +99 -0
- package/dist/src/preconditions.js.map +1 -0
- package/dist/src/query-builder.d.ts +37 -0
- package/dist/src/query-builder.d.ts.map +1 -0
- package/dist/src/query-builder.js +82 -0
- package/dist/src/query-builder.js.map +1 -0
- package/dist/src/sql-util.d.ts +20 -0
- package/dist/src/sql-util.d.ts.map +1 -0
- package/dist/src/sql-util.js +40 -0
- package/dist/src/sql-util.js.map +1 -0
- package/dist/src/types.d.ts +16 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/types.js +35 -0
- package/dist/src/types.js.map +1 -0
- package/migrations/0001_facts_table.sql +77 -0
- package/migrations/0002_indexes.sql +27 -0
- package/migrations/0003_age_bootstrap.sql +13 -0
- package/migrations/0004_facts_procs.sql +253 -0
- package/migrations/0005_embedder_workflow.sql +119 -0
- package/migrations/0006_facts_read_uncrawled_embedded_gate.sql +33 -0
- package/migrations/0007_embed_key_value_text.sql +122 -0
- package/migrations/0008_embedder_failure_recovery.sql +409 -0
- package/migrations/0009_search_text_full_value.sql +52 -0
- package/migrations/0010_minimal_two_loop_embedder.sql +392 -0
- package/migrations/0011_unified_api_embedder_workflow.sql +273 -0
- package/migrations/0012_facts_soft_delete.sql +587 -0
- package/migrations/0013_graph_namespaces.sql +60 -0
- package/package.json +69 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
-- 0004_facts_procs — ALL relational + vector data access (03-design §1: no
|
|
2
|
+
-- inline SQL in the provider; the 04 §6 M1 grep guard enforces it).
|
|
3
|
+
--
|
|
4
|
+
-- ACL convention (every read): the access predicate is part of the WHERE
|
|
5
|
+
-- clause, evaluated BEFORE ranking/LIMIT (01 §4.2 — never a post-filter).
|
|
6
|
+
-- Parameters: p_reader (caller session), p_granted (spawn-tree session ids),
|
|
7
|
+
-- p_unrestricted, p_scope ('accessible'|'shared'|'session'|'descendants').
|
|
8
|
+
--
|
|
9
|
+
-- Tokens: {{SCHEMA}}.
|
|
10
|
+
|
|
11
|
+
-- ── write ────────────────────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
CREATE OR REPLACE FUNCTION "{{SCHEMA}}".facts_store(
|
|
14
|
+
p_scope_key TEXT,
|
|
15
|
+
p_key TEXT,
|
|
16
|
+
p_value JSONB,
|
|
17
|
+
p_agent_id TEXT,
|
|
18
|
+
p_session_id TEXT,
|
|
19
|
+
p_shared BOOLEAN,
|
|
20
|
+
p_tags TEXT[]
|
|
21
|
+
) RETURNS void
|
|
22
|
+
LANGUAGE sql AS $$
|
|
23
|
+
INSERT INTO "{{SCHEMA}}".facts (scope_key, key, value, agent_id, session_id, shared, transient, tags, updated_at)
|
|
24
|
+
VALUES (p_scope_key, p_key, p_value, p_agent_id, p_session_id, p_shared, NOT p_shared, coalesce(p_tags, '{}'), now())
|
|
25
|
+
ON CONFLICT (scope_key) DO UPDATE SET
|
|
26
|
+
value = EXCLUDED.value, agent_id = EXCLUDED.agent_id, tags = EXCLUDED.tags,
|
|
27
|
+
shared = EXCLUDED.shared, transient = EXCLUDED.transient, updated_at = now();
|
|
28
|
+
$$;
|
|
29
|
+
|
|
30
|
+
CREATE OR REPLACE FUNCTION "{{SCHEMA}}".facts_delete(p_scope_key TEXT) RETURNS boolean
|
|
31
|
+
LANGUAGE sql AS $$
|
|
32
|
+
WITH del AS (DELETE FROM "{{SCHEMA}}".facts WHERE scope_key = p_scope_key RETURNING 1)
|
|
33
|
+
SELECT count(*) > 0 FROM del;
|
|
34
|
+
$$;
|
|
35
|
+
|
|
36
|
+
CREATE OR REPLACE FUNCTION "{{SCHEMA}}".facts_delete_session(p_session_id TEXT) RETURNS int
|
|
37
|
+
LANGUAGE sql AS $$
|
|
38
|
+
WITH del AS (DELETE FROM "{{SCHEMA}}".facts WHERE shared = FALSE AND session_id = p_session_id RETURNING 1)
|
|
39
|
+
SELECT count(*)::int FROM del;
|
|
40
|
+
$$;
|
|
41
|
+
|
|
42
|
+
-- ── ACL predicate (shared by the read/search procs) ─────────────────────────
|
|
43
|
+
|
|
44
|
+
CREATE OR REPLACE FUNCTION "{{SCHEMA}}".facts_acl(
|
|
45
|
+
f_shared BOOLEAN, f_session_id TEXT,
|
|
46
|
+
p_reader TEXT, p_granted TEXT[], p_unrestricted BOOLEAN, p_scope TEXT
|
|
47
|
+
) RETURNS boolean
|
|
48
|
+
LANGUAGE sql IMMUTABLE AS $$
|
|
49
|
+
SELECT CASE
|
|
50
|
+
WHEN p_unrestricted THEN TRUE
|
|
51
|
+
WHEN p_scope = 'shared' THEN f_shared
|
|
52
|
+
WHEN p_scope = 'session' THEN (NOT f_shared AND f_session_id = p_reader)
|
|
53
|
+
WHEN p_scope = 'descendants' THEN (NOT f_shared AND f_session_id = ANY (coalesce(p_granted, '{}')))
|
|
54
|
+
ELSE (f_shared OR f_session_id = p_reader OR f_session_id = ANY (coalesce(p_granted, '{}')))
|
|
55
|
+
END;
|
|
56
|
+
$$;
|
|
57
|
+
|
|
58
|
+
-- ── read ─────────────────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
CREATE OR REPLACE FUNCTION "{{SCHEMA}}".facts_read(
|
|
61
|
+
p_reader TEXT,
|
|
62
|
+
p_granted TEXT[],
|
|
63
|
+
p_unrestricted BOOLEAN,
|
|
64
|
+
p_scope TEXT,
|
|
65
|
+
p_key_pattern TEXT,
|
|
66
|
+
p_scope_keys TEXT[],
|
|
67
|
+
p_tags TEXT[],
|
|
68
|
+
p_agent_id TEXT,
|
|
69
|
+
p_limit INT
|
|
70
|
+
) RETURNS SETOF "{{SCHEMA}}".facts
|
|
71
|
+
LANGUAGE sql STABLE AS $$
|
|
72
|
+
SELECT f.* FROM "{{SCHEMA}}".facts f
|
|
73
|
+
WHERE "{{SCHEMA}}".facts_acl(f.shared, f.session_id, p_reader, p_granted, p_unrestricted, p_scope)
|
|
74
|
+
AND (p_key_pattern IS NULL OR f.key LIKE p_key_pattern)
|
|
75
|
+
AND (p_scope_keys IS NULL OR f.scope_key = ANY (p_scope_keys))
|
|
76
|
+
AND (p_tags IS NULL OR f.tags @> p_tags)
|
|
77
|
+
AND (p_agent_id IS NULL OR f.agent_id = p_agent_id)
|
|
78
|
+
ORDER BY f.updated_at DESC
|
|
79
|
+
LIMIT p_limit;
|
|
80
|
+
$$;
|
|
81
|
+
|
|
82
|
+
-- ── stats (namespace-bucketed, in SQL — not in Node) ─────────────────────────
|
|
83
|
+
|
|
84
|
+
CREATE OR REPLACE FUNCTION "{{SCHEMA}}".facts_stats(
|
|
85
|
+
p_mode TEXT, -- 'session' | 'sessions' | 'shared'
|
|
86
|
+
p_session_ids TEXT[]
|
|
87
|
+
) RETURNS TABLE (
|
|
88
|
+
namespace TEXT, fact_count BIGINT, total_value_bytes BIGINT,
|
|
89
|
+
oldest_created_at TIMESTAMPTZ, newest_updated_at TIMESTAMPTZ
|
|
90
|
+
)
|
|
91
|
+
LANGUAGE sql STABLE AS $$
|
|
92
|
+
SELECT
|
|
93
|
+
CASE WHEN split_part(f.key, '/', 1) IN ('skills', 'asks', 'intake', 'config')
|
|
94
|
+
THEN split_part(f.key, '/', 1) ELSE '(other)' END AS namespace,
|
|
95
|
+
count(*) AS fact_count,
|
|
96
|
+
sum(octet_length(f.value::text))::bigint AS total_value_bytes,
|
|
97
|
+
min(f.created_at) AS oldest_created_at,
|
|
98
|
+
max(f.updated_at) AS newest_updated_at
|
|
99
|
+
FROM "{{SCHEMA}}".facts f
|
|
100
|
+
WHERE CASE p_mode
|
|
101
|
+
WHEN 'shared' THEN f.shared
|
|
102
|
+
WHEN 'session' THEN (NOT f.shared AND f.session_id = p_session_ids[1])
|
|
103
|
+
WHEN 'sessions' THEN (NOT f.shared AND f.session_id = ANY (coalesce(p_session_ids, '{}')))
|
|
104
|
+
ELSE FALSE
|
|
105
|
+
END
|
|
106
|
+
GROUP BY 1;
|
|
107
|
+
$$;
|
|
108
|
+
|
|
109
|
+
-- ── lexical search (BM25 via pg_textsearch — verified on live HorizonDB) ────
|
|
110
|
+
-- `search_text <@> to_bm25query(q, idx)` yields the NEGATIVE BM25 score
|
|
111
|
+
-- (0 = no match), so: match ⇔ d < 0, rank = -d, best-first = ORDER BY d ASC.
|
|
112
|
+
-- The ACL predicate sits in the same WHERE — before ranking and LIMIT.
|
|
113
|
+
|
|
114
|
+
CREATE OR REPLACE FUNCTION "{{SCHEMA}}".facts_search_lexical(
|
|
115
|
+
p_query TEXT,
|
|
116
|
+
p_reader TEXT,
|
|
117
|
+
p_granted TEXT[],
|
|
118
|
+
p_unrestricted BOOLEAN,
|
|
119
|
+
p_scope TEXT,
|
|
120
|
+
p_ns_prefix TEXT,
|
|
121
|
+
p_tags TEXT[],
|
|
122
|
+
p_pool INT
|
|
123
|
+
) RETURNS TABLE (
|
|
124
|
+
scope_key TEXT, key TEXT, value JSONB, agent_id TEXT, session_id TEXT,
|
|
125
|
+
shared BOOLEAN, tags TEXT[], created_at TIMESTAMPTZ, updated_at TIMESTAMPTZ,
|
|
126
|
+
rank DOUBLE PRECISION
|
|
127
|
+
)
|
|
128
|
+
LANGUAGE sql STABLE AS $$
|
|
129
|
+
SELECT f.scope_key, f.key, f.value, f.agent_id, f.session_id,
|
|
130
|
+
f.shared, f.tags, f.created_at, f.updated_at,
|
|
131
|
+
(-(f.search_text <@> q.bq))::double precision AS rank
|
|
132
|
+
FROM "{{SCHEMA}}".facts f,
|
|
133
|
+
(SELECT to_bm25query(p_query, '{{SCHEMA}}.idx_facts_lexical') AS bq) q
|
|
134
|
+
WHERE (f.search_text <@> q.bq) < 0
|
|
135
|
+
AND "{{SCHEMA}}".facts_acl(f.shared, f.session_id, p_reader, p_granted, p_unrestricted, p_scope)
|
|
136
|
+
AND (p_ns_prefix IS NULL OR f.key LIKE p_ns_prefix)
|
|
137
|
+
AND (p_tags IS NULL OR f.tags @> p_tags)
|
|
138
|
+
ORDER BY f.search_text <@> q.bq ASC
|
|
139
|
+
LIMIT p_pool;
|
|
140
|
+
$$;
|
|
141
|
+
|
|
142
|
+
-- ── semantic search (pgvector cosine; model-matched rows only — 01 §5.2) ────
|
|
143
|
+
|
|
144
|
+
CREATE OR REPLACE FUNCTION "{{SCHEMA}}".facts_search_semantic(
|
|
145
|
+
p_vec vector,
|
|
146
|
+
p_model TEXT,
|
|
147
|
+
p_reader TEXT,
|
|
148
|
+
p_granted TEXT[],
|
|
149
|
+
p_unrestricted BOOLEAN,
|
|
150
|
+
p_scope TEXT,
|
|
151
|
+
p_ns_prefix TEXT,
|
|
152
|
+
p_tags TEXT[],
|
|
153
|
+
p_min DOUBLE PRECISION,
|
|
154
|
+
p_pool INT
|
|
155
|
+
) RETURNS TABLE (
|
|
156
|
+
scope_key TEXT, key TEXT, value JSONB, agent_id TEXT, session_id TEXT,
|
|
157
|
+
shared BOOLEAN, tags TEXT[], created_at TIMESTAMPTZ, updated_at TIMESTAMPTZ,
|
|
158
|
+
sim DOUBLE PRECISION
|
|
159
|
+
)
|
|
160
|
+
LANGUAGE sql STABLE AS $$
|
|
161
|
+
SELECT f.scope_key, f.key, f.value, f.agent_id, f.session_id,
|
|
162
|
+
f.shared, f.tags, f.created_at, f.updated_at,
|
|
163
|
+
(1 - (f.embedding <=> p_vec))::double precision AS sim
|
|
164
|
+
FROM "{{SCHEMA}}".facts f
|
|
165
|
+
WHERE f.embedding IS NOT NULL
|
|
166
|
+
AND f.embedding_model IS NOT DISTINCT FROM p_model -- mismatched model ⇒ treated as NULL embedding
|
|
167
|
+
AND "{{SCHEMA}}".facts_acl(f.shared, f.session_id, p_reader, p_granted, p_unrestricted, p_scope)
|
|
168
|
+
AND (p_ns_prefix IS NULL OR f.key LIKE p_ns_prefix)
|
|
169
|
+
AND (p_tags IS NULL OR f.tags @> p_tags)
|
|
170
|
+
AND (1 - (f.embedding <=> p_vec)) >= coalesce(p_min, 0)
|
|
171
|
+
ORDER BY f.embedding <=> p_vec ASC
|
|
172
|
+
LIMIT p_pool;
|
|
173
|
+
$$;
|
|
174
|
+
|
|
175
|
+
-- ── similarFacts (kNN of a known fact; anchor ACL-checked IN the proc) ──────
|
|
176
|
+
-- An existing-but-inaccessible anchor yields zero rows, byte-identical to an
|
|
177
|
+
-- unknown key (01 §4.3 — no similarity oracle). Neighbours must carry the
|
|
178
|
+
-- anchor's embedding_model (cross-model vectors are not comparable); when
|
|
179
|
+
-- p_model is supplied (an endpoint is configured) the anchor must match it too.
|
|
180
|
+
|
|
181
|
+
CREATE OR REPLACE FUNCTION "{{SCHEMA}}".facts_similar(
|
|
182
|
+
p_scope_key TEXT,
|
|
183
|
+
p_model TEXT,
|
|
184
|
+
p_reader TEXT,
|
|
185
|
+
p_granted TEXT[],
|
|
186
|
+
p_unrestricted BOOLEAN,
|
|
187
|
+
p_scope TEXT,
|
|
188
|
+
p_ns_prefix TEXT,
|
|
189
|
+
p_min DOUBLE PRECISION,
|
|
190
|
+
p_k INT
|
|
191
|
+
) RETURNS TABLE (
|
|
192
|
+
scope_key TEXT, key TEXT, value JSONB, agent_id TEXT, session_id TEXT,
|
|
193
|
+
shared BOOLEAN, tags TEXT[], created_at TIMESTAMPTZ, updated_at TIMESTAMPTZ,
|
|
194
|
+
sim DOUBLE PRECISION
|
|
195
|
+
)
|
|
196
|
+
LANGUAGE sql STABLE AS $$
|
|
197
|
+
WITH anchor AS (
|
|
198
|
+
SELECT a.embedding, a.embedding_model
|
|
199
|
+
FROM "{{SCHEMA}}".facts a
|
|
200
|
+
WHERE a.scope_key = p_scope_key
|
|
201
|
+
AND a.embedding IS NOT NULL
|
|
202
|
+
AND (p_model IS NULL OR a.embedding_model IS NOT DISTINCT FROM p_model)
|
|
203
|
+
AND "{{SCHEMA}}".facts_acl(a.shared, a.session_id, p_reader, p_granted, p_unrestricted, p_scope)
|
|
204
|
+
)
|
|
205
|
+
SELECT f.scope_key, f.key, f.value, f.agent_id, f.session_id,
|
|
206
|
+
f.shared, f.tags, f.created_at, f.updated_at,
|
|
207
|
+
(1 - (f.embedding <=> a.embedding))::double precision AS sim
|
|
208
|
+
FROM "{{SCHEMA}}".facts f, anchor a
|
|
209
|
+
WHERE f.scope_key <> p_scope_key
|
|
210
|
+
AND f.embedding IS NOT NULL
|
|
211
|
+
AND f.embedding_model IS NOT DISTINCT FROM a.embedding_model
|
|
212
|
+
AND "{{SCHEMA}}".facts_acl(f.shared, f.session_id, p_reader, p_granted, p_unrestricted, p_scope)
|
|
213
|
+
AND (p_ns_prefix IS NULL OR f.key LIKE p_ns_prefix)
|
|
214
|
+
AND (1 - (f.embedding <=> a.embedding)) >= coalesce(p_min, 0)
|
|
215
|
+
ORDER BY f.embedding <=> a.embedding ASC
|
|
216
|
+
LIMIT p_k;
|
|
217
|
+
$$;
|
|
218
|
+
|
|
219
|
+
-- ── crawl tracking (PRIVILEGED harvester surface — 01 §6.6) ─────────────────
|
|
220
|
+
-- No access context: the crawler reads everything by design. The mark is
|
|
221
|
+
-- guarded against the read→mark TOCTOU race: a stamp applies only WHERE
|
|
222
|
+
-- content_hash still equals the supplied hash; mismatches are skipped.
|
|
223
|
+
|
|
224
|
+
CREATE OR REPLACE FUNCTION "{{SCHEMA}}".facts_read_uncrawled(
|
|
225
|
+
p_ns_prefix TEXT,
|
|
226
|
+
p_limit INT
|
|
227
|
+
) RETURNS SETOF "{{SCHEMA}}".facts
|
|
228
|
+
LANGUAGE sql STABLE AS $$
|
|
229
|
+
SELECT f.* FROM "{{SCHEMA}}".facts f
|
|
230
|
+
WHERE f.last_crawled_at IS NULL
|
|
231
|
+
AND (p_ns_prefix IS NULL OR f.key LIKE p_ns_prefix)
|
|
232
|
+
ORDER BY f.id
|
|
233
|
+
LIMIT p_limit;
|
|
234
|
+
$$;
|
|
235
|
+
|
|
236
|
+
CREATE OR REPLACE FUNCTION "{{SCHEMA}}".facts_mark_crawled(p_stamps JSONB)
|
|
237
|
+
RETURNS TABLE (marked INT, skipped INT)
|
|
238
|
+
LANGUAGE sql AS $$
|
|
239
|
+
WITH stamps AS (
|
|
240
|
+
SELECT e->>'scopeKey' AS scope_key, e->>'contentHash' AS content_hash
|
|
241
|
+
FROM jsonb_array_elements(p_stamps) e
|
|
242
|
+
),
|
|
243
|
+
upd AS (
|
|
244
|
+
UPDATE "{{SCHEMA}}".facts f
|
|
245
|
+
SET last_crawled_at = now()
|
|
246
|
+
FROM stamps s
|
|
247
|
+
WHERE f.scope_key = s.scope_key
|
|
248
|
+
AND f.content_hash = s.content_hash
|
|
249
|
+
RETURNING f.scope_key
|
|
250
|
+
)
|
|
251
|
+
SELECT (SELECT count(*) FROM upd)::int AS marked,
|
|
252
|
+
((SELECT count(*) FROM stamps) - (SELECT count(*) FROM upd))::int AS skipped;
|
|
253
|
+
$$;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
-- 0005_embedder_workflow — pg_durable grant + the eternal batch-embedding
|
|
2
|
+
-- loop builder (03-design §3).
|
|
3
|
+
--
|
|
4
|
+
-- The loop is ONE durable instance per schema (label hz-embed-cron:<schema>):
|
|
5
|
+
--
|
|
6
|
+
-- df.loop( df.seq( df.sleep(interval),
|
|
7
|
+
-- df.as(df.sql(<batch select>), 'batch'),
|
|
8
|
+
-- df.if_rows('batch',
|
|
9
|
+
-- df.seq(df.as(df.http(url, POST, '$batch.body', hdrs), 'resp'),
|
|
10
|
+
-- df.sql(<zip-back update>)),
|
|
11
|
+
-- df.sql('SELECT 1')) ) )
|
|
12
|
+
--
|
|
13
|
+
-- One HTTP request per BATCH (array-input API), never per fact — no df-in-df.
|
|
14
|
+
--
|
|
15
|
+
-- Config snapshot semantics: endpoint config lives in durable vars
|
|
16
|
+
-- (hz_<schema>_url/_model/...; written by configureEmbedder). This builder
|
|
17
|
+
-- reads them via df.getvar() and bakes LITERALS into the workflow definition,
|
|
18
|
+
-- so the running instance captures config at df.start exactly like {var}
|
|
19
|
+
-- substitution would — and configureEmbedder restarts the loop to apply
|
|
20
|
+
-- changes. (Literal-baking avoids the whole-body '{var}' escaping risk
|
|
21
|
+
-- flagged in 03-design §7 for everything except the body, which must be the
|
|
22
|
+
-- runtime step result '$batch.body'.)
|
|
23
|
+
--
|
|
24
|
+
-- Write-back guard: last_embedded_hash is set from the hashes captured at
|
|
25
|
+
-- SELECT time ($batch.hashes), never the row's current content_hash — a fact
|
|
26
|
+
-- edited mid-flight stays pending (01 §5.3).
|
|
27
|
+
--
|
|
28
|
+
-- Tokens: {{SCHEMA}}.
|
|
29
|
+
|
|
30
|
+
CREATE EXTENSION IF NOT EXISTS pg_durable;
|
|
31
|
+
|
|
32
|
+
DO $$
|
|
33
|
+
BEGIN
|
|
34
|
+
PERFORM df.grant_usage(current_user, true, false);
|
|
35
|
+
EXCEPTION WHEN OTHERS THEN
|
|
36
|
+
NULL; -- may already be granted / insufficient privilege; surfaced by preconditions if it matters
|
|
37
|
+
END $$;
|
|
38
|
+
|
|
39
|
+
CREATE OR REPLACE FUNCTION "{{SCHEMA}}".embedder_workflow(p_interval int, p_batch int)
|
|
40
|
+
RETURNS text
|
|
41
|
+
LANGUAGE plpgsql AS $fn$
|
|
42
|
+
DECLARE
|
|
43
|
+
v_url text;
|
|
44
|
+
v_model text;
|
|
45
|
+
v_key text;
|
|
46
|
+
v_keyhdr text;
|
|
47
|
+
v_bearer text;
|
|
48
|
+
v_inputfield text;
|
|
49
|
+
v_timeout int;
|
|
50
|
+
v_headers jsonb;
|
|
51
|
+
v_batch_sql text;
|
|
52
|
+
v_zip_sql text;
|
|
53
|
+
BEGIN
|
|
54
|
+
v_url := df.getvar('hz_{{SCHEMA}}_url');
|
|
55
|
+
v_model := df.getvar('hz_{{SCHEMA}}_model');
|
|
56
|
+
v_key := df.getvar('hz_{{SCHEMA}}_key');
|
|
57
|
+
v_keyhdr := coalesce(df.getvar('hz_{{SCHEMA}}_keyhdr'), 'api-key');
|
|
58
|
+
v_bearer := coalesce(df.getvar('hz_{{SCHEMA}}_bearer'), 'false');
|
|
59
|
+
v_inputfield := coalesce(df.getvar('hz_{{SCHEMA}}_inputfield'), 'input');
|
|
60
|
+
v_timeout := coalesce(df.getvar('hz_{{SCHEMA}}_timeout'), '30')::int;
|
|
61
|
+
|
|
62
|
+
IF v_url IS NULL OR v_model IS NULL THEN
|
|
63
|
+
RAISE EXCEPTION 'embedder not configured: call configureEmbedder first (durable vars hz_{{SCHEMA}}_url / hz_{{SCHEMA}}_model missing)';
|
|
64
|
+
END IF;
|
|
65
|
+
|
|
66
|
+
v_headers := jsonb_build_object('content-type', 'application/json');
|
|
67
|
+
IF v_key IS NOT NULL AND v_key <> '' THEN
|
|
68
|
+
v_headers := v_headers || jsonb_build_object(
|
|
69
|
+
v_keyhdr,
|
|
70
|
+
CASE WHEN v_bearer = 'true' THEN 'Bearer ' || v_key ELSE v_key END
|
|
71
|
+
);
|
|
72
|
+
END IF;
|
|
73
|
+
|
|
74
|
+
-- Batch select: ONE row (or zero when nothing pending — HAVING) carrying
|
|
75
|
+
-- the ready-to-send body plus positional ids/hashes for the zip-back.
|
|
76
|
+
-- Pending = NULL embedding, stale hash, or model mismatch (01 §5.2).
|
|
77
|
+
v_batch_sql := format(
|
|
78
|
+
$q$SELECT jsonb_build_object(%L, jsonb_agg(t.txt ORDER BY t.id), 'model', %L)::text AS body,
|
|
79
|
+
array_agg(t.id ORDER BY t.id)::text AS ids,
|
|
80
|
+
array_agg(t.content_hash ORDER BY t.id)::text AS hashes
|
|
81
|
+
FROM (SELECT id, content_hash, coalesce(search_text, '') AS txt
|
|
82
|
+
FROM "{{SCHEMA}}".facts
|
|
83
|
+
WHERE embedding IS NULL
|
|
84
|
+
OR last_embedded_hash IS DISTINCT FROM content_hash
|
|
85
|
+
OR embedding_model IS DISTINCT FROM %L
|
|
86
|
+
ORDER BY id
|
|
87
|
+
LIMIT %s) t
|
|
88
|
+
HAVING count(*) > 0$q$,
|
|
89
|
+
v_inputfield, v_model, v_model, greatest(p_batch, 1));
|
|
90
|
+
|
|
91
|
+
-- Zip-back: data[i] ↔ ids[i] positionally (WITH ORDINALITY); write the
|
|
92
|
+
-- SELECT-time hash, stamp the model.
|
|
93
|
+
v_zip_sql := format(
|
|
94
|
+
$q$WITH resp AS (SELECT ($resp::jsonb->>'body')::jsonb AS j),
|
|
95
|
+
emb AS (SELECT v.ord, v.e->'embedding' AS vec
|
|
96
|
+
FROM resp, jsonb_array_elements(resp.j->'data') WITH ORDINALITY AS v(e, ord)),
|
|
97
|
+
tgt AS (SELECT u.id, u.h, u.ord
|
|
98
|
+
FROM unnest(($batch.ids)::bigint[], ($batch.hashes)::text[]) WITH ORDINALITY AS u(id, h, ord))
|
|
99
|
+
UPDATE "{{SCHEMA}}".facts f
|
|
100
|
+
SET embedding = (emb.vec)::text::vector,
|
|
101
|
+
embedded_at = now(),
|
|
102
|
+
embedding_model = %L,
|
|
103
|
+
last_embedded_hash = tgt.h
|
|
104
|
+
FROM emb JOIN tgt ON tgt.ord = emb.ord
|
|
105
|
+
WHERE f.id = tgt.id$q$,
|
|
106
|
+
v_model);
|
|
107
|
+
|
|
108
|
+
RETURN df.loop(
|
|
109
|
+
df.seq(
|
|
110
|
+
df.sleep(greatest(p_interval, 1)::bigint),
|
|
111
|
+
df.seq(
|
|
112
|
+
df.as(df.sql(v_batch_sql), 'batch'),
|
|
113
|
+
df.if_rows('batch',
|
|
114
|
+
df.seq(
|
|
115
|
+
df.as(df.http(v_url, 'POST', '$batch.body', v_headers, v_timeout), 'resp'),
|
|
116
|
+
df.sql(v_zip_sql)),
|
|
117
|
+
df.sql('SELECT 1')))),
|
|
118
|
+
NULL);
|
|
119
|
+
END $fn$;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
-- 0006_facts_read_uncrawled_embedded_gate — add an OPTIONAL embedding gate to
|
|
2
|
+
-- the crawl-queue read (01 §5.2 / §6.6 harvester surface).
|
|
3
|
+
--
|
|
4
|
+
-- Why: the harvester now refines the graph with similarity search over each
|
|
5
|
+
-- fact's STORED embedding (facts_similar). A fact with no embedding yet cannot
|
|
6
|
+
-- be similarity-refined, so handing it to the harvester early produces a
|
|
7
|
+
-- thinner graph. With p_embedded_only => TRUE the queue read SKIPS facts whose
|
|
8
|
+
-- embedding is still NULL; they stay uncrawled and reappear on a later turn
|
|
9
|
+
-- once the in-DB embed loop (0005) has filled them in. The ungated 2-arg
|
|
10
|
+
-- behaviour is preserved as the default (FALSE) so the done-check and any
|
|
11
|
+
-- embedding-less deployment keep draining the whole queue.
|
|
12
|
+
--
|
|
13
|
+
-- Signature change (added param) cannot be done with CREATE OR REPLACE, so the
|
|
14
|
+
-- old 2-arg function is dropped first; the new 3-arg form has a DEFAULT so the
|
|
15
|
+
-- prior 2-arg call sites bind unchanged.
|
|
16
|
+
--
|
|
17
|
+
-- Tokens: {{SCHEMA}}.
|
|
18
|
+
|
|
19
|
+
DROP FUNCTION IF EXISTS "{{SCHEMA}}".facts_read_uncrawled(TEXT, INT);
|
|
20
|
+
|
|
21
|
+
CREATE OR REPLACE FUNCTION "{{SCHEMA}}".facts_read_uncrawled(
|
|
22
|
+
p_ns_prefix TEXT,
|
|
23
|
+
p_limit INT,
|
|
24
|
+
p_embedded_only BOOLEAN DEFAULT FALSE
|
|
25
|
+
) RETURNS SETOF "{{SCHEMA}}".facts
|
|
26
|
+
LANGUAGE sql STABLE AS $$
|
|
27
|
+
SELECT f.* FROM "{{SCHEMA}}".facts f
|
|
28
|
+
WHERE f.last_crawled_at IS NULL
|
|
29
|
+
AND (p_ns_prefix IS NULL OR f.key LIKE p_ns_prefix)
|
|
30
|
+
AND (NOT p_embedded_only OR f.embedding IS NOT NULL)
|
|
31
|
+
ORDER BY f.id
|
|
32
|
+
LIMIT p_limit;
|
|
33
|
+
$$;
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
-- 0007_embed_key_value_text — embed the KEY + the FULL VALUE, not the
|
|
2
|
+
-- cherry-picked search_text columns (03-design §3, embed-input correctness).
|
|
3
|
+
--
|
|
4
|
+
-- WHY. The 0005 embedder built its per-fact input from `search_text`, a STORED
|
|
5
|
+
-- generated column that concatenates only a fixed set of conventional value
|
|
6
|
+
-- fields:
|
|
7
|
+
--
|
|
8
|
+
-- coalesce(key,'') || ' ' || value->>'name' || ' ' || value->>'description'
|
|
9
|
+
-- || ' ' || value->>'text' || ' ' || value->>'body'
|
|
10
|
+
-- || ' ' || value->>'subject'
|
|
11
|
+
--
|
|
12
|
+
-- For a fact whose value uses ANY other shape — e.g. a harvested document
|
|
13
|
+
-- stored as { "title": ..., "content": ... } — none of those fields match, so
|
|
14
|
+
-- search_text collapses to essentially just the key and the embedding carries
|
|
15
|
+
-- almost no signal. The vector column then exists but is useless for semantic /
|
|
16
|
+
-- hybrid retrieval and for the harvester's facts_similar refinement (0006).
|
|
17
|
+
--
|
|
18
|
+
-- FIX. Embed `key` + a delimiter + the WHOLE value rendered as text, so the
|
|
19
|
+
-- embedding represents the entire fact regardless of value shape:
|
|
20
|
+
--
|
|
21
|
+
-- coalesce(key,'') || E'\n' || coalesce(value::text,'')
|
|
22
|
+
--
|
|
23
|
+
-- This mirrors how content_hash already keys off `key || value::text` (0001):
|
|
24
|
+
-- the embed input now covers exactly what the hash covers, so "content changed
|
|
25
|
+
-- ⇒ re-embed" stays coherent. `search_text` (lexical/BM25) is intentionally
|
|
26
|
+
-- left unchanged — this migration only changes the EMBEDDING input text.
|
|
27
|
+
--
|
|
28
|
+
-- Only the `t.txt` projection inside v_batch_sql changes versus 0005; the loop
|
|
29
|
+
-- shape, batching, config-var reads, and zip-back write-back are identical. This
|
|
30
|
+
-- is a CREATE OR REPLACE of the workflow BUILDER. A loop instance that is
|
|
31
|
+
-- already running was baked from the old function body at df.start and will keep
|
|
32
|
+
-- using the old text until it is cancelled and restarted (configureEmbedder
|
|
33
|
+
-- restart, stopEmbedder + startEmbedder, or a fresh deploy after the schema +
|
|
34
|
+
-- durable instance are dropped). New deployments pick it up immediately.
|
|
35
|
+
--
|
|
36
|
+
-- Tokens: {{SCHEMA}}.
|
|
37
|
+
|
|
38
|
+
CREATE OR REPLACE FUNCTION "{{SCHEMA}}".embedder_workflow(p_interval int, p_batch int)
|
|
39
|
+
RETURNS text
|
|
40
|
+
LANGUAGE plpgsql AS $fn$
|
|
41
|
+
DECLARE
|
|
42
|
+
v_url text;
|
|
43
|
+
v_model text;
|
|
44
|
+
v_key text;
|
|
45
|
+
v_keyhdr text;
|
|
46
|
+
v_bearer text;
|
|
47
|
+
v_inputfield text;
|
|
48
|
+
v_timeout int;
|
|
49
|
+
v_headers jsonb;
|
|
50
|
+
v_batch_sql text;
|
|
51
|
+
v_zip_sql text;
|
|
52
|
+
BEGIN
|
|
53
|
+
v_url := df.getvar('hz_{{SCHEMA}}_url');
|
|
54
|
+
v_model := df.getvar('hz_{{SCHEMA}}_model');
|
|
55
|
+
v_key := df.getvar('hz_{{SCHEMA}}_key');
|
|
56
|
+
v_keyhdr := coalesce(df.getvar('hz_{{SCHEMA}}_keyhdr'), 'api-key');
|
|
57
|
+
v_bearer := coalesce(df.getvar('hz_{{SCHEMA}}_bearer'), 'false');
|
|
58
|
+
v_inputfield := coalesce(df.getvar('hz_{{SCHEMA}}_inputfield'), 'input');
|
|
59
|
+
v_timeout := coalesce(df.getvar('hz_{{SCHEMA}}_timeout'), '30')::int;
|
|
60
|
+
|
|
61
|
+
IF v_url IS NULL OR v_model IS NULL THEN
|
|
62
|
+
RAISE EXCEPTION 'embedder not configured: call configureEmbedder first (durable vars hz_{{SCHEMA}}_url / hz_{{SCHEMA}}_model missing)';
|
|
63
|
+
END IF;
|
|
64
|
+
|
|
65
|
+
v_headers := jsonb_build_object('content-type', 'application/json');
|
|
66
|
+
IF v_key IS NOT NULL AND v_key <> '' THEN
|
|
67
|
+
v_headers := v_headers || jsonb_build_object(
|
|
68
|
+
v_keyhdr,
|
|
69
|
+
CASE WHEN v_bearer = 'true' THEN 'Bearer ' || v_key ELSE v_key END
|
|
70
|
+
);
|
|
71
|
+
END IF;
|
|
72
|
+
|
|
73
|
+
-- Batch select: ONE row (or zero when nothing pending — HAVING) carrying
|
|
74
|
+
-- the ready-to-send body plus positional ids/hashes for the zip-back.
|
|
75
|
+
-- Pending = NULL embedding, stale hash, or model mismatch (01 §5.2).
|
|
76
|
+
--
|
|
77
|
+
-- Embed input (CHANGED in 0007): key + delimiter + the FULL value as text,
|
|
78
|
+
-- so any value shape is represented — not just the search_text fields.
|
|
79
|
+
v_batch_sql := format(
|
|
80
|
+
$q$SELECT jsonb_build_object(%L, jsonb_agg(t.txt ORDER BY t.id), 'model', %L)::text AS body,
|
|
81
|
+
array_agg(t.id ORDER BY t.id)::text AS ids,
|
|
82
|
+
array_agg(t.content_hash ORDER BY t.id)::text AS hashes
|
|
83
|
+
FROM (SELECT id, content_hash,
|
|
84
|
+
coalesce(key, '') || E'\n' || coalesce(value::text, '') AS txt
|
|
85
|
+
FROM "{{SCHEMA}}".facts
|
|
86
|
+
WHERE embedding IS NULL
|
|
87
|
+
OR last_embedded_hash IS DISTINCT FROM content_hash
|
|
88
|
+
OR embedding_model IS DISTINCT FROM %L
|
|
89
|
+
ORDER BY id
|
|
90
|
+
LIMIT %s) t
|
|
91
|
+
HAVING count(*) > 0$q$,
|
|
92
|
+
v_inputfield, v_model, v_model, greatest(p_batch, 1));
|
|
93
|
+
|
|
94
|
+
-- Zip-back: data[i] ↔ ids[i] positionally (WITH ORDINALITY); write the
|
|
95
|
+
-- SELECT-time hash, stamp the model. (Unchanged from 0005.)
|
|
96
|
+
v_zip_sql := format(
|
|
97
|
+
$q$WITH resp AS (SELECT ($resp::jsonb->>'body')::jsonb AS j),
|
|
98
|
+
emb AS (SELECT v.ord, v.e->'embedding' AS vec
|
|
99
|
+
FROM resp, jsonb_array_elements(resp.j->'data') WITH ORDINALITY AS v(e, ord)),
|
|
100
|
+
tgt AS (SELECT u.id, u.h, u.ord
|
|
101
|
+
FROM unnest(($batch.ids)::bigint[], ($batch.hashes)::text[]) WITH ORDINALITY AS u(id, h, ord))
|
|
102
|
+
UPDATE "{{SCHEMA}}".facts f
|
|
103
|
+
SET embedding = (emb.vec)::text::vector,
|
|
104
|
+
embedded_at = now(),
|
|
105
|
+
embedding_model = %L,
|
|
106
|
+
last_embedded_hash = tgt.h
|
|
107
|
+
FROM emb JOIN tgt ON tgt.ord = emb.ord
|
|
108
|
+
WHERE f.id = tgt.id$q$,
|
|
109
|
+
v_model);
|
|
110
|
+
|
|
111
|
+
RETURN df.loop(
|
|
112
|
+
df.seq(
|
|
113
|
+
df.sleep(greatest(p_interval, 1)::bigint),
|
|
114
|
+
df.seq(
|
|
115
|
+
df.as(df.sql(v_batch_sql), 'batch'),
|
|
116
|
+
df.if_rows('batch',
|
|
117
|
+
df.seq(
|
|
118
|
+
df.as(df.http(v_url, 'POST', '$batch.body', v_headers, v_timeout), 'resp'),
|
|
119
|
+
df.sql(v_zip_sql)),
|
|
120
|
+
df.sql('SELECT 1')))),
|
|
121
|
+
NULL);
|
|
122
|
+
END $fn$;
|