@gonzih/meet-the-one-ai 1.0.0
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/.env.example +41 -0
- package/.node-version +1 -0
- package/basis/BERNAYS.md +233 -0
- package/basis/FOUNDING_TRANSCRIPT.md +218 -0
- package/basis/TECH_SPEC.md +303 -0
- package/basis/VALS.md +255 -0
- package/basis/layers/L1_IDENTITY_AUTH.md +78 -0
- package/basis/layers/L2_CONVERSATION.md +159 -0
- package/basis/layers/L3_RECORDING_STORE.md +104 -0
- package/basis/layers/L4_ANALYSIS_PIPELINE.md +257 -0
- package/basis/layers/L5_MATCHING_ENGINE.md +164 -0
- package/basis/layers/L6_CONSENT_INTRODUCTION.md +143 -0
- package/basis/layers/L7_PORTABLE_IDENTITY.md +139 -0
- package/basis/layers/STACK.md +64 -0
- package/basis/schema.sql +203 -0
- package/dist/agent.d.ts +2 -0
- package/dist/agent.d.ts.map +1 -0
- package/dist/agent.js +114 -0
- package/dist/agent.js.map +1 -0
- package/dist/api/routes/auth.d.ts +2 -0
- package/dist/api/routes/auth.d.ts.map +1 -0
- package/dist/api/routes/auth.js +79 -0
- package/dist/api/routes/auth.js.map +1 -0
- package/dist/api/routes/identity.d.ts +2 -0
- package/dist/api/routes/identity.d.ts.map +1 -0
- package/dist/api/routes/identity.js +92 -0
- package/dist/api/routes/identity.js.map +1 -0
- package/dist/api/routes/text-submission.d.ts +2 -0
- package/dist/api/routes/text-submission.d.ts.map +1 -0
- package/dist/api/routes/text-submission.js +56 -0
- package/dist/api/routes/text-submission.js.map +1 -0
- package/dist/api/webhooks/twilio.d.ts +2 -0
- package/dist/api/webhooks/twilio.d.ts.map +1 -0
- package/dist/api/webhooks/twilio.js +144 -0
- package/dist/api/webhooks/twilio.js.map +1 -0
- package/dist/api/webhooks/vapi.d.ts +2 -0
- package/dist/api/webhooks/vapi.d.ts.map +1 -0
- package/dist/api/webhooks/vapi.js +177 -0
- package/dist/api/webhooks/vapi.js.map +1 -0
- package/dist/bot.d.ts +3 -0
- package/dist/bot.d.ts.map +1 -0
- package/dist/bot.js +39 -0
- package/dist/bot.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/jobs/compact-identity.d.ts +2 -0
- package/dist/jobs/compact-identity.d.ts.map +1 -0
- package/dist/jobs/compact-identity.js +159 -0
- package/dist/jobs/compact-identity.js.map +1 -0
- package/dist/jobs/consent-call.d.ts +2 -0
- package/dist/jobs/consent-call.d.ts.map +1 -0
- package/dist/jobs/consent-call.js +70 -0
- package/dist/jobs/consent-call.js.map +1 -0
- package/dist/jobs/export-identity.d.ts +2 -0
- package/dist/jobs/export-identity.d.ts.map +1 -0
- package/dist/jobs/export-identity.js +129 -0
- package/dist/jobs/export-identity.js.map +1 -0
- package/dist/jobs/introduction-call.d.ts +2 -0
- package/dist/jobs/introduction-call.d.ts.map +1 -0
- package/dist/jobs/introduction-call.js +86 -0
- package/dist/jobs/introduction-call.js.map +1 -0
- package/dist/jobs/reanalyze-identity.d.ts +2 -0
- package/dist/jobs/reanalyze-identity.d.ts.map +1 -0
- package/dist/jobs/reanalyze-identity.js +56 -0
- package/dist/jobs/reanalyze-identity.js.map +1 -0
- package/dist/jobs/run-matching.d.ts +2 -0
- package/dist/jobs/run-matching.d.ts.map +1 -0
- package/dist/jobs/run-matching.js +200 -0
- package/dist/jobs/run-matching.js.map +1 -0
- package/dist/jobs/scheduled-matching.d.ts +2 -0
- package/dist/jobs/scheduled-matching.d.ts.map +1 -0
- package/dist/jobs/scheduled-matching.js +44 -0
- package/dist/jobs/scheduled-matching.js.map +1 -0
- package/dist/jobs/transcribe-session.d.ts +2 -0
- package/dist/jobs/transcribe-session.d.ts.map +1 -0
- package/dist/jobs/transcribe-session.js +66 -0
- package/dist/jobs/transcribe-session.js.map +1 -0
- package/dist/lib/anthropic.d.ts +4 -0
- package/dist/lib/anthropic.d.ts.map +1 -0
- package/dist/lib/anthropic.js +32 -0
- package/dist/lib/anthropic.js.map +1 -0
- package/dist/lib/config.d.ts +57 -0
- package/dist/lib/config.d.ts.map +1 -0
- package/dist/lib/config.js +73 -0
- package/dist/lib/config.js.map +1 -0
- package/dist/lib/deepgram.d.ts +15 -0
- package/dist/lib/deepgram.d.ts.map +1 -0
- package/dist/lib/deepgram.js +37 -0
- package/dist/lib/deepgram.js.map +1 -0
- package/dist/lib/inngest.d.ts +42 -0
- package/dist/lib/inngest.d.ts.map +1 -0
- package/dist/lib/inngest.js +7 -0
- package/dist/lib/inngest.js.map +1 -0
- package/dist/lib/openai.d.ts +3 -0
- package/dist/lib/openai.d.ts.map +1 -0
- package/dist/lib/openai.js +13 -0
- package/dist/lib/openai.js.map +1 -0
- package/dist/lib/prompts.d.ts +8 -0
- package/dist/lib/prompts.d.ts.map +1 -0
- package/dist/lib/prompts.js +258 -0
- package/dist/lib/prompts.js.map +1 -0
- package/dist/lib/r2.d.ts +7 -0
- package/dist/lib/r2.d.ts.map +1 -0
- package/dist/lib/r2.js +49 -0
- package/dist/lib/r2.js.map +1 -0
- package/dist/lib/session-helpers.d.ts +8 -0
- package/dist/lib/session-helpers.d.ts.map +1 -0
- package/dist/lib/session-helpers.js +31 -0
- package/dist/lib/session-helpers.js.map +1 -0
- package/dist/lib/supabase.d.ts +2 -0
- package/dist/lib/supabase.d.ts.map +1 -0
- package/dist/lib/supabase.js +11 -0
- package/dist/lib/supabase.js.map +1 -0
- package/dist/lib/twilio.d.ts +7 -0
- package/dist/lib/twilio.d.ts.map +1 -0
- package/dist/lib/twilio.js +34 -0
- package/dist/lib/twilio.js.map +1 -0
- package/dist/lib/vapi.d.ts +4 -0
- package/dist/lib/vapi.d.ts.map +1 -0
- package/dist/lib/vapi.js +59 -0
- package/dist/lib/vapi.js.map +1 -0
- package/dist/mcp-server.d.ts +3 -0
- package/dist/mcp-server.d.ts.map +1 -0
- package/dist/mcp-server.js +177 -0
- package/dist/mcp-server.js.map +1 -0
- package/dist/types/index.d.ts +104 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +3 -0
- package/dist/types/index.js.map +1 -0
- package/package.json +28 -0
- package/railway.json +14 -0
- package/src/agent.ts +123 -0
- package/src/api/routes/auth.ts +95 -0
- package/src/api/routes/identity.ts +112 -0
- package/src/api/routes/text-submission.ts +64 -0
- package/src/api/webhooks/twilio.ts +181 -0
- package/src/api/webhooks/vapi.ts +219 -0
- package/src/bot.ts +44 -0
- package/src/index.ts +11 -0
- package/src/jobs/compact-identity.ts +211 -0
- package/src/jobs/consent-call.ts +87 -0
- package/src/jobs/export-identity.ts +166 -0
- package/src/jobs/introduction-call.ts +101 -0
- package/src/jobs/reanalyze-identity.ts +65 -0
- package/src/jobs/run-matching.ts +243 -0
- package/src/jobs/scheduled-matching.ts +59 -0
- package/src/jobs/transcribe-session.ts +77 -0
- package/src/lib/anthropic.ts +37 -0
- package/src/lib/config.ts +81 -0
- package/src/lib/deepgram.ts +57 -0
- package/src/lib/inngest.ts +33 -0
- package/src/lib/openai.ts +14 -0
- package/src/lib/prompts.ts +266 -0
- package/src/lib/r2.ts +79 -0
- package/src/lib/session-helpers.ts +37 -0
- package/src/lib/supabase.ts +15 -0
- package/src/lib/twilio.ts +49 -0
- package/src/lib/vapi.ts +80 -0
- package/src/mcp-server.ts +195 -0
- package/src/types/index.ts +146 -0
- package/supabase/.branches/_current_branch +1 -0
- package/supabase/.temp/cli-latest +1 -0
- package/supabase/.temp/gotrue-version +1 -0
- package/supabase/.temp/pooler-url +1 -0
- package/supabase/.temp/postgres-version +1 -0
- package/supabase/.temp/project-ref +1 -0
- package/supabase/.temp/rest-version +1 -0
- package/supabase/.temp/storage-migration +1 -0
- package/supabase/.temp/storage-version +1 -0
- package/supabase/config.toml +384 -0
- package/supabase/migrations/20260303000000_initial_schema.sql +203 -0
- package/supabase/migrations/20260304000000_brand_consents.sql +13 -0
- package/tsconfig.json +25 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { inngest } from "../lib/inngest.js";
|
|
2
|
+
import { supabase } from "../lib/supabase.js";
|
|
3
|
+
import { complete, extract_json } from "../lib/anthropic.js";
|
|
4
|
+
import { SYSTEM_MATCH_VALIDATION } from "../lib/prompts.js";
|
|
5
|
+
import type { ModalityWeights, Modality } from "../types/index.js";
|
|
6
|
+
|
|
7
|
+
const WORLD_VIEW_DELTA_MAX = 0.5;
|
|
8
|
+
const CONFIDENCE_THRESHOLD = 0.70;
|
|
9
|
+
const MAX_MATCHES_IN_QUEUE = 10;
|
|
10
|
+
const VECTOR_CANDIDATES_PER_MODALITY = 30;
|
|
11
|
+
|
|
12
|
+
// Triggered when a user's identity is updated
|
|
13
|
+
// 1. Vector search per active modality via pgvector RPC
|
|
14
|
+
// 2. Hard-filter on worldview bifurcations
|
|
15
|
+
// 3. LLM-validate each candidate
|
|
16
|
+
// 4. Insert confirmed matches + fire match/confirmed
|
|
17
|
+
export const run_matching = inngest.createFunction(
|
|
18
|
+
{ id: "run-matching", retries: 2 },
|
|
19
|
+
{ event: "identity/updated" },
|
|
20
|
+
async ({ event, step }) => {
|
|
21
|
+
const { user_id } = event.data;
|
|
22
|
+
|
|
23
|
+
// ── Load identity ─────────────────────────────────────────────────────────
|
|
24
|
+
const identity = await step.run("load-identity", async () => {
|
|
25
|
+
const { data, error } = await supabase
|
|
26
|
+
.from("identities")
|
|
27
|
+
.select("base_profile, modality_weights, signal_completeness_score, ready_for_matching")
|
|
28
|
+
.eq("user_id", user_id)
|
|
29
|
+
.single();
|
|
30
|
+
if (error || !data) throw new Error("Identity not found");
|
|
31
|
+
return data;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
if (!identity.ready_for_matching) {
|
|
35
|
+
return { skipped: true, reason: "not ready for matching" };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ── Check queue capacity ──────────────────────────────────────────────────
|
|
39
|
+
const { count: pending_count } = await supabase
|
|
40
|
+
.from("matches")
|
|
41
|
+
.select("*", { count: "exact", head: true })
|
|
42
|
+
.or(`user_a_id.eq.${user_id},user_b_id.eq.${user_id}`)
|
|
43
|
+
.eq("status", "pending_consent");
|
|
44
|
+
|
|
45
|
+
if ((pending_count ?? 0) >= MAX_MATCHES_IN_QUEUE) {
|
|
46
|
+
return { skipped: true, reason: "match queue full" };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const slots_available = MAX_MATCHES_IN_QUEUE - (pending_count ?? 0);
|
|
50
|
+
|
|
51
|
+
// ── Load user's modality embeddings ───────────────────────────────────────
|
|
52
|
+
const my_embeddings = await step.run("load-embeddings", async () => {
|
|
53
|
+
const { data } = await supabase
|
|
54
|
+
.from("identity_modality_embeddings")
|
|
55
|
+
.select("modality, embedding")
|
|
56
|
+
.eq("user_id", user_id);
|
|
57
|
+
return data ?? [];
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
if (my_embeddings.length === 0) {
|
|
61
|
+
return { skipped: true, reason: "no embeddings yet" };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ── Build exclusion set (existing matches + declined pairs) ───────────────
|
|
65
|
+
const [existing_matches, declined_pairs] = await Promise.all([
|
|
66
|
+
supabase
|
|
67
|
+
.from("matches")
|
|
68
|
+
.select("user_a_id, user_b_id")
|
|
69
|
+
.or(`user_a_id.eq.${user_id},user_b_id.eq.${user_id}`),
|
|
70
|
+
supabase
|
|
71
|
+
.from("declined_pairs")
|
|
72
|
+
.select("user_a_id, user_b_id")
|
|
73
|
+
.or(`user_a_id.eq.${user_id},user_b_id.eq.${user_id}`),
|
|
74
|
+
]);
|
|
75
|
+
|
|
76
|
+
const excluded_ids = new Set<string>([user_id]); // always exclude self
|
|
77
|
+
for (const p of existing_matches.data ?? []) {
|
|
78
|
+
excluded_ids.add(p.user_a_id);
|
|
79
|
+
excluded_ids.add(p.user_b_id);
|
|
80
|
+
}
|
|
81
|
+
for (const p of declined_pairs.data ?? []) {
|
|
82
|
+
excluded_ids.add(p.user_a_id);
|
|
83
|
+
excluded_ids.add(p.user_b_id);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── Vector search per active modality ─────────────────────────────────────
|
|
87
|
+
// Best similarity per candidate across all modality searches
|
|
88
|
+
const candidate_scores = new Map<string, { similarity: number; modality: string }>();
|
|
89
|
+
|
|
90
|
+
const vector_results = await step.run("vector-search", async () => {
|
|
91
|
+
const results: Array<{ user_id: string; modality: string; similarity: number }> = [];
|
|
92
|
+
|
|
93
|
+
for (const { modality, embedding } of my_embeddings) {
|
|
94
|
+
const { data, error } = await supabase.rpc("match_identities", {
|
|
95
|
+
p_user_id: user_id,
|
|
96
|
+
p_modality: modality,
|
|
97
|
+
p_embedding: embedding,
|
|
98
|
+
p_limit: VECTOR_CANDIDATES_PER_MODALITY,
|
|
99
|
+
});
|
|
100
|
+
if (error) {
|
|
101
|
+
console.error(`match_identities RPC error for modality ${modality}:`, error);
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
for (const row of data ?? []) {
|
|
105
|
+
results.push(row);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return results;
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
for (const row of vector_results) {
|
|
112
|
+
if (excluded_ids.has(row.user_id)) continue;
|
|
113
|
+
const existing = candidate_scores.get(row.user_id);
|
|
114
|
+
if (!existing || row.similarity > existing.similarity) {
|
|
115
|
+
candidate_scores.set(row.user_id, {
|
|
116
|
+
similarity: row.similarity,
|
|
117
|
+
modality: row.modality,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Sort by similarity descending, take top 20 for LLM validation
|
|
123
|
+
const ranked_candidates = Array.from(candidate_scores.entries())
|
|
124
|
+
.sort((a, b) => b[1].similarity - a[1].similarity)
|
|
125
|
+
.slice(0, 20)
|
|
126
|
+
.map(([cid, meta]) => ({ user_id: cid, ...meta }));
|
|
127
|
+
|
|
128
|
+
if (ranked_candidates.length === 0) {
|
|
129
|
+
return { user_id, confirmed_matches: 0, reason: "no vector candidates" };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── Load candidate full profiles ──────────────────────────────────────────
|
|
133
|
+
const candidate_ids = ranked_candidates.map((c) => c.user_id);
|
|
134
|
+
const { data: candidate_profiles } = await supabase
|
|
135
|
+
.from("identities")
|
|
136
|
+
.select("user_id, base_profile, modality_weights")
|
|
137
|
+
.in("user_id", candidate_ids);
|
|
138
|
+
|
|
139
|
+
const profile_map = new Map(
|
|
140
|
+
(candidate_profiles ?? []).map((p) => [p.user_id, p])
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
// ── Hard filter: worldview bifurcation gates ──────────────────────────────
|
|
144
|
+
const a_wv = identity.base_profile.worldview;
|
|
145
|
+
|
|
146
|
+
const passing_candidates = ranked_candidates.filter((c) => {
|
|
147
|
+
const profile = profile_map.get(c.user_id);
|
|
148
|
+
if (!profile) return false;
|
|
149
|
+
const b_wv = profile.base_profile.worldview;
|
|
150
|
+
const danger_delta = Math.abs(a_wv.world_danger_adventure - b_wv.world_danger_adventure);
|
|
151
|
+
const people_delta = Math.abs(a_wv.people_good_bad - b_wv.people_good_bad);
|
|
152
|
+
return danger_delta <= WORLD_VIEW_DELTA_MAX && people_delta <= WORLD_VIEW_DELTA_MAX;
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
// ── LLM validation ────────────────────────────────────────────────────────
|
|
156
|
+
const confirmed_matches: string[] = [];
|
|
157
|
+
|
|
158
|
+
for (const candidate of passing_candidates) {
|
|
159
|
+
if (confirmed_matches.length >= slots_available) break;
|
|
160
|
+
|
|
161
|
+
const profile = profile_map.get(candidate.user_id)!;
|
|
162
|
+
const primary_modality = dominant_shared_modality(
|
|
163
|
+
identity.modality_weights,
|
|
164
|
+
profile.modality_weights
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
const assessment_raw = await step.run(
|
|
168
|
+
`validate-${candidate.user_id}`,
|
|
169
|
+
async () =>
|
|
170
|
+
complete(
|
|
171
|
+
"matching",
|
|
172
|
+
SYSTEM_MATCH_VALIDATION,
|
|
173
|
+
[
|
|
174
|
+
`Person A profile (${primary_modality} lens):\n${JSON.stringify(identity.base_profile, null, 2)}`,
|
|
175
|
+
`Person A modality weights: ${JSON.stringify(identity.modality_weights)}`,
|
|
176
|
+
`\nPerson B profile (${primary_modality} lens):\n${JSON.stringify(profile.base_profile, null, 2)}`,
|
|
177
|
+
`Person B modality weights: ${JSON.stringify(profile.modality_weights)}`,
|
|
178
|
+
`\nVector similarity score: ${candidate.similarity.toFixed(3)} (modality: ${candidate.modality})`,
|
|
179
|
+
].join("\n\n")
|
|
180
|
+
)
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
let assessment: Record<string, unknown>;
|
|
184
|
+
try {
|
|
185
|
+
assessment = JSON.parse(extract_json(assessment_raw));
|
|
186
|
+
} catch {
|
|
187
|
+
console.error(`Failed to parse assessment for ${candidate.user_id}`);
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (!assessment.compatible || assessment.tension_fatal) continue;
|
|
192
|
+
if ((assessment.confidence as number) < CONFIDENCE_THRESHOLD) continue;
|
|
193
|
+
|
|
194
|
+
// Insert match and capture the generated UUID
|
|
195
|
+
const { data: match_row, error: insert_error } = await supabase
|
|
196
|
+
.from("matches")
|
|
197
|
+
.insert({
|
|
198
|
+
user_a_id: user_id,
|
|
199
|
+
user_b_id: candidate.user_id,
|
|
200
|
+
primary_modality,
|
|
201
|
+
cross_modality: assessment.cross_modality ?? false,
|
|
202
|
+
cross_modality_bridge: assessment.cross_modality_bridge ?? null,
|
|
203
|
+
confidence_score: assessment.confidence,
|
|
204
|
+
resonances: assessment.resonances ?? [],
|
|
205
|
+
tensions: assessment.tensions ?? [],
|
|
206
|
+
tension_fatal: false,
|
|
207
|
+
consent_call_framing: assessment.consent_call_framing ?? "",
|
|
208
|
+
status: "pending_consent",
|
|
209
|
+
created_at: new Date().toISOString(),
|
|
210
|
+
})
|
|
211
|
+
.select("id")
|
|
212
|
+
.single();
|
|
213
|
+
|
|
214
|
+
if (insert_error || !match_row) {
|
|
215
|
+
console.error("Failed to insert match:", insert_error);
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
confirmed_matches.push(candidate.user_id);
|
|
220
|
+
|
|
221
|
+
await step.sendEvent(`match-confirmed-${match_row.id}`, {
|
|
222
|
+
name: "match/confirmed",
|
|
223
|
+
data: { match_id: match_row.id },
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return { user_id, confirmed_matches: confirmed_matches.length };
|
|
228
|
+
}
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
function dominant_shared_modality(a: ModalityWeights, b: ModalityWeights): Modality {
|
|
232
|
+
const modalities = Object.keys(a) as Modality[];
|
|
233
|
+
let best = modalities[0];
|
|
234
|
+
let best_score = 0;
|
|
235
|
+
for (const m of modalities) {
|
|
236
|
+
const score = Math.min(a[m], b[m]);
|
|
237
|
+
if (score > best_score) {
|
|
238
|
+
best_score = score;
|
|
239
|
+
best = m;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return best;
|
|
243
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { inngest } from "../lib/inngest.js";
|
|
2
|
+
import { supabase } from "../lib/supabase.js";
|
|
3
|
+
|
|
4
|
+
const MAX_MATCHES_IN_QUEUE = 10;
|
|
5
|
+
|
|
6
|
+
// Hourly cron — finds all ready users with queue capacity and re-runs matching
|
|
7
|
+
// Ensures new users get found by existing users even without a new session
|
|
8
|
+
export const scheduled_matching = inngest.createFunction(
|
|
9
|
+
{ id: "scheduled-matching", retries: 1 },
|
|
10
|
+
{ cron: "0 * * * *" }, // every hour on the hour
|
|
11
|
+
async ({ step }) => {
|
|
12
|
+
// Find all users ready for matching
|
|
13
|
+
const eligible_users = await step.run("find-eligible-users", async () => {
|
|
14
|
+
const { data, error } = await supabase
|
|
15
|
+
.from("identities")
|
|
16
|
+
.select("user_id")
|
|
17
|
+
.eq("ready_for_matching", true);
|
|
18
|
+
if (error) throw new Error(`Failed to load eligible users: ${error.message}`);
|
|
19
|
+
return data ?? [];
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
if (eligible_users.length === 0) {
|
|
23
|
+
return { triggered: 0 };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Filter to those with queue capacity
|
|
27
|
+
const user_ids = eligible_users.map((u) => u.user_id);
|
|
28
|
+
|
|
29
|
+
const { data: queue_counts } = await supabase
|
|
30
|
+
.from("matches")
|
|
31
|
+
.select("user_a_id, user_b_id")
|
|
32
|
+
.or(user_ids.map((id) => `user_a_id.eq.${id},user_b_id.eq.${id}`).join(","))
|
|
33
|
+
.eq("status", "pending_consent");
|
|
34
|
+
|
|
35
|
+
// Count pending matches per user
|
|
36
|
+
const pending_per_user = new Map<string, number>();
|
|
37
|
+
for (const row of queue_counts ?? []) {
|
|
38
|
+
pending_per_user.set(row.user_a_id, (pending_per_user.get(row.user_a_id) ?? 0) + 1);
|
|
39
|
+
pending_per_user.set(row.user_b_id, (pending_per_user.get(row.user_b_id) ?? 0) + 1);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const users_with_capacity = user_ids.filter(
|
|
43
|
+
(id) => (pending_per_user.get(id) ?? 0) < MAX_MATCHES_IN_QUEUE
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
// Fan out identity/updated events — run_matching handles each
|
|
47
|
+
if (users_with_capacity.length > 0) {
|
|
48
|
+
await step.sendEvent(
|
|
49
|
+
"scheduled-matching-fanout",
|
|
50
|
+
users_with_capacity.map((user_id) => ({
|
|
51
|
+
name: "identity/updated" as const,
|
|
52
|
+
data: { user_id },
|
|
53
|
+
}))
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return { triggered: users_with_capacity.length, total_eligible: eligible_users.length };
|
|
58
|
+
}
|
|
59
|
+
);
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { inngest } from "../lib/inngest.js";
|
|
2
|
+
import { supabase } from "../lib/supabase.js";
|
|
3
|
+
import { transcribe_url, user_speech_only } from "../lib/deepgram.js";
|
|
4
|
+
import { generate_presigned_get_url, upload_json } from "../lib/r2.js";
|
|
5
|
+
|
|
6
|
+
// Triggered when a voice session recording lands in R2
|
|
7
|
+
// Runs transcription, stores result, fires next event
|
|
8
|
+
export const transcribe_session = inngest.createFunction(
|
|
9
|
+
{ id: "transcribe-session", retries: 3 },
|
|
10
|
+
{ event: "session/completed" },
|
|
11
|
+
async ({ event, step }) => {
|
|
12
|
+
const { user_id, session_id } = event.data;
|
|
13
|
+
|
|
14
|
+
// Mark as processing
|
|
15
|
+
await step.run("mark-processing", async () => {
|
|
16
|
+
await supabase
|
|
17
|
+
.from("sessions")
|
|
18
|
+
.update({ analysis_status: "processing" })
|
|
19
|
+
.eq("id", session_id);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// Get session record to find audio key
|
|
23
|
+
const session = await step.run("get-session", async () => {
|
|
24
|
+
const { data, error } = await supabase
|
|
25
|
+
.from("sessions")
|
|
26
|
+
.select("audio_r2_key")
|
|
27
|
+
.eq("id", session_id)
|
|
28
|
+
.single();
|
|
29
|
+
if (error || !data) throw new Error(`Session not found: ${session_id}`);
|
|
30
|
+
if (!data.audio_r2_key) throw new Error(`No audio_r2_key for session: ${session_id}`);
|
|
31
|
+
return data;
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
// Generate presigned URL for Deepgram to fetch audio from R2
|
|
35
|
+
const audio_url = await step.run("presign-url", async () => {
|
|
36
|
+
return generate_presigned_get_url(session.audio_r2_key!, 3600);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
// Transcribe via Deepgram
|
|
40
|
+
const result = await step.run("transcribe", async () => {
|
|
41
|
+
return transcribe_url(audio_url);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Extract user-only speech (speaker 0 = user)
|
|
45
|
+
const user_transcript = user_speech_only(result);
|
|
46
|
+
|
|
47
|
+
// Store transcript JSON to R2
|
|
48
|
+
const transcript_r2_key = `transcripts/${user_id}/${session_id}.json`;
|
|
49
|
+
await step.run("store-transcript", async () => {
|
|
50
|
+
await upload_json(transcript_r2_key, {
|
|
51
|
+
full: result,
|
|
52
|
+
text: user_transcript,
|
|
53
|
+
stored_at: new Date().toISOString(),
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// Update session record
|
|
58
|
+
await step.run("update-session", async () => {
|
|
59
|
+
await supabase
|
|
60
|
+
.from("sessions")
|
|
61
|
+
.update({
|
|
62
|
+
transcript_r2_key,
|
|
63
|
+
analysis_status: "pending", // reset so compact-identity picks it up
|
|
64
|
+
duration_seconds: Math.round(result.duration_seconds),
|
|
65
|
+
})
|
|
66
|
+
.eq("id", session_id);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Fire next job
|
|
70
|
+
await step.sendEvent("trigger-analysis", {
|
|
71
|
+
name: "session/transcribed",
|
|
72
|
+
data: { user_id, session_id, transcript_r2_key },
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
return { session_id, duration_seconds: result.duration_seconds };
|
|
76
|
+
}
|
|
77
|
+
);
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
2
|
+
import { config } from "./config.js";
|
|
3
|
+
|
|
4
|
+
export const anthropic = new Anthropic({
|
|
5
|
+
apiKey: config.anthropic.api_key,
|
|
6
|
+
});
|
|
7
|
+
|
|
8
|
+
// Strip markdown code fences from LLM output and return raw JSON string
|
|
9
|
+
export function extract_json(raw: string): string {
|
|
10
|
+
const fence = raw.match(/```(?:json)?\s*([\s\S]*?)```/);
|
|
11
|
+
if (fence) return fence[1].trim();
|
|
12
|
+
// Try to find first { or [ and last } or ]
|
|
13
|
+
const start = raw.search(/[{[]/);
|
|
14
|
+
const end = Math.max(raw.lastIndexOf("}"), raw.lastIndexOf("]"));
|
|
15
|
+
if (start !== -1 && end !== -1 && end > start) return raw.slice(start, end + 1);
|
|
16
|
+
return raw.trim();
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Thin wrapper — returns text content from a single-turn message
|
|
20
|
+
export async function complete(
|
|
21
|
+
model: "conversation" | "analysis" | "matching",
|
|
22
|
+
system: string,
|
|
23
|
+
user: string,
|
|
24
|
+
max_tokens?: number
|
|
25
|
+
): Promise<string> {
|
|
26
|
+
const default_tokens = model === "analysis" ? 8192 : 4096;
|
|
27
|
+
const response = await anthropic.messages.create({
|
|
28
|
+
model: config.anthropic.models[model],
|
|
29
|
+
max_tokens: max_tokens ?? default_tokens,
|
|
30
|
+
system,
|
|
31
|
+
messages: [{ role: "user", content: user }],
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const block = response.content[0];
|
|
35
|
+
if (block.type !== "text") throw new Error("Unexpected non-text response");
|
|
36
|
+
return block.text;
|
|
37
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import "dotenv/config";
|
|
2
|
+
|
|
3
|
+
function require_env(key: string): string {
|
|
4
|
+
const val = process.env[key];
|
|
5
|
+
if (!val) throw new Error(`Missing required env var: ${key}`);
|
|
6
|
+
return val;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export const config = {
|
|
10
|
+
// Supabase
|
|
11
|
+
supabase: {
|
|
12
|
+
url: require_env("SUPABASE_URL"),
|
|
13
|
+
service_role_key: require_env("SUPABASE_SERVICE_ROLE_KEY"),
|
|
14
|
+
},
|
|
15
|
+
|
|
16
|
+
// Twilio
|
|
17
|
+
twilio: {
|
|
18
|
+
account_sid: require_env("TWILIO_ACCOUNT_SID"),
|
|
19
|
+
auth_token: require_env("TWILIO_AUTH_TOKEN"),
|
|
20
|
+
verify_service_sid: require_env("TWILIO_VERIFY_SERVICE_SID"),
|
|
21
|
+
// Phone numbers per brand
|
|
22
|
+
numbers: {
|
|
23
|
+
"meet-the-one": require_env("TWILIO_NUMBER_FLAGSHIP"),
|
|
24
|
+
casual: process.env["TWILIO_NUMBER_CASUAL"] ?? "",
|
|
25
|
+
kink: process.env["TWILIO_NUMBER_KINK"] ?? "",
|
|
26
|
+
adventure: process.env["TWILIO_NUMBER_ADVENTURE"] ?? "",
|
|
27
|
+
"open-poly": process.env["TWILIO_NUMBER_OPEN_POLY"] ?? "",
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
// Vapi (voice AI — v0.1)
|
|
32
|
+
vapi: {
|
|
33
|
+
api_key: require_env("VAPI_API_KEY"),
|
|
34
|
+
intake_assistant_id: require_env("VAPI_INTAKE_ASSISTANT_ID"),
|
|
35
|
+
consent_assistant_id: require_env("VAPI_CONSENT_ASSISTANT_ID"),
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
// Anthropic
|
|
39
|
+
anthropic: {
|
|
40
|
+
api_key: require_env("ANTHROPIC_API_KEY"),
|
|
41
|
+
models: {
|
|
42
|
+
conversation: "claude-sonnet-4-6",
|
|
43
|
+
analysis: "claude-opus-4-6",
|
|
44
|
+
matching: "claude-sonnet-4-6",
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
// OpenAI (embeddings)
|
|
49
|
+
openai: {
|
|
50
|
+
api_key: require_env("OPENAI_API_KEY"),
|
|
51
|
+
embedding_model: "text-embedding-3-large",
|
|
52
|
+
embedding_dimensions: 1536, // truncated — pgvector local limit is 2000
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
// Deepgram (transcription)
|
|
56
|
+
deepgram: {
|
|
57
|
+
api_key: require_env("DEEPGRAM_API_KEY"),
|
|
58
|
+
model: "nova-2",
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
// Cloudflare R2 (audio/transcript storage)
|
|
62
|
+
r2: {
|
|
63
|
+
account_id: require_env("CLOUDFLARE_ACCOUNT_ID"),
|
|
64
|
+
access_key_id: require_env("R2_ACCESS_KEY_ID"),
|
|
65
|
+
secret_access_key: require_env("R2_SECRET_ACCESS_KEY"),
|
|
66
|
+
bucket: require_env("R2_BUCKET_NAME"),
|
|
67
|
+
endpoint: `https://${require_env("CLOUDFLARE_ACCOUNT_ID")}.r2.cloudflarestorage.com`,
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
// Inngest
|
|
71
|
+
inngest: {
|
|
72
|
+
event_key: require_env("INNGEST_EVENT_KEY"),
|
|
73
|
+
signing_key: require_env("INNGEST_SIGNING_KEY"),
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
// App
|
|
77
|
+
app: {
|
|
78
|
+
url: process.env["APP_URL"] ?? "http://localhost:3000",
|
|
79
|
+
port: parseInt(process.env["PORT"] ?? "3000", 10),
|
|
80
|
+
},
|
|
81
|
+
} as const;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { createClient } from "@deepgram/sdk";
|
|
2
|
+
import { config } from "./config.js";
|
|
3
|
+
|
|
4
|
+
export const deepgram = createClient(config.deepgram.api_key);
|
|
5
|
+
|
|
6
|
+
export interface TranscriptResult {
|
|
7
|
+
transcript: string; // full text
|
|
8
|
+
words: Array<{
|
|
9
|
+
word: string;
|
|
10
|
+
start: number;
|
|
11
|
+
end: number;
|
|
12
|
+
confidence: number;
|
|
13
|
+
speaker: number;
|
|
14
|
+
}>;
|
|
15
|
+
duration_seconds: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Transcribe audio from a URL (e.g. presigned R2 URL)
|
|
19
|
+
export async function transcribe_url(
|
|
20
|
+
audio_url: string
|
|
21
|
+
): Promise<TranscriptResult> {
|
|
22
|
+
const { result, error } = await deepgram.listen.prerecorded.transcribeUrl(
|
|
23
|
+
{ url: audio_url },
|
|
24
|
+
{
|
|
25
|
+
model: config.deepgram.model,
|
|
26
|
+
diarize: true, // speaker separation
|
|
27
|
+
punctuate: true,
|
|
28
|
+
utterances: true,
|
|
29
|
+
smart_format: true,
|
|
30
|
+
}
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
if (error) throw new Error(`Deepgram error: ${error.message}`);
|
|
34
|
+
if (!result) throw new Error("No result from Deepgram");
|
|
35
|
+
|
|
36
|
+
const channel = result.results.channels[0].alternatives[0];
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
transcript: channel.transcript,
|
|
40
|
+
words: (channel.words ?? []).map((w) => ({
|
|
41
|
+
word: w.word,
|
|
42
|
+
start: w.start,
|
|
43
|
+
end: w.end,
|
|
44
|
+
confidence: w.confidence,
|
|
45
|
+
speaker: w.speaker ?? 0,
|
|
46
|
+
})),
|
|
47
|
+
duration_seconds: result.metadata.duration,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Extract only user speech (speaker 1) from transcript words
|
|
52
|
+
export function user_speech_only(result: TranscriptResult): string {
|
|
53
|
+
return result.words
|
|
54
|
+
.filter((w) => w.speaker === 1)
|
|
55
|
+
.map((w) => w.word)
|
|
56
|
+
.join(" ");
|
|
57
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Inngest } from "inngest";
|
|
2
|
+
import { config } from "./config.js";
|
|
3
|
+
|
|
4
|
+
export const inngest = new Inngest({
|
|
5
|
+
id: "meet-the-one",
|
|
6
|
+
eventKey: config.inngest.event_key,
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
// ─── Event type definitions ───────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
export type Events = {
|
|
12
|
+
"session/completed": {
|
|
13
|
+
data: { user_id: string; session_id: string };
|
|
14
|
+
};
|
|
15
|
+
"session/transcribed": {
|
|
16
|
+
data: { user_id: string; session_id: string; transcript_r2_key: string };
|
|
17
|
+
};
|
|
18
|
+
"identity/updated": {
|
|
19
|
+
data: { user_id: string };
|
|
20
|
+
};
|
|
21
|
+
"match/confirmed": {
|
|
22
|
+
data: { match_id: string };
|
|
23
|
+
};
|
|
24
|
+
"match/both-accepted": {
|
|
25
|
+
data: { match_id: string };
|
|
26
|
+
};
|
|
27
|
+
"identity/reanalyze": {
|
|
28
|
+
data: { user_id: string };
|
|
29
|
+
};
|
|
30
|
+
"identity/export": {
|
|
31
|
+
data: { user_id: string };
|
|
32
|
+
};
|
|
33
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import OpenAI from "openai";
|
|
2
|
+
import { config } from "./config.js";
|
|
3
|
+
|
|
4
|
+
export const openai = new OpenAI({ apiKey: config.openai.api_key });
|
|
5
|
+
|
|
6
|
+
// Generate embedding vector for a text string
|
|
7
|
+
export async function embed(text: string): Promise<number[]> {
|
|
8
|
+
const response = await openai.embeddings.create({
|
|
9
|
+
model: config.openai.embedding_model,
|
|
10
|
+
input: text,
|
|
11
|
+
dimensions: config.openai.embedding_dimensions,
|
|
12
|
+
});
|
|
13
|
+
return response.data[0].embedding;
|
|
14
|
+
}
|