-
Notifications
You must be signed in to change notification settings - Fork 27
Telescopetest-io: add AI content filtering #144
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
154be1e
de2fb11
04c7e1b
af7a809
2c53b84
78bb620
c2109c2
32b4bbd
59e858a
2306349
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| # Local development variable overrides for wrangler dev (npm run preview). | ||
| # Copy this file to .dev.vars and fill in your values. | ||
| # .dev.vars is gitignored — never commit it. | ||
|
|
||
|
|
||
| # Override the AI rating flag from wrangler.jsonc for local testing. | ||
| # Default in wrangler.jsonc development env is "false". | ||
| # Set to "true" to test AI rating locally (requires the AI binding to be active). | ||
| # WARNING: using AI rating may incur costs, even locally. | ||
|
|
||
| ENABLE_AI_RATING=false | ||
|
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| { | ||
| "$schema": "https://opencode.ai/config.json", | ||
| "default_agent": "telescopetest-io", | ||
| "agent": { | ||
| "telescopetest-io": { | ||
| "permission": { | ||
| "edit": "ask", | ||
| "webfetch": "allow", | ||
| "bash": { | ||
| "*": "ask", | ||
| "git status *": "allow", | ||
| "git branch --show-current": "allow", | ||
| "git diff *": "allow", | ||
| "git log *": "allow", | ||
| "git show *": "allow", | ||
| "gh pr list *": "allow", | ||
| "gh pr view *": "allow", | ||
| "ls *": "allow", | ||
| "cat *": "allow", | ||
| "grep *": "allow", | ||
| "find *": "allow", | ||
| "wc *": "allow", | ||
| "jq *": "allow", | ||
| "npm *": "ask", | ||
| "npx *": "ask", | ||
| "wrangler *": "ask", | ||
| "node *": "ask", | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| -- RedefineTables | ||
| PRAGMA defer_foreign_keys=ON; | ||
| PRAGMA foreign_keys=OFF; | ||
| CREATE TABLE "new_tests" ( | ||
| "test_id" TEXT NOT NULL PRIMARY KEY, | ||
| "zip_key" TEXT NOT NULL, | ||
| "name" TEXT, | ||
| "description" TEXT, | ||
| "source" TEXT NOT NULL, | ||
| "url" TEXT NOT NULL, | ||
| "test_date" INTEGER NOT NULL, | ||
| "browser" TEXT NOT NULL, | ||
| "content_rating" TEXT NOT NULL DEFAULT 'unknown', | ||
| "created_at" INTEGER DEFAULT (unixepoch()), | ||
| "updated_at" INTEGER DEFAULT (unixepoch()) | ||
| ); | ||
| INSERT INTO "new_tests" ("browser", "content_rating", "created_at", "description", "name", "source", "test_date", "test_id", "updated_at", "url", "zip_key") SELECT "browser", "content_rating", "created_at", "description", "name", "source", "test_date", "test_id", "updated_at", "url", "zip_key" FROM "tests"; | ||
| DROP TABLE "tests"; | ||
| ALTER TABLE "new_tests" RENAME TO "tests"; | ||
| CREATE UNIQUE INDEX "tests_zip_key_key" ON "tests"("zip_key"); | ||
| CREATE INDEX "idx_tests_file_key" ON "tests"("zip_key"); | ||
| CREATE INDEX "idx_tests_content_rating" ON "tests"("content_rating"); | ||
| CREATE INDEX "idx_tests_updated_at" ON "tests"("updated_at" DESC); | ||
| CREATE INDEX "idx_tests_created_at" ON "tests"("created_at" DESC); | ||
| PRAGMA foreign_keys=ON; | ||
| PRAGMA defer_foreign_keys=OFF; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,142 @@ | ||
| import { ContentRating } from '@/lib/types/tests'; | ||
|
|
||
| type Ai = import('@cloudflare/workers-types').Ai; | ||
|
|
||
| // helper to extract text from metrics.json | ||
| function extractTextFromMetrics(metricsBytes: Uint8Array): string { | ||
| const metricsJson = JSON.parse(new TextDecoder('utf-8').decode(metricsBytes)); | ||
| const parts: string[] = []; | ||
| const lcpEvents: Array<{ | ||
| element?: { content?: string; outerHTML?: string }; | ||
| }> = metricsJson.largestContentfulPaint ?? []; | ||
| for (const lcp of lcpEvents) { | ||
| if (lcp.element?.content) { | ||
| parts.push(lcp.element.content); | ||
| } else if (lcp.element?.outerHTML) { | ||
| parts.push(lcp.element.outerHTML.replace(/<[^>]+>/g, ' ').trim()); | ||
| } | ||
| } | ||
| if (metricsJson.navigationTiming?.name) { | ||
| parts.push(metricsJson.navigationTiming.name); | ||
| } | ||
| return parts.join(' ').replace(/\s+/g, ' ').trim(); | ||
| } | ||
|
|
||
| // helper to scrape text from url | ||
| async function scrapeUrl(url: string): Promise<string> { | ||
| const response = await fetch(url, { | ||
| headers: { 'User-Agent': 'TelescopetestBot/1.0' }, | ||
| signal: AbortSignal.timeout(10_000), | ||
| }); | ||
| const html = await response.text(); | ||
| return html | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @Judyzc did you have any success with sending HTML to the agent here? |
||
| .replace(/<(script|style|noscript|head|template)[\s\S]*?<\/\1>/gi, '') | ||
| .replace(/<[^>]+>/g, ' ') | ||
| .replace(/&/g, '&') | ||
| .replace(/</g, '<') | ||
| .replace(/>/g, '>') | ||
| .replace(/"/g, '"') | ||
| .replace(/'/g, "'") | ||
| .replace(/ /g, ' ') | ||
| .replace(/&[a-z]+;/gi, ' ') | ||
| .replace(/\s+/g, ' ') | ||
|
Comment on lines
+33
to
+42
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hmm, I think this sort of replacement won't work across newlines, and is omitting valid escaped text. We should most likely be parsing the HTML and extracting the text nodes (most likely via https://developer.mozilla.org/en-US/docs/Web/API/DOMParser/parseFromString) from the parsed document. Also, do we need to extract text at all? Like, assuming the content scanner is an LLM capable of sifting through structured documents, it probably could be passed the HTML document as-is and make a determination on the content?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think the I don't think DOMParser works with Cloudflare workers, explained here, though I might be wrong. Cloudflare has its own HTMLRewriter tool I could use but that adds in streaming. There's also this third-party library linkedom I could try using, but what are your thoughts? For needing to extract text, the LLM seems to be for conversation like strings: https://developers.cloudflare.com/workers-ai/models/llama-guard-3-8b/, so I haven't actually tested with just the HTML document. I can probably try this too though. |
||
| .trim(); | ||
| } | ||
|
|
||
| export async function rateUrlContent( | ||
| ai: Ai, | ||
| url: string, | ||
| metricsBytes: Uint8Array | undefined, | ||
| screenshotBytes: Uint8Array | undefined, | ||
| ): Promise<ContentRating> { | ||
| // first check text with llama-guard-3-8b | ||
| // combine metrics LCP text with whatever static HTML scraping can get | ||
| if (metricsBytes) { | ||
| try { | ||
| const [metricsText, scrapedText] = await Promise.allSettled([ | ||
| Promise.resolve(extractTextFromMetrics(metricsBytes)), | ||
| scrapeUrl(url), | ||
| ]); | ||
| const combined = [ | ||
| metricsText.status === 'fulfilled' ? metricsText.value : '', | ||
| scrapedText.status === 'fulfilled' ? scrapedText.value : '', | ||
| ] | ||
| .join(' ') | ||
| .replace(/\s+/g, ' ') | ||
| .trim() | ||
| .slice(0, 4000); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We're intentionally only scanning the first ~4k characters?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We can probably increase this to ~100,000 chars b/c the model can take 131,072 tokens, but yeah we can definitely increase this. |
||
| // console.log('pageText: ', combined); | ||
| if (combined) { | ||
| const textResult = await ai.run('@cf/meta/llama-guard-3-8b', { | ||
| messages: [{ role: 'user', content: combined }], | ||
| temperature: 0, | ||
| response_format: { type: 'json_object' }, | ||
| }); | ||
| const textRating = ( | ||
| textResult as { response: { safe: boolean; categories: string[] } } | ||
| ).response; | ||
| console.log( | ||
| 'ai-content-rater text result: ', | ||
| JSON.stringify(textRating), | ||
| ); | ||
| if (textRating?.safe === false) return ContentRating.UNSAFE; | ||
| // safe === false means at least one S1–S14 category was flagged | ||
| // unsafe categories: https://huggingface.co/meta-llama/Llama-Guard-3-8B | ||
| } | ||
| } catch (e) { | ||
| console.log('ERROR in text context check: ', e); | ||
| } | ||
| } | ||
| // then check screenshot with llama-3.2-11b-vision-instruct | ||
| if (screenshotBytes) { | ||
| try { | ||
| // pass screenshot in as base64 encoding, chunked to prevent overflow | ||
| let binary = ''; | ||
| const chunkSize = 8192; | ||
| for (let i = 0; i < screenshotBytes.length; i += chunkSize) { | ||
| binary += String.fromCharCode( | ||
| ...screenshotBytes.subarray(i, i + chunkSize), | ||
| ); | ||
| } | ||
| const base64 = btoa(binary); | ||
| const dataUrl = `data:image/png;base64,${base64}`; | ||
| const visionResult = await ai.run( | ||
| '@cf/meta/llama-3.2-11b-vision-instruct', | ||
| { | ||
| messages: [ | ||
| { | ||
| role: 'system', | ||
| content: | ||
| 'You are a content safety classifier. You must respond with exactly one word: "UNSAFE" if the screenshot contains violence, weapons, sexual content, hate speech, suicide, self-harm, private information, or illegal activity, "SAFE" if the content does not, and "UNSAFE" if you cannot decide. No other words, no punctuation, no explanation.', | ||
| }, | ||
| { | ||
| role: 'user', | ||
| content: [ | ||
| { | ||
| type: 'image_url', | ||
| image_url: { url: dataUrl }, | ||
| }, | ||
| { | ||
| type: 'text', | ||
| text: 'Does this screenshot contain violence, sexual content, hate speech, self-harm, or illegal activity? Be very strict. One word only: SAFE or UNSAFE.', | ||
| }, | ||
| ], | ||
| }, | ||
| ], | ||
| max_tokens: 5, | ||
| }, | ||
| ); | ||
| const visionRating = (visionResult as { response: string }).response | ||
| ?.trim() | ||
| .toUpperCase() | ||
| .replace(/[^A-Z]/g, ''); | ||
| console.log('ai-content-rater image result: ', visionRating); | ||
| if (visionRating === 'UNSAFE') return ContentRating.UNSAFE; | ||
| if (visionRating === 'SAFE') return ContentRating.SAFE; | ||
| } catch (e) { | ||
| console.log('ERROR in vision check: ', e); | ||
| } | ||
| } | ||
| // if didn't already return SAFE, default to returning UNSAFE | ||
| return ContentRating.UNSAFE; | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.