Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .ai/decisions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Decisions

- Implemented report diffs, badge/cert, fix-it snippets; bumped spec_version to 1.2; added /badge/{domain}.svg.
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,4 @@ jobs:
- run: pnpm -C apps/web build
- run: pnpm check:ai
- run: pnpm -C apps/functions build
- run: ./scripts/smoke-live.sh
64 changes: 64 additions & 0 deletions apps/functions/src/brand/renderBadgeSvg.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
export type BadgeInput = {
domain: string;
score?: number | null;
grade?: string | null;
updatedAtISO?: string | null;
statusLabel?: string | null;
};

function escapeXml(value: string): string {
return value.replace(/[<>&'"]/g, (char) => {
switch (char) {
case "<":
return "&lt;";
case ">":
return "&gt;";
case "&":
return "&amp;";
case "\"":
return "&quot;";
case "'":
return "&apos;";
default:
return char;
}
});
}

export function renderBadgeSvg({
domain,
score,
grade,
updatedAtISO,
statusLabel,
}: BadgeInput): string {
const left = "Agentability";
const right =
statusLabel ?? (score == null ? "Not evaluated" : `Score ${score}${grade ? ` (${grade})` : ""}`);
const date = updatedAtISO ? updatedAtISO.slice(0, 10) : "";
const sub = date ? `updated ${date}` : "";

return `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="420" height="28" viewBox="0 0 420 28" role="img" aria-label="${escapeXml(left)}: ${escapeXml(right)}">
<rect x="0" y="0" width="420" height="28" rx="6" fill="#0b0f19"/>
<rect x="0" y="0" width="160" height="28" rx="6" fill="#111827"/>
<rect x="156" y="0" width="264" height="28" rx="6" fill="#0b0f19"/>

<g transform="translate(10,5)" fill="none" stroke="#e5e7eb" stroke-width="1.8" stroke-linejoin="round">
<path d="M10 0l8 4v6c0 5-3 9-8 12C5 19 2 15 2 10V4l8-4z"/>
<path d="M5.2 10.5l2.2 2.2 6-6.2" stroke-linecap="round"/>
</g>

<text x="40" y="18" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial"
font-size="12" fill="#e5e7eb" font-weight="700" letter-spacing=".2">${escapeXml(left)}</text>

<text x="170" y="18" font-family="ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial"
font-size="12" fill="#e5e7eb" font-weight="600">${escapeXml(right)}</text>

${sub ? `<text x="410" y="18" text-anchor="end"
font-family="ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace"
font-size="10" fill="#9ca3af">${escapeXml(sub)}</text>` : ""}

<title>${escapeXml(domain)}</title>
</svg>`;
}
125 changes: 116 additions & 9 deletions apps/functions/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,14 @@ import { getStorage } from "firebase-admin/storage";
import { onRequest } from "firebase-functions/v2/https";
import { logger } from "firebase-functions";
import { evaluatePublic } from "@agentability/evaluator";
import { EvaluationInputSchema, EvaluationProfile, EvaluationResult } from "@agentability/shared";
import {
EvaluationInputSchema,
EvaluationProfile,
EvaluationResult,
computeDiff,
} from "@agentability/shared";
import { SSR_ASSETS } from "./ssr/asset-manifest";
import { renderBadgeSvg } from "./brand/renderBadgeSvg";

initializeApp();

Expand Down Expand Up @@ -588,11 +594,13 @@ async function runEvaluation(
let evaluation: EvaluationResult | null = null;

try {
const runRef = db
.collection("evaluations")
.doc(domain)
.collection("runs")
.doc(runId);
const domainRef = db.collection("evaluations").doc(domain);
const domainSnap = await domainRef.get();
const previousRunId = domainSnap.exists
? (domainSnap.data()?.latestRunId as string | undefined)
: undefined;
Comment on lines +597 to +601

Choose a reason for hiding this comment

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

P2 Badge Compute diff against stale run when evaluations overlap

The previousRunId is captured once at evaluation start, before the run completes. If two evaluations for the same domain overlap, the later-finishing run will still compare against the run that was latest at start, not the run that actually completed most recently. That means diffSummary/previousSummary can be wrong for concurrent runs. Consider re-reading latestRunId (or using a transaction) right before finalizing the run to ensure the diff is based on the immediate predecessor.

Useful? React with 👍 / 👎.


const runRef = domainRef.collection("runs").doc(runId);
const runRootRef = db.collection("runs").doc(runId);

const baseRun = {
Expand All @@ -602,13 +610,14 @@ async function runEvaluation(
status: "running",
input: { origin },
createdAt: new Date().toISOString(),
previousRunId,
};

await runRef.set(baseRun);
await runRootRef.set(baseRun);

const finalizeEvaluation = async (result: EvaluationResult, evidence: unknown[]) => {
evaluation = { ...result, runId };
evaluation = { ...result, runId, previousRunId };
const artifacts = {
reportUrl: `${baseUrl}/reports/${result.domain}`,
jsonUrl: `${baseUrl}/v1/evaluations/${result.domain}/latest.json`,
Expand All @@ -619,17 +628,40 @@ async function runEvaluation(
artifacts.evidenceBundleUrl = evidenceUpload.evidenceBundleUrl;
}

let diffSummary = null;
if (previousRunId) {
const previousSnap = await runRef.parent.doc(previousRunId).get();
if (previousSnap.exists) {
const previous = previousSnap.data() as EvaluationResult;
diffSummary = computeDiff(
{
score: previous.score,
grade: previous.grade,
pillarScores: previous.pillarScores,
checks: previous.checks,
},
{
score: evaluation.score,
grade: evaluation.grade,
pillarScores: evaluation.pillarScores,
checks: evaluation.checks,
}
);
}
}

evaluation = {
...evaluation,
status: "complete",
artifacts,
completedAt: new Date().toISOString(),
diffSummary: diffSummary ?? undefined,
};

await runRef.set(evaluation, { merge: true });
await runRootRef.set(evaluation, { merge: true });

await db.collection("evaluations").doc(result.domain).set(
await domainRef.set(
{
domain: result.domain,
latestRunId: runId,
Expand Down Expand Up @@ -980,7 +1012,38 @@ app.get("/v1/evaluations/:domain/latest.json", async (req, res) => {
if (!run.exists) {
return sendError(res, 404, "Run not found", "not_found");
}
return res.json(run.data());
const runData = run.data() as EvaluationResult;
let previousSummary: {
score: number;
grade: string;
pillarScores: EvaluationResult["pillarScores"];
completedAt?: string;
} | null = null;

if (runData.previousRunId) {
const previousRun = await db
.collection("evaluations")
.doc(domain)
.collection("runs")
.doc(runData.previousRunId)
.get();
if (previousRun.exists) {
const previous = previousRun.data() as EvaluationResult;
previousSummary = {
score: previous.score,
grade: previous.grade,
pillarScores: previous.pillarScores,
completedAt: previous.completedAt ?? previous.createdAt,
};
}
}

return res.json({
...runData,
previousRunId: runData.previousRunId,
diff: runData.diffSummary ?? undefined,
previousSummary: previousSummary ?? undefined,
});
});

app.get("/v1/evaluations/:domain/:runId.json", async (req, res) => {
Expand All @@ -998,6 +1061,50 @@ app.get("/v1/evaluations/:domain/:runId.json", async (req, res) => {
return res.json(run.data());
});

app.get("/badge/:domain.svg", async (req, res) => {
const domain = req.params.domain.toLowerCase();
if (!/^[a-z0-9.-]+$/.test(domain)) {
const svg = renderBadgeSvg({ domain, statusLabel: "Invalid domain" });
res.set("Content-Type", "image/svg+xml; charset=utf-8");
res.set("Cache-Control", "public, max-age=300, stale-while-revalidate=3600");
return res.status(400).send(svg);
}

const parent = await db.collection("evaluations").doc(domain).get();
const latestRunId = parent.exists ? (parent.data()?.latestRunId as string | undefined) : undefined;
if (!latestRunId) {
const svg = renderBadgeSvg({ domain, statusLabel: "Not evaluated" });
res.set("Content-Type", "image/svg+xml; charset=utf-8");
res.set("Cache-Control", "public, max-age=300, stale-while-revalidate=3600");
return res.status(404).send(svg);
}

const run = await db
.collection("evaluations")
.doc(domain)
.collection("runs")
.doc(latestRunId)
.get();
if (!run.exists) {
const svg = renderBadgeSvg({ domain, statusLabel: "Not evaluated" });
res.set("Content-Type", "image/svg+xml; charset=utf-8");
res.set("Cache-Control", "public, max-age=300, stale-while-revalidate=3600");
return res.status(404).send(svg);
}

const data = run.data() as EvaluationResult;
const updatedAt = data.completedAt ?? data.createdAt;
const svg = renderBadgeSvg({
domain,
score: data.status === "complete" ? data.score : null,
grade: data.status === "complete" ? data.grade : null,
updatedAtISO: updatedAt,
});
res.set("Content-Type", "image/svg+xml; charset=utf-8");
res.set("Cache-Control", "public, max-age=300, stale-while-revalidate=3600");
return res.status(data.status === "complete" ? 200 : 404).send(svg);
});

export const api = onRequest(
{
region: "us-central1",
Expand Down
4 changes: 2 additions & 2 deletions apps/functions/src/ssr/asset-manifest.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export const SSR_ASSETS = {
"scriptSrc": "/assets/index-BwWXd1jY.js",
"cssHref": "/assets/index-DXsQJ1HL.css"
"scriptSrc": "/assets/index-BTJpqCVz.js",
"cssHref": "/assets/index-EQLOK5sA.css"
} as const;
3 changes: 2 additions & 1 deletion apps/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" href="/logo.png" />
<link rel="apple-touch-icon" href="/logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
Expand Down Expand Up @@ -58,7 +59,7 @@
"@type": "Organization",
"name": "Agentability",
"url": "https://agentability.org",
"logo": "https://agentability.org/logo.png",
"logo": "https://agentability.org/logo.svg",
"sameAs": ["https://github.com/khalidsaidi/agentability"]
}
</script>
Expand Down
2 changes: 1 addition & 1 deletion apps/web/public/.well-known/ai-plugin.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"url": "https://agentability.org/.well-known/openapi.json",
"is_user_authenticated": false
},
"logo_url": "https://agentability.org/logo.png",
"logo_url": "https://agentability.org/logo.svg",
"contact_email": "hello@agentability.org",
"legal_url": "https://agentability.org/legal/terms.md"
}
2 changes: 1 addition & 1 deletion apps/web/public/.well-known/air.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"spec_version": "1.0",
"spec_version": "1.2",
"canonical_base_url": "https://agentability.org",
"product": {
"name": "Agentability",
Expand Down
Loading
Loading