Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions telescopetest-io/.dev.vars.example
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

32 changes: 32 additions & 0 deletions telescopetest-io/.opencode/opencode.jsonc
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",
},
},
},
},
}
6 changes: 5 additions & 1 deletion telescopetest-io/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ You should now be able to run `npm run studio` to view local D1 data in Prisma S

## Migrations

Prisma migrate does not support D1 yet, so you cannot follow the default prisma migrate workflows. Instead, migration files need to be created as follows.
We use Prisma to generate SQL for migrations, and Wrangler to apply them. Prisma migrate does not fully support D1 yet, so you cannot follow the default prisma migrate workflows. Instead, migration need to be done as follows:

#### Normal Use

Expand All @@ -51,6 +51,10 @@ Make sure you've followed all steps in Project Setup and Migrations -> Initial L

Then, you can run `npm run build` and then `npm run dev` to view the site with Astro's hot reload (instantly reflect changes) using the adapter for Cloudflare. Alternatively, you can run `npm run preview` to see Astro with Workers together in one step, but there's no hot reload.

### Note about Workers AI (AI content review)

One thing to note is that telescopetest-io uses Workers AI for AI content review on uploads. Wokers AI _always_ uses tokens that can incur costs, even in local/remote testing. AI content review is disabled locally by default. You can optionally enable AI content review (which may start costing money) by running the command `cp .dev.vars.example .dev.vars` and setting `ENABLE_AI_RATING=true`.

## Testing in Staging

Staging allows you to test changes in a remote environment that isn't production. To deploy to staging, run `npm run deploy:staging`. This command will only work if you have permission to deploy to telesceoptest-io's remote Worker.
Expand Down
26 changes: 26 additions & 0 deletions telescopetest-io/migrations/0002_add_content_rating.sql
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;
22 changes: 12 additions & 10 deletions telescopetest-io/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,20 @@ datasource db {
}

model tests {
test_id String @id
zip_key String @unique(map: "sqlite_autoindex_tests_2")
name String?
description String?
source String?
url String
test_date Int
browser String
created_at Int? @default(dbgenerated("unixepoch()"))
updated_at Int? @default(dbgenerated("unixepoch()"))
test_id String @id
zip_key String @unique
name String?
description String?
source String
url String
test_date Int
browser String
content_rating String @default("unknown")
created_at Int? @default(dbgenerated("(unixepoch())"))
updated_at Int? @default(dbgenerated("(unixepoch())"))

@@index([zip_key], map: "idx_tests_file_key")
@@index([content_rating], map: "idx_tests_content_rating")
@@index([updated_at(sort: Desc)], map: "idx_tests_updated_at")
@@index([created_at(sort: Desc)], map: "idx_tests_created_at")
}
51 changes: 45 additions & 6 deletions telescopetest-io/src/components/TestCard.astro
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,21 @@ export interface Props {
name: string | null;
description: string | null;
screenshotUrl: string | null;
contentRating?: string | null;
}

const { testId, url, testDate, browser, name, description, screenshotUrl } =
Astro.props;
const {
testId,
url,
testDate,
browser,
name,
description,
screenshotUrl,
contentRating,
} = Astro.props;

const isFlagged = contentRating === 'unsafe';

const date = new Date(testDate * 1000);
const formattedDate = date.toLocaleString('en-US', {
Expand All @@ -27,9 +38,18 @@ const formattedDate = date.toLocaleString('en-US', {
const nonamePlaceholder = 'No Name';
---

<a href={`/results/${testId}`} class="card">
<a
href={`/results/${testId}`}
class="card"
aria-label={isFlagged
? `${name || url} — flagged for unsafe content`
: undefined}
>
<div class="screenshot">
<ScreenshotDisplay screenshotUrl={screenshotUrl} alt={`Screenshot of ${url}`} />
<ScreenshotDisplay
screenshotUrl={screenshotUrl}
alt={`Screenshot of ${url}`}
/>
</div>
<div class="content">
<h3 class="name">{name || nonamePlaceholder}</h3>
Expand All @@ -38,6 +58,7 @@ const nonamePlaceholder = 'No Name';
<p class="url">{url}</p>
<p class="date">{formattedDate}</p>
</div>
{isFlagged && <span class="unsafe-flag">&#9873; UNSAFE</span>}
</a>

<style>
Expand All @@ -51,14 +72,16 @@ const nonamePlaceholder = 'No Name';
transition: all 0.2s;
text-decoration: none;
color: var(--text);
position: relative;

&:hover {
transform: translateY(-0.125rem); /* -2px */
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.1); /* 8px 16px */
border-color: var(--brand-hover);
}

@media (max-width: 48rem) { /* 768px */
@media (max-width: 48rem) {
/* 768px */
flex-direction: column;
}
}
Expand All @@ -73,7 +96,8 @@ const nonamePlaceholder = 'No Name';
background: var(--background);
overflow: hidden;

@media (max-width: 48rem) { /* 768px */
@media (max-width: 48rem) {
/* 768px */
width: 100%;
aspect-ratio: 16 / 10;
}
Expand All @@ -95,6 +119,7 @@ const nonamePlaceholder = 'No Name';
}

.name {
line-height: 1.5;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
Expand Down Expand Up @@ -131,4 +156,18 @@ const nonamePlaceholder = 'No Name';
margin: 0;
margin-top: auto;
}

.unsafe-flag {
position: absolute;
bottom: 0.625rem; /* 10px */
right: 0.625rem; /* 10px */
font-size: 0.6875rem; /* 11px */
font-weight: 500;
color: var(--color-danger);
background: var(--panel);
border: 1px solid rgba(239, 68, 68, 0.35);
border-radius: 0.25rem; /* 4px */
padding: 0.125rem 0.375rem; /* 2px 6px */
white-space: nowrap;
}
</style>
142 changes: 142 additions & 0 deletions telescopetest-io/src/lib/ai/ai-content-rater.ts
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
Copy link
Member

Choose a reason for hiding this comment

The 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(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, ' ')
.replace(/&[a-z]+;/gi, ' ')
.replace(/\s+/g, ' ')
Comment on lines +33 to +42
Copy link
Contributor

Choose a reason for hiding this comment

The 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?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the [\s\S] part of the regex allows it to work over newlines, shown here and through testing.

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);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're intentionally only scanning the first ~4k characters?

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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;
}
Loading