Skip to content

Commit edca768

Browse files
committed
update to chainlink cre workflow
1 parent 242eade commit edca768

File tree

8 files changed

+478
-93
lines changed

8 files changed

+478
-93
lines changed

app/api/cre/trigger/route.ts

Lines changed: 138 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
import { NextRequest, NextResponse } from "next/server";
22
import { validateAuthOrReject, isAuthError } from "@/lib/auth";
33
import { createSupabaseAdminClient } from "@/app/utils/supabase/supabaseAdmin";
4+
import { GoogleGenerativeAI } from "@google/generative-ai";
45

56
/**
67
* POST /api/cre/trigger
78
* Triggers CRE story verification workflow.
8-
* Sends story content directly in the CRE trigger payload (Approach B).
9+
*
10+
* When CRE_WORKFLOW_URL is set: triggers the deployed CRE workflow (production).
11+
* When CRE_WORKFLOW_URL is NOT set: runs Gemini analysis directly and writes
12+
* metrics to Supabase (fallback for demo/dev when CRE isn't deployed yet).
913
*/
1014
export async function POST(req: NextRequest) {
1115
try {
@@ -83,9 +87,10 @@ export async function POST(req: NextRequest) {
8387
return NextResponse.json({ error: "Failed to start verification" }, { status: 500 });
8488
}
8589

86-
// Trigger CRE workflow with content in payload (Approach B — standard pattern)
90+
// Route: CRE workflow (production) or direct Gemini fallback (demo/dev)
8791
const creWorkflowUrl = process.env.CRE_WORKFLOW_URL;
8892
if (creWorkflowUrl) {
93+
// Production path: trigger deployed CRE workflow
8994
fetch(creWorkflowUrl, {
9095
method: "POST",
9196
headers: {
@@ -100,7 +105,11 @@ export async function POST(req: NextRequest) {
100105
}),
101106
}).catch(err => console.error("[CRE/TRIGGER] Workflow trigger failed:", err));
102107
} else {
103-
console.warn("[CRE/TRIGGER] CRE_WORKFLOW_URL not set, skipping workflow trigger");
108+
// Fallback: direct Gemini analysis (same AI, no on-chain attestation)
109+
console.log("[CRE/TRIGGER] No CRE_WORKFLOW_URL — using direct Gemini fallback");
110+
runDirectGeminiFallback(admin, story.id, story.title || "Untitled", story.content).catch(
111+
err => console.error("[CRE/TRIGGER] Direct fallback failed:", err)
112+
);
104113
}
105114

106115
return NextResponse.json({
@@ -113,3 +122,129 @@ export async function POST(req: NextRequest) {
113122
return NextResponse.json({ error: "Internal server error" }, { status: 500 });
114123
}
115124
}
125+
126+
// ============================================================================
127+
// Direct Gemini Fallback (used when CRE_WORKFLOW_URL is not set)
128+
// ============================================================================
129+
130+
const VERIFICATION_PROMPT = `You are a content quality analyst. Analyze the provided story and return a JSON object with exactly these fields:
131+
132+
- significanceScore: number 0-100 (how meaningful/impactful to the author's personal growth)
133+
- emotionalDepth: number 1-5 (1=surface, 2=mild, 3=moderate, 4=deep, 5=profound)
134+
- qualityScore: number 0-100 (writing quality: coherence, structure, vocabulary, narrative flow)
135+
- wordCount: number (exact word count of the content)
136+
- themes: string[] (2-5 main themes, lowercase, e.g. ["growth", "family", "resilience"])
137+
138+
STRICT RULES:
139+
- Output MUST be valid JSON. No markdown, no backticks, no explanation.
140+
- Return ONLY the JSON object.`;
141+
142+
interface DirectMetrics {
143+
significanceScore: number;
144+
emotionalDepth: number;
145+
qualityScore: number;
146+
wordCount: number;
147+
themes: string[];
148+
}
149+
150+
function clamp(value: number, min: number, max: number): number {
151+
return Math.min(max, Math.max(min, value));
152+
}
153+
154+
function scoreToTier(score: number): number {
155+
if (score <= 20) return 1;
156+
if (score <= 40) return 2;
157+
if (score <= 60) return 3;
158+
if (score <= 80) return 4;
159+
return 5;
160+
}
161+
162+
/**
163+
* Runs the same Gemini analysis as the CRE workflow but directly,
164+
* then writes results to verified_metrics in Supabase.
165+
* No on-chain attestation — source marked as "direct".
166+
*/
167+
async function runDirectGeminiFallback(
168+
admin: ReturnType<typeof createSupabaseAdminClient>,
169+
storyId: string,
170+
title: string,
171+
content: string
172+
) {
173+
const apiKey = process.env.GOOGLE_GENERATIVE_AI_API_KEY;
174+
if (!apiKey) {
175+
console.error("[CRE/DIRECT] GOOGLE_GENERATIVE_AI_API_KEY not set");
176+
return;
177+
}
178+
179+
const genAI = new GoogleGenerativeAI(apiKey);
180+
const model = genAI.getGenerativeModel({
181+
model: "gemini-2.0-flash",
182+
generationConfig: { temperature: 0.1, responseMimeType: "application/json" },
183+
});
184+
185+
const prompt = `${VERIFICATION_PROMPT}\n\nTitle: "${title}"\n\nContent:\n"""\n${content}\n"""`;
186+
187+
const result = await model.generateContent(prompt);
188+
const text = result.response.text();
189+
190+
// Parse response
191+
const cleaned = text.replace(/```json\s*/gi, "").replace(/```\s*/gi, "").trim();
192+
const jsonMatch = cleaned.match(/\{[\s\S]*\}/);
193+
if (!jsonMatch) {
194+
console.error("[CRE/DIRECT] No JSON found in Gemini response");
195+
return;
196+
}
197+
198+
const raw = JSON.parse(jsonMatch[0]) as Partial<DirectMetrics>;
199+
200+
const metrics: DirectMetrics = {
201+
significanceScore: clamp(Math.round(Number(raw.significanceScore) || 0), 0, 100),
202+
emotionalDepth: clamp(Math.round(Number(raw.emotionalDepth) || 1), 1, 5),
203+
qualityScore: clamp(Math.round(Number(raw.qualityScore) || 0), 0, 100),
204+
wordCount: Math.max(0, Math.round(Number(raw.wordCount) || 0)),
205+
themes: Array.isArray(raw.themes)
206+
? raw.themes.filter((t): t is string => typeof t === "string").map(t => t.toLowerCase().trim()).slice(0, 5)
207+
: [],
208+
};
209+
210+
const qualityTier = scoreToTier(metrics.qualityScore);
211+
212+
console.log(
213+
`[CRE/DIRECT] Analysis complete: significance=${metrics.significanceScore}, quality=${metrics.qualityScore}, tier=${qualityTier}`
214+
);
215+
216+
// Write to verified_metrics — same schema as CRE callback
217+
const { error: upsertError } = await admin
218+
.from("verified_metrics")
219+
.upsert(
220+
{
221+
story_id: storyId,
222+
significance_score: metrics.significanceScore,
223+
emotional_depth: metrics.emotionalDepth,
224+
quality_score: metrics.qualityScore,
225+
word_count: metrics.wordCount,
226+
verified_themes: metrics.themes,
227+
quality_tier: qualityTier,
228+
meets_quality_threshold: metrics.qualityScore >= 70,
229+
metrics_hash: null, // No CRE hash — direct analysis
230+
on_chain_tx_hash: null, // No on-chain write — direct analysis
231+
cre_attestation_id: null,
232+
updated_at: new Date().toISOString(),
233+
},
234+
{ onConflict: "story_id" }
235+
);
236+
237+
if (upsertError) {
238+
console.error("[CRE/DIRECT] Supabase upsert error:", upsertError);
239+
return;
240+
}
241+
242+
// Mark verification as completed
243+
await admin
244+
.from("verification_logs")
245+
.update({ status: "completed", updated_at: new Date().toISOString() })
246+
.eq("story_id", storyId)
247+
.eq("status", "pending");
248+
249+
console.log(`[CRE/DIRECT] Metrics saved for story ${storyId}`);
250+
}

cre/iStory_workflow/gemini.ts

Lines changed: 29 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,23 @@
11
/**
2-
* CRE AI Analysis — Gemini Flash with ConfidentialHTTPClient
2+
* CRE AI Analysis — Gemini Flash with HTTPClient
33
*
4-
* Uses ConfidentialHTTPClient so the Gemini API call (which contains
5-
* the user's private story content) runs inside the encrypted enclave.
6-
* Story content never leaves the enclave unencrypted.
4+
* Uses HTTPClient for simulation compatibility. ConfidentialHTTPClient
5+
* requires actual DON enclave hardware and is not supported in local
6+
* simulation mode (capability "confidential-http@1.0.0-alpha" not registered).
77
*
8-
* API key is injected via Vault DON template syntax, not runtime.getSecret().
8+
* TODO: Switch to ConfidentialHTTPClient for production deployment:
9+
* - Replace HTTPClient → ConfidentialHTTPClient
10+
* - Replace HTTPSendRequester → ConfidentialHTTPSendRequester
11+
* - Replace headers → multiHeaders with Vault DON templates
12+
* - Replace runtime.getSecret() → vaultDonSecrets
913
*/
1014

1115
import {
12-
ConfidentialHTTPClient,
16+
cre,
1317
consensusIdenticalAggregation,
1418
ok,
1519
type Runtime,
16-
type ConfidentialHTTPSendRequester,
20+
type HTTPSendRequester,
1721
} from "@chainlink/cre-sdk";
1822
import type { Config } from "./main";
1923

@@ -53,22 +57,23 @@ interface GeminiResult {
5357
}
5458

5559
/**
56-
* Analyze story content using Gemini via CRE ConfidentialHTTPClient.
57-
* Story content stays encrypted inside the DON enclave.
60+
* Analyze story content using Gemini via CRE HTTPClient.
61+
* Uses runtime.getSecret() for API key (simulation-compatible).
5862
*/
5963
export function askGemini(
6064
runtime: Runtime<Config>,
6165
title: string,
6266
content: string
6367
): StoryMetrics {
64-
runtime.log("[Gemini] Querying AI for story analysis (confidential)...");
68+
runtime.log("[Gemini] Querying AI for story analysis...");
6569

66-
const confClient = new ConfidentialHTTPClient();
70+
const geminiApiKey = runtime.getSecret({ id: "GEMINI_API_KEY" }).result().value;
71+
const httpClient = new cre.capabilities.HTTPClient();
6772

68-
const result = confClient
73+
const result = httpClient
6974
.sendRequest(
7075
runtime,
71-
buildGeminiRequest(title, content),
76+
buildGeminiRequest(title, content, geminiApiKey),
7277
consensusIdenticalAggregation<GeminiResult>()
7378
)(runtime.config)
7479
.result();
@@ -80,19 +85,15 @@ export function askGemini(
8085
}
8186

8287
/**
83-
* Build the Gemini request function for ConfidentialHTTPClient.
88+
* Build the Gemini request function for HTTPClient.
8489
* Returns a curried function: (sendRequester, config) => GeminiResult
8590
*
86-
* Key differences from HTTPClient:
87-
* - Uses ConfidentialHTTPSendRequester instead of HTTPSendRequester
88-
* - API key via Vault DON template: {{.geminiApiKey}}
89-
* - multiHeaders instead of headers
90-
* - vaultDonSecrets to reference Vault DON secrets
91-
* - Request body (story content) encrypted inside enclave
91+
* Uses flat headers and runtime.getSecret() (simulation-compatible).
92+
* For production: switch to ConfidentialHTTPSendRequester + multiHeaders + vaultDonSecrets.
9293
*/
9394
const buildGeminiRequest =
94-
(title: string, content: string) =>
95-
(sendRequester: ConfidentialHTTPSendRequester, config: Config): GeminiResult => {
95+
(title: string, content: string, apiKey: string) =>
96+
(sendRequester: HTTPSendRequester, config: Config): GeminiResult => {
9697
const userPrompt = `Title: "${title}"
9798
9899
Content:
@@ -117,19 +118,13 @@ ${content}
117118

118119
const resp = sendRequester
119120
.sendRequest({
120-
request: {
121-
url: `https://generativelanguage.googleapis.com/v1beta/models/${config.geminiModel}:generateContent`,
122-
method: "POST" as const,
123-
body,
124-
multiHeaders: {
125-
"Content-Type": { values: ["application/json"] },
126-
"x-goog-api-key": { values: ["{{.geminiApiKey}}"] },
127-
},
121+
url: `https://generativelanguage.googleapis.com/v1beta/models/${config.geminiModel}:generateContent`,
122+
method: "POST",
123+
headers: {
124+
"Content-Type": "application/json",
125+
"x-goog-api-key": apiKey,
128126
},
129-
vaultDonSecrets: [
130-
{ key: "geminiApiKey", owner: config.owner },
131-
],
132-
// encryptOutput not used — we need to parse the response in-workflow
127+
body,
133128
})
134129
.result();
135130

cre/iStory_workflow/httpCallback.ts

Lines changed: 20 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,14 @@
99
* 5. Encode minimal data for on-chain storage
1010
* 6. Generate a signed CRE report
1111
* 7. Write the report to the PrivateVerifiedMetrics contract
12-
* 8. Callback full metrics to eStory API (confidential HTTP)
12+
* 8. Callback full metrics to eStory API (HTTP)
1313
*/
1414

1515
import {
1616
cre,
17-
ConfidentialHTTPClient,
1817
type Runtime,
1918
type HTTPPayload,
20-
type ConfidentialHTTPSendRequester,
19+
type HTTPSendRequester,
2120
getNetwork,
2221
hexToBase64,
2322
TxStatus,
@@ -88,9 +87,9 @@ export function onHttpTrigger(
8887
}
8988

9089
// ─────────────────────────────────────────────────────────────
91-
// Step 2: AI Analysis via Gemini (Confidential HTTP)
90+
// Step 2: AI Analysis via Gemini (HTTP)
9291
// ─────────────────────────────────────────────────────────────
93-
runtime.log("[Step 2] Querying Gemini AI (confidential enclave)...");
92+
runtime.log("[Step 2] Querying Gemini AI...");
9493

9594
let metrics;
9695
try {
@@ -229,13 +228,14 @@ export function onHttpTrigger(
229228
}
230229

231230
// ─────────────────────────────────────────────────────────────
232-
// Step 8: Callback full metrics to eStory API (confidential)
231+
// Step 8: Callback full metrics to eStory API
233232
// ─────────────────────────────────────────────────────────────
234-
runtime.log("[Step 8] Sending full metrics via confidential callback...");
233+
runtime.log("[Step 8] Sending full metrics via callback...");
235234

236235
if (runtime.config.callbackUrl) {
237236
try {
238-
const confClient = new ConfidentialHTTPClient();
237+
const callbackSecret = runtime.getSecret({ id: "CRE_CALLBACK_SECRET" }).result().value;
238+
const httpClient = new cre.capabilities.HTTPClient();
239239

240240
const callbackPayload = {
241241
storyId: input.storyId,
@@ -255,10 +255,10 @@ export function onHttpTrigger(
255255
new TextEncoder().encode(JSON.stringify(callbackPayload))
256256
).toString("base64");
257257

258-
confClient
258+
httpClient
259259
.sendRequest(
260260
runtime,
261-
buildCallbackRequest(callbackBody),
261+
buildCallbackRequest(callbackBody, callbackSecret),
262262
consensusIdenticalAggregation<{ success: boolean }>()
263263
)(runtime.config)
264264
.result();
@@ -286,26 +286,21 @@ export function onHttpTrigger(
286286
}
287287

288288
/**
289-
* Build the confidential callback request.
290-
* Full metrics payload stays encrypted in transit via ConfidentialHTTPClient.
289+
* Build the callback request via HTTPClient.
290+
* For production: switch to ConfidentialHTTPClient + multiHeaders + vaultDonSecrets.
291291
*/
292292
const buildCallbackRequest =
293-
(body: string) =>
294-
(sendRequester: ConfidentialHTTPSendRequester, config: Config): { success: boolean } => {
293+
(body: string, secret: string) =>
294+
(sendRequester: HTTPSendRequester, config: Config): { success: boolean } => {
295295
sendRequester
296296
.sendRequest({
297-
request: {
298-
url: config.callbackUrl,
299-
method: "POST" as const,
300-
body,
301-
multiHeaders: {
302-
"Content-Type": { values: ["application/json"] },
303-
"X-CRE-Callback-Secret": { values: ["{{.callbackSecret}}"] },
304-
},
297+
url: config.callbackUrl,
298+
method: "POST",
299+
headers: {
300+
"Content-Type": "application/json",
301+
"X-CRE-Callback-Secret": secret,
305302
},
306-
vaultDonSecrets: [
307-
{ key: "callbackSecret", owner: config.owner },
308-
],
303+
body,
309304
})
310305
.result();
311306

0 commit comments

Comments
 (0)