From 900907c189a1a62b60fb7e923f82c0dde2ad6bc5 Mon Sep 17 00:00:00 2001 From: ElasticBottle Date: Mon, 16 Feb 2026 21:59:40 +0800 Subject: [PATCH 1/9] chore: update node and env package --- apps/seo-contact/package.json | 4 +- apps/seo-www/package.json | 4 +- apps/seo/package.json | 4 +- apps/www/package.json | 4 +- packages/api-core/package.json | 2 +- packages/api-seo/package.json | 4 +- packages/api-user-vm/package.json | 4 +- packages/auth/package.json | 4 +- packages/content/package.json | 2 +- packages/dataforseo/package.json | 4 +- packages/db/package.json | 4 +- packages/emails/package.json | 2 +- packages/google-apis/package.json | 4 +- packages/task/package.json | 4 +- pnpm-lock.yaml | 460 +++++++++++++----------------- 15 files changed, 218 insertions(+), 292 deletions(-) diff --git a/apps/seo-contact/package.json b/apps/seo-contact/package.json index be7fa40e4..0b62ac182 100644 --- a/apps/seo-contact/package.json +++ b/apps/seo-contact/package.json @@ -22,7 +22,7 @@ }, "dependencies": { "@rectangular-labs/ui": "workspace:*", - "@t3-oss/env-core": "^0.13.8", + "@t3-oss/env-core": "^0.13.10", "@tanstack/react-query": "^5.90.10", "@tanstack/react-query-devtools": "^5.90.2", "@tanstack/react-router": "^1.146.2", @@ -41,7 +41,7 @@ "@tailwindcss/vite": "^4.1.17", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.0", - "@types/node": "^24.10.1", + "@types/node": "^25.2.3", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", diff --git a/apps/seo-www/package.json b/apps/seo-www/package.json index b66308951..90de8ab8b 100644 --- a/apps/seo-www/package.json +++ b/apps/seo-www/package.json @@ -24,7 +24,7 @@ "@rectangular-labs/content": "workspace:*", "@rectangular-labs/db": "workspace:*", "@rectangular-labs/ui": "workspace:*", - "@t3-oss/env-core": "^0.13.8", + "@t3-oss/env-core": "^0.13.10", "@tanstack/react-query": "^5.90.10", "@tanstack/react-query-devtools": "^5.90.2", "@tanstack/react-router": "^1.146.2", @@ -43,7 +43,7 @@ "@tailwindcss/vite": "^4.1.17", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.0", - "@types/node": "^24.10.1", + "@types/node": "^25.2.3", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", diff --git a/apps/seo/package.json b/apps/seo/package.json index 3a392e574..db19895db 100644 --- a/apps/seo/package.json +++ b/apps/seo/package.json @@ -32,7 +32,7 @@ "@rectangular-labs/result": "workspace:*", "@rectangular-labs/ui": "workspace:*", "@stepperize/react": "^5.1.9", - "@t3-oss/env-core": "^0.13.8", + "@t3-oss/env-core": "^0.13.10", "@tanstack/react-query": "^5.90.10", "@tanstack/react-query-devtools": "^5.90.2", "@tanstack/react-router": "^1.146.2", @@ -59,7 +59,7 @@ "@tailwindcss/vite": "^4.1.17", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.0", - "@types/node": "^24.10.1", + "@types/node": "^25.2.3", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", diff --git a/apps/www/package.json b/apps/www/package.json index 47eadca6c..d45e93a29 100644 --- a/apps/www/package.json +++ b/apps/www/package.json @@ -25,7 +25,7 @@ "@react-three/fiber": "^9.4.0", "@rectangular-labs/content": "workspace:*", "@rectangular-labs/ui": "workspace:*", - "@t3-oss/env-core": "^0.13.8", + "@t3-oss/env-core": "^0.13.10", "@tanstack/react-query": "^5.90.10", "@tanstack/react-query-devtools": "^5.90.2", "@tanstack/react-router": "^1.146.2", @@ -44,7 +44,7 @@ "@tailwindcss/vite": "^4.1.17", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.0", - "@types/node": "^24.10.1", + "@types/node": "^25.2.3", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@types/three": "^0.181.0", diff --git a/packages/api-core/package.json b/packages/api-core/package.json index 3c0aae177..bb7479293 100644 --- a/packages/api-core/package.json +++ b/packages/api-core/package.json @@ -19,7 +19,7 @@ }, "devDependencies": { "@rectangular-labs/typescript": "workspace:*", - "@types/node": "^24.10.1", + "@types/node": "^25.2.3", "typescript": "^5.9.3" }, "dependencies": { diff --git a/packages/api-seo/package.json b/packages/api-seo/package.json index 1b7cd5498..9f50e271c 100644 --- a/packages/api-seo/package.json +++ b/packages/api-seo/package.json @@ -48,7 +48,7 @@ "devDependencies": { "@cloudflare/workers-types": "^4.20251121.0", "@rectangular-labs/typescript": "workspace:*", - "@types/node": "^24.10.1", + "@types/node": "^25.2.3", "typescript": "^5.9.3" }, "dependencies": { @@ -70,7 +70,7 @@ "@rectangular-labs/loro-file-system": "workspace:*", "@rectangular-labs/result": "workspace:*", "@rectangular-labs/task": "workspace:*", - "@t3-oss/env-core": "^0.13.8", + "@t3-oss/env-core": "^0.13.10", "ai": "^6.0.86", "arktype": "^2.1.29", "aws4fetch": "^1.0.20", diff --git a/packages/api-user-vm/package.json b/packages/api-user-vm/package.json index d9c0c1a5b..7dc90da7c 100644 --- a/packages/api-user-vm/package.json +++ b/packages/api-user-vm/package.json @@ -27,7 +27,7 @@ }, "devDependencies": { "@rectangular-labs/typescript": "workspace:*", - "@types/node": "^24.10.1", + "@types/node": "^25.2.3", "tsup": "^8.5.1", "typescript": "^5.9.3" }, @@ -41,7 +41,7 @@ "@rectangular-labs/auth": "workspace:*", "@rectangular-labs/db": "workspace:*", "@rectangular-labs/result": "workspace:*", - "@t3-oss/env-core": "^0.13.8", + "@t3-oss/env-core": "^0.13.10", "ai": "^6.0.86", "ai-sdk-provider-claude-code": "^2.1.0", "arktype": "^2.1.29" diff --git a/packages/auth/package.json b/packages/auth/package.json index d8ee90eb6..def662b8f 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -27,7 +27,7 @@ "devDependencies": { "@rectangular-labs/typescript": "workspace:*", "@tanstack/react-query": "^5.90.10", - "@types/node": "^24.10.1", + "@types/node": "^25.2.3", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "class-variance-authority": "^0.7.1", @@ -45,7 +45,7 @@ "@rectangular-labs/core": "workspace:*", "@rectangular-labs/emails": "workspace:*", "@rectangular-labs/ui": "workspace:*", - "@t3-oss/env-core": "^0.13.8", + "@t3-oss/env-core": "^0.13.10", "arktype": "^2.1.29", "better-auth": "1.4.13", "lucide-react": "^0.556.0", diff --git a/packages/content/package.json b/packages/content/package.json index 4d7ead100..1f958dbb5 100644 --- a/packages/content/package.json +++ b/packages/content/package.json @@ -43,7 +43,7 @@ "@fumadocs/content-collections": "^1.2.4", "@rectangular-labs/typescript": "workspace:*", "@types/mdx": "^2.0.13", - "@types/node": "^24.10.1", + "@types/node": "^25.2.3", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "concurrently": "^9.2.1", diff --git a/packages/dataforseo/package.json b/packages/dataforseo/package.json index da183774c..39700321d 100644 --- a/packages/dataforseo/package.json +++ b/packages/dataforseo/package.json @@ -33,14 +33,14 @@ "devDependencies": { "@rectangular-labs/db": "workspace:*", "@rectangular-labs/typescript": "workspace:*", - "@types/node": "^24.10.1", + "@types/node": "^25.2.3", "typescript": "^5.9.3" }, "dependencies": { "@hey-api/openapi-ts": "^0.88.0", "@rectangular-labs/core": "workspace:*", "@rectangular-labs/result": "workspace:*", - "@t3-oss/env-core": "^0.13.8", + "@t3-oss/env-core": "^0.13.10", "arktype": "^2.1.29" } } diff --git a/packages/db/package.json b/packages/db/package.json index 9123962c9..e2a2aaf58 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -39,7 +39,7 @@ "@neondatabase/serverless": "^1.0.2", "@rectangular-labs/core": "workspace:*", "@rectangular-labs/result": "workspace:*", - "@t3-oss/env-core": "^0.13.8", + "@t3-oss/env-core": "^0.13.10", "ai": "^6.0.86", "arktype": "^2.1.29", "drizzle-arktype": "^0.1.3", @@ -49,7 +49,7 @@ }, "devDependencies": { "@rectangular-labs/typescript": "workspace:*", - "@types/node": "^24.10.1", + "@types/node": "^25.2.3", "drizzle-kit": "^0.31.7", "typescript": "^5.9.3" } diff --git a/packages/emails/package.json b/packages/emails/package.json index 84dbe7a30..11db8dd4f 100644 --- a/packages/emails/package.json +++ b/packages/emails/package.json @@ -62,7 +62,7 @@ "@plunk/node": "^3.0.3", "@rectangular-labs/typescript": "workspace:*", "@sendgrid/mail": "^8.1.6", - "@types/node": "^24.10.1", + "@types/node": "^25.2.3", "@types/nodemailer": "^7.0.4", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", diff --git a/packages/google-apis/package.json b/packages/google-apis/package.json index ab2069b6c..67989b0cf 100644 --- a/packages/google-apis/package.json +++ b/packages/google-apis/package.json @@ -19,14 +19,14 @@ }, "devDependencies": { "@rectangular-labs/typescript": "workspace:*", - "@types/node": "^24.10.1", + "@types/node": "^25.2.3", "typescript": "^5.9.3" }, "dependencies": { "@rectangular-labs/core": "workspace:*", "@rectangular-labs/db": "workspace:*", "@rectangular-labs/result": "workspace:*", - "@t3-oss/env-core": "^0.13.8", + "@t3-oss/env-core": "^0.13.10", "arktype": "^2.1.29" } } diff --git a/packages/task/package.json b/packages/task/package.json index 0aa51d0ec..9a392247d 100644 --- a/packages/task/package.json +++ b/packages/task/package.json @@ -25,7 +25,7 @@ "devDependencies": { "@rectangular-labs/typescript": "workspace:*", "@trigger.dev/build": "4.1.1", - "@types/node": "^24.10.1", + "@types/node": "^25.2.3", "typescript": "^5.9.3" }, "dependencies": { @@ -36,7 +36,7 @@ "@rectangular-labs/dataforseo": "workspace:*", "@rectangular-labs/db": "workspace:*", "@rectangular-labs/result": "workspace:*", - "@t3-oss/env-core": "^0.13.8", + "@t3-oss/env-core": "^0.13.10", "@trigger.dev/sdk": "4.1.1", "ai": "^6.0.86", "arktype": "^2.1.29", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7b7fc6eb8..02bdb040b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -22,7 +22,7 @@ importers: version: 2.4.0 '@changesets/cli': specifier: ^2.29.7 - version: 2.29.7(@types/node@24.10.13) + version: 2.29.7(@types/node@25.2.3) '@dotenvx/dotenvx': specifier: ^1.51.1 version: 1.51.1 @@ -31,10 +31,10 @@ importers: version: link:tooling/typescript '@turbo/gen': specifier: ^2.6.1 - version: 2.6.1(@swc/core@1.13.5)(@swc/wasm@1.13.21)(@types/node@24.10.13)(typescript@5.9.3) + version: 2.6.1(@swc/core@1.13.5)(@swc/wasm@1.13.21)(@types/node@25.2.3)(typescript@5.9.3) knip: specifier: ^5.70.1 - version: 5.70.1(@types/node@24.10.13)(typescript@5.9.3) + version: 5.70.1(@types/node@25.2.3)(typescript@5.9.3) miniflare: specifier: 4.20251118.1 version: 4.20251118.1 @@ -49,7 +49,9 @@ importers: version: 5.9.3 vitest: specifier: ^4.0.13 - version: 4.0.13(@opentelemetry/api@1.9.0)(@types/debug@4.1.12)(@types/node@24.10.13)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2) + version: 4.0.13(@opentelemetry/api@1.9.0)(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2) + + apps/queries: {} apps/seo: dependencies: @@ -87,8 +89,8 @@ importers: specifier: ^5.1.9 version: 5.1.9(react@19.2.3)(typescript@5.9.3) '@t3-oss/env-core': - specifier: ^0.13.8 - version: 0.13.8(arktype@2.1.29)(typescript@5.9.3)(zod@4.1.12) + specifier: ^0.13.10 + version: 0.13.10(arktype@2.1.29)(typescript@5.9.3)(zod@4.1.12) '@tanstack/react-query': specifier: ^5.90.10 version: 5.90.10(react@19.2.3) @@ -106,7 +108,7 @@ importers: version: 1.150.0(@tanstack/query-core@5.90.10)(@tanstack/react-query@5.90.10(react@19.2.3))(@tanstack/react-router@1.150.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.150.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/react-start': specifier: ^1.146.2 - version: 1.150.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2))(webpack@5.99.5(@swc/core@1.13.5)) + version: 1.150.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2))(webpack@5.99.5(@swc/core@1.13.5)) '@tanstack/react-table': specifier: ^8.21.3 version: 8.21.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -149,13 +151,13 @@ importers: devDependencies: '@cloudflare/vite-plugin': specifier: ^1.15.2 - version: 1.15.2(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2))(workerd@1.20251118.0)(wrangler@4.50.0(@cloudflare/workers-types@4.20251121.0)) + version: 1.15.2(vite@7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2))(workerd@1.20251118.0)(wrangler@4.50.0(@cloudflare/workers-types@4.20251121.0)) '@rectangular-labs/typescript': specifier: workspace:* version: link:../../tooling/typescript '@tailwindcss/vite': specifier: ^4.1.17 - version: 4.1.17(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2)) + version: 4.1.17(vite@7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2)) '@testing-library/dom': specifier: ^10.4.1 version: 10.4.1 @@ -163,8 +165,8 @@ importers: specifier: ^16.3.0 version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@types/node': - specifier: ^24.10.1 - version: 24.10.1 + specifier: ^25.2.3 + version: 25.2.3 '@types/react': specifier: ^19.2.7 version: 19.2.7 @@ -173,7 +175,7 @@ importers: version: 19.2.3(@types/react@19.2.7) '@vitejs/plugin-react': specifier: ^5.1.1 - version: 5.1.1(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2)) + version: 5.1.1(vite@7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2)) jiti: specifier: ^2.6.1 version: 2.6.1 @@ -185,16 +187,16 @@ importers: version: 5.9.3 vite: specifier: 7.2.4 - version: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2) + version: 7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2) vite-plugin-mkcert: specifier: ^1.17.9 - version: 1.17.9(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2)) + version: 1.17.9(vite@7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2)) vite-tsconfig-paths: specifier: ^5.1.4 - version: 5.1.4(typescript@5.9.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2)) + version: 5.1.4(typescript@5.9.3)(vite@7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2)) vitest: specifier: ^4.0.13 - version: 4.0.13(@opentelemetry/api@1.9.0)(@types/debug@4.1.12)(@types/node@24.10.1)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2) + version: 4.0.13(@opentelemetry/api@1.9.0)(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2) web-vitals: specifier: ^5.1.0 version: 5.1.0 @@ -208,8 +210,8 @@ importers: specifier: workspace:* version: link:../../packages/ui '@t3-oss/env-core': - specifier: ^0.13.8 - version: 0.13.8(arktype@2.1.29)(typescript@5.9.3)(zod@4.1.12) + specifier: ^0.13.10 + version: 0.13.10(arktype@2.1.29)(typescript@5.9.3)(zod@4.1.12) '@tanstack/react-query': specifier: ^5.90.10 version: 5.90.10(react@19.2.3) @@ -227,7 +229,7 @@ importers: version: 1.150.0(@tanstack/query-core@5.90.10)(@tanstack/react-query@5.90.10(react@19.2.3))(@tanstack/react-router@1.150.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.150.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/react-start': specifier: ^1.146.2 - version: 1.150.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2))(webpack@5.99.5(@swc/core@1.13.5)) + version: 1.150.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2))(webpack@5.99.5(@swc/core@1.13.5)) arktype: specifier: ^2.1.29 version: 2.1.29 @@ -246,13 +248,13 @@ importers: devDependencies: '@cloudflare/vite-plugin': specifier: ^1.15.2 - version: 1.15.2(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2))(workerd@1.20251118.0)(wrangler@4.50.0(@cloudflare/workers-types@4.20251121.0)) + version: 1.15.2(vite@7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2))(workerd@1.20251118.0)(wrangler@4.50.0(@cloudflare/workers-types@4.20251121.0)) '@rectangular-labs/typescript': specifier: workspace:* version: link:../../tooling/typescript '@tailwindcss/vite': specifier: ^4.1.17 - version: 4.1.17(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2)) + version: 4.1.17(vite@7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2)) '@testing-library/dom': specifier: ^10.4.1 version: 10.4.1 @@ -260,8 +262,8 @@ importers: specifier: ^16.3.0 version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@types/node': - specifier: ^24.10.1 - version: 24.10.1 + specifier: ^25.2.3 + version: 25.2.3 '@types/react': specifier: ^19.2.7 version: 19.2.7 @@ -270,7 +272,7 @@ importers: version: 19.2.3(@types/react@19.2.7) '@vitejs/plugin-react': specifier: ^5.1.1 - version: 5.1.1(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2)) + version: 5.1.1(vite@7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2)) jiti: specifier: ^2.6.1 version: 2.6.1 @@ -285,16 +287,16 @@ importers: version: 5.9.3 vite: specifier: 7.2.4 - version: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2) + version: 7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2) vite-plugin-mkcert: specifier: ^1.17.9 - version: 1.17.9(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2)) + version: 1.17.9(vite@7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2)) vite-tsconfig-paths: specifier: ^5.1.4 - version: 5.1.4(typescript@5.9.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2)) + version: 5.1.4(typescript@5.9.3)(vite@7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2)) vitest: specifier: ^4.0.13 - version: 4.0.13(@opentelemetry/api@1.9.0)(@types/debug@4.1.12)(@types/node@24.10.1)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2) + version: 4.0.13(@opentelemetry/api@1.9.0)(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2) web-vitals: specifier: ^5.1.0 version: 5.1.0 @@ -314,8 +316,8 @@ importers: specifier: workspace:* version: link:../../packages/ui '@t3-oss/env-core': - specifier: ^0.13.8 - version: 0.13.8(arktype@2.1.29)(typescript@5.9.3)(zod@4.1.12) + specifier: ^0.13.10 + version: 0.13.10(arktype@2.1.29)(typescript@5.9.3)(zod@4.1.12) '@tanstack/react-query': specifier: ^5.90.10 version: 5.90.10(react@19.2.3) @@ -333,7 +335,7 @@ importers: version: 1.150.0(@tanstack/query-core@5.90.10)(@tanstack/react-query@5.90.10(react@19.2.3))(@tanstack/react-router@1.150.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.150.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/react-start': specifier: ^1.146.2 - version: 1.150.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2))(webpack@5.99.5(@swc/core@1.13.5)) + version: 1.150.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2))(webpack@5.99.5(@swc/core@1.13.5)) arktype: specifier: ^2.1.29 version: 2.1.29 @@ -352,13 +354,13 @@ importers: devDependencies: '@cloudflare/vite-plugin': specifier: ^1.15.2 - version: 1.15.2(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2))(workerd@1.20251118.0)(wrangler@4.50.0(@cloudflare/workers-types@4.20251121.0)) + version: 1.15.2(vite@7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2))(workerd@1.20251118.0)(wrangler@4.50.0(@cloudflare/workers-types@4.20251121.0)) '@rectangular-labs/typescript': specifier: workspace:* version: link:../../tooling/typescript '@tailwindcss/vite': specifier: ^4.1.17 - version: 4.1.17(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2)) + version: 4.1.17(vite@7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2)) '@testing-library/dom': specifier: ^10.4.1 version: 10.4.1 @@ -366,8 +368,8 @@ importers: specifier: ^16.3.0 version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@types/node': - specifier: ^24.10.1 - version: 24.10.1 + specifier: ^25.2.3 + version: 25.2.3 '@types/react': specifier: ^19.2.7 version: 19.2.7 @@ -376,7 +378,7 @@ importers: version: 19.2.3(@types/react@19.2.7) '@vitejs/plugin-react': specifier: ^5.1.1 - version: 5.1.1(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2)) + version: 5.1.1(vite@7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2)) jiti: specifier: ^2.6.1 version: 2.6.1 @@ -391,16 +393,16 @@ importers: version: 5.9.3 vite: specifier: 7.2.4 - version: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2) + version: 7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2) vite-plugin-mkcert: specifier: ^1.17.9 - version: 1.17.9(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2)) + version: 1.17.9(vite@7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2)) vite-tsconfig-paths: specifier: ^5.1.4 - version: 5.1.4(typescript@5.9.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2)) + version: 5.1.4(typescript@5.9.3)(vite@7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2)) vitest: specifier: ^4.0.13 - version: 4.0.13(@opentelemetry/api@1.9.0)(@types/debug@4.1.12)(@types/node@24.10.1)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2) + version: 4.0.13(@opentelemetry/api@1.9.0)(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2) web-vitals: specifier: ^5.1.0 version: 5.1.0 @@ -423,8 +425,8 @@ importers: specifier: workspace:* version: link:../../packages/ui '@t3-oss/env-core': - specifier: ^0.13.8 - version: 0.13.8(arktype@2.1.29)(typescript@5.9.3)(zod@4.1.12) + specifier: ^0.13.10 + version: 0.13.10(arktype@2.1.29)(typescript@5.9.3)(zod@4.1.12) '@tanstack/react-query': specifier: ^5.90.10 version: 5.90.10(react@19.2.3) @@ -442,7 +444,7 @@ importers: version: 1.150.0(@tanstack/query-core@5.90.10)(@tanstack/react-query@5.90.10(react@19.2.3))(@tanstack/react-router@1.150.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@tanstack/router-core@1.150.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/react-start': specifier: ^1.146.2 - version: 1.150.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2))(webpack@5.99.5(@swc/core@1.13.5)) + version: 1.150.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2))(webpack@5.99.5(@swc/core@1.13.5)) arktype: specifier: ^2.1.29 version: 2.1.29 @@ -461,13 +463,13 @@ importers: devDependencies: '@cloudflare/vite-plugin': specifier: ^1.15.2 - version: 1.15.2(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2))(workerd@1.20251118.0)(wrangler@4.50.0(@cloudflare/workers-types@4.20251121.0)) + version: 1.15.2(vite@7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2))(workerd@1.20251118.0)(wrangler@4.50.0(@cloudflare/workers-types@4.20251121.0)) '@rectangular-labs/typescript': specifier: workspace:* version: link:../../tooling/typescript '@tailwindcss/vite': specifier: ^4.1.17 - version: 4.1.17(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2)) + version: 4.1.17(vite@7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2)) '@testing-library/dom': specifier: ^10.4.1 version: 10.4.1 @@ -475,8 +477,8 @@ importers: specifier: ^16.3.0 version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@types/node': - specifier: ^24.10.1 - version: 24.10.1 + specifier: ^25.2.3 + version: 25.2.3 '@types/react': specifier: ^19.2.7 version: 19.2.7 @@ -488,7 +490,7 @@ importers: version: 0.181.0 '@vitejs/plugin-react': specifier: ^5.1.1 - version: 5.1.1(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2)) + version: 5.1.1(vite@7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2)) jiti: specifier: ^2.6.1 version: 2.6.1 @@ -500,16 +502,16 @@ importers: version: 5.9.3 vite: specifier: 7.2.4 - version: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2) + version: 7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2) vite-plugin-mkcert: specifier: ^1.17.9 - version: 1.17.9(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2)) + version: 1.17.9(vite@7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2)) vite-tsconfig-paths: specifier: ^5.1.4 - version: 5.1.4(typescript@5.9.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2)) + version: 5.1.4(typescript@5.9.3)(vite@7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2)) vitest: specifier: ^4.0.13 - version: 4.0.13(@opentelemetry/api@1.9.0)(@types/debug@4.1.12)(@types/node@24.10.1)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2) + version: 4.0.13(@opentelemetry/api@1.9.0)(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2) web-vitals: specifier: ^5.1.0 version: 5.1.0 @@ -517,6 +519,25 @@ importers: specifier: ^4.50.0 version: 4.50.0(@cloudflare/workers-types@4.20251121.0) + packages/agents: + dependencies: + '@t3-oss/env-core': + specifier: ^0.13.10 + version: 0.13.10(arktype@2.1.29)(typescript@5.9.3)(zod@4.1.12) + arktype: + specifier: ^2.1.29 + version: 2.1.29 + devDependencies: + '@rectangular-labs/typescript': + specifier: workspace:* + version: link:../../tooling/typescript + '@types/node': + specifier: ^25.2.3 + version: 25.2.3 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + packages/api-core: dependencies: '@orpc/arktype': @@ -551,8 +572,8 @@ importers: specifier: workspace:* version: link:../../tooling/typescript '@types/node': - specifier: ^24.10.1 - version: 24.10.1 + specifier: ^25.2.3 + version: 25.2.3 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -614,8 +635,8 @@ importers: specifier: workspace:* version: link:../task '@t3-oss/env-core': - specifier: ^0.13.8 - version: 0.13.8(arktype@2.1.29)(typescript@5.9.3)(zod@4.1.12) + specifier: ^0.13.10 + version: 0.13.10(arktype@2.1.29)(typescript@5.9.3)(zod@4.1.12) ai: specifier: ^6.0.86 version: 6.0.86(zod@4.1.12) @@ -642,8 +663,8 @@ importers: specifier: workspace:* version: link:../../tooling/typescript '@types/node': - specifier: ^24.10.1 - version: 24.10.1 + specifier: ^25.2.3 + version: 25.2.3 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -678,8 +699,8 @@ importers: specifier: workspace:* version: link:../result '@t3-oss/env-core': - specifier: ^0.13.8 - version: 0.13.8(arktype@2.1.29)(typescript@5.9.3)(zod@4.1.12) + specifier: ^0.13.10 + version: 0.13.10(arktype@2.1.29)(typescript@5.9.3)(zod@4.1.12) ai: specifier: ^6.0.86 version: 6.0.86(zod@4.1.12) @@ -694,8 +715,8 @@ importers: specifier: workspace:* version: link:../../tooling/typescript '@types/node': - specifier: ^24.10.1 - version: 24.10.1 + specifier: ^25.2.3 + version: 25.2.3 tsup: specifier: ^8.5.1 version: 8.5.1(@swc/core@1.13.5)(jiti@2.6.1)(postcss@8.5.6)(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.2) @@ -707,7 +728,7 @@ importers: dependencies: '@better-auth/expo': specifier: 1.4.13 - version: 1.4.13(98e6562b45f0d45ab1a0015cb2f287c6) + version: 1.4.13(bc908f08eb2fe3c4a9d5dffaf42efee3) '@rectangular-labs/core': specifier: workspace:* version: link:../core @@ -718,14 +739,14 @@ importers: specifier: workspace:* version: link:../ui '@t3-oss/env-core': - specifier: ^0.13.8 - version: 0.13.8(arktype@2.1.29)(typescript@5.9.3)(zod@4.1.12) + specifier: ^0.13.10 + version: 0.13.10(arktype@2.1.29)(typescript@5.9.3)(zod@4.1.12) arktype: specifier: ^2.1.29 version: 2.1.29 better-auth: specifier: 1.4.13 - version: 1.4.13(@prisma/client@6.6.0(prisma@6.6.0(typescript@5.9.3))(typescript@5.9.3))(@tanstack/start-server-core@1.150.0)(better-sqlite3@11.9.1)(drizzle-kit@0.31.7)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20251121.0)(@neondatabase/serverless@1.0.2)(@opentelemetry/api@1.9.0)(@prisma/client@6.6.0(prisma@6.6.0(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.15.5)(better-sqlite3@11.9.1)(gel@2.0.1)(kysely@0.28.5)(pg@8.16.3)(postgres@3.4.7)(prisma@6.6.0(typescript@5.9.3)))(pg@8.16.3)(prisma@6.6.0(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@4.0.13(@opentelemetry/api@1.9.0)(@types/debug@4.1.12)(@types/node@24.10.1)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2))(vue@3.5.26(typescript@5.9.3)) + version: 1.4.13(@prisma/client@6.6.0(prisma@6.6.0(typescript@5.9.3))(typescript@5.9.3))(@tanstack/start-server-core@1.150.0)(better-sqlite3@11.9.1)(drizzle-kit@0.31.7)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20251121.0)(@neondatabase/serverless@1.0.2)(@opentelemetry/api@1.9.0)(@prisma/client@6.6.0(prisma@6.6.0(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.15.5)(better-sqlite3@11.9.1)(gel@2.0.1)(kysely@0.28.5)(pg@8.16.3)(postgres@3.4.7)(prisma@6.6.0(typescript@5.9.3)))(pg@8.16.3)(prisma@6.6.0(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@4.0.13(@opentelemetry/api@1.9.0)(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2))(vue@3.5.26(typescript@5.9.3)) lucide-react: specifier: ^0.556.0 version: 0.556.0(react@19.2.3) @@ -740,8 +761,8 @@ importers: specifier: ^5.90.10 version: 5.90.10(react@19.2.3) '@types/node': - specifier: ^24.10.1 - version: 24.10.1 + specifier: ^25.2.3 + version: 25.2.3 '@types/react': specifier: ^19.2.7 version: 19.2.7 @@ -801,8 +822,8 @@ importers: specifier: ^2.0.13 version: 2.0.13 '@types/node': - specifier: ^24.10.1 - version: 24.10.1 + specifier: ^25.2.3 + version: 25.2.3 '@types/react': specifier: ^19.2.7 version: 19.2.7 @@ -865,8 +886,8 @@ importers: specifier: workspace:* version: link:../result '@t3-oss/env-core': - specifier: ^0.13.8 - version: 0.13.8(arktype@2.1.29)(typescript@5.9.3)(zod@4.1.12) + specifier: ^0.13.10 + version: 0.13.10(arktype@2.1.29)(typescript@5.9.3)(zod@4.1.12) arktype: specifier: ^2.1.29 version: 2.1.29 @@ -878,8 +899,8 @@ importers: specifier: workspace:* version: link:../../tooling/typescript '@types/node': - specifier: ^24.10.1 - version: 24.10.1 + specifier: ^25.2.3 + version: 25.2.3 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -896,8 +917,8 @@ importers: specifier: workspace:* version: link:../result '@t3-oss/env-core': - specifier: ^0.13.8 - version: 0.13.8(arktype@2.1.29)(typescript@5.9.3)(zod@4.1.12) + specifier: ^0.13.10 + version: 0.13.10(arktype@2.1.29)(typescript@5.9.3)(zod@4.1.12) ai: specifier: ^6.0.86 version: 6.0.86(zod@4.1.12) @@ -921,8 +942,8 @@ importers: specifier: workspace:* version: link:../../tooling/typescript '@types/node': - specifier: ^24.10.1 - version: 24.10.1 + specifier: ^25.2.3 + version: 25.2.3 drizzle-kit: specifier: ^0.31.7 version: 0.31.7 @@ -945,8 +966,8 @@ importers: specifier: ^8.1.6 version: 8.1.6 '@types/node': - specifier: ^24.10.1 - version: 24.10.1 + specifier: ^25.2.3 + version: 25.2.3 '@types/nodemailer': specifier: ^7.0.4 version: 7.0.4 @@ -961,7 +982,7 @@ importers: version: 0.20.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) jsx-email: specifier: ^2.8.1 - version: 2.8.1(@jsx-email/plugin-inline@1.0.1)(@jsx-email/plugin-minify@1.0.2)(@jsx-email/plugin-pretty@1.0.0)(@types/node@24.10.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(jiti@2.6.1)(lightningcss@1.31.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(terser@5.46.0)(ts-node@10.9.2(@swc/core@1.13.5)(@swc/wasm@1.13.21)(@types/node@24.10.1)(typescript@5.9.3))(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.2) + version: 2.8.1(@jsx-email/plugin-inline@1.0.1)(@jsx-email/plugin-minify@1.0.2)(@jsx-email/plugin-pretty@1.0.0)(@types/node@25.2.3)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(jiti@2.6.1)(lightningcss@1.31.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(terser@5.46.0)(ts-node@10.9.2(@swc/core@1.13.5)(@swc/wasm@1.13.21)(@types/node@25.2.3)(typescript@5.9.3))(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.2) mailersend: specifier: ^2.6.0 version: 2.6.0 @@ -999,8 +1020,8 @@ importers: specifier: workspace:* version: link:../result '@t3-oss/env-core': - specifier: ^0.13.8 - version: 0.13.8(arktype@2.1.29)(typescript@5.9.3)(zod@4.1.12) + specifier: ^0.13.10 + version: 0.13.10(arktype@2.1.29)(typescript@5.9.3)(zod@4.1.12) arktype: specifier: ^2.1.29 version: 2.1.29 @@ -1009,8 +1030,8 @@ importers: specifier: workspace:* version: link:../../tooling/typescript '@types/node': - specifier: ^24.10.1 - version: 24.10.1 + specifier: ^25.2.3 + version: 25.2.3 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -1066,8 +1087,8 @@ importers: specifier: workspace:* version: link:../result '@t3-oss/env-core': - specifier: ^0.13.8 - version: 0.13.8(arktype@2.1.29)(typescript@5.9.3)(zod@4.1.12) + specifier: ^0.13.10 + version: 0.13.10(arktype@2.1.29)(typescript@5.9.3)(zod@4.1.12) '@trigger.dev/sdk': specifier: 4.1.1 version: 4.1.1(ai@6.0.86(zod@4.1.12))(typescript@5.9.3)(zod@4.1.12) @@ -1097,8 +1118,8 @@ importers: specifier: 4.1.1 version: 4.1.1(magicast@0.3.5)(typescript@5.9.3) '@types/node': - specifier: ^24.10.1 - version: 24.10.1 + specifier: ^25.2.3 + version: 25.2.3 typescript: specifier: ^5.9.3 version: 5.9.3 @@ -6793,13 +6814,13 @@ packages: resolution: {integrity: sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==} engines: {node: '>=14.16'} - '@t3-oss/env-core@0.13.8': - resolution: {integrity: sha512-L1inmpzLQyYu4+Q1DyrXsGJYCXbtXjC4cICw1uAKv0ppYPQv656lhZPU91Qd1VS6SO/bou1/q5ufVzBGbNsUpw==} + '@t3-oss/env-core@0.13.10': + resolution: {integrity: sha512-NNFfdlJ+HmPHkLi2HKy7nwuat9SIYOxei9K10lO2YlcSObDILY7mHZNSHsieIM3A0/5OOzw/P/b+yLvPdaG52g==} peerDependencies: arktype: ^2.1.0 typescript: '>=5.0.0' valibot: ^1.0.0-beta.7 || ^1.0.0 - zod: ^3.24.0 || ^4.0.0-beta.0 + zod: ^3.24.0 || ^4.0.0 peerDependenciesMeta: arktype: optional: true @@ -7372,11 +7393,8 @@ packages: '@types/node@22.18.6': resolution: {integrity: sha512-r8uszLPpeIWbNKtvWRt/DbVi5zbqZyj1PTmhRMqBMvDnaz1QpmSKujUtJLrqGZeoM8v72MfYggDceY4K1itzWQ==} - '@types/node@24.10.1': - resolution: {integrity: sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ==} - - '@types/node@24.10.13': - resolution: {integrity: sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==} + '@types/node@25.2.3': + resolution: {integrity: sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==} '@types/nodemailer@7.0.4': resolution: {integrity: sha512-ee8fxWqOchH+Hv6MDDNNy028kwvVnLplrStm4Zf/3uHWw5zzo8FoYYeffpJtGs2wWysEumMH0ZIdMGMY1eMAow==} @@ -16202,11 +16220,11 @@ snapshots: nanostores: 1.0.1 zod: 4.1.12 - '@better-auth/expo@1.4.13(98e6562b45f0d45ab1a0015cb2f287c6)': + '@better-auth/expo@1.4.13(bc908f08eb2fe3c4a9d5dffaf42efee3)': dependencies: '@better-auth/core': 1.4.13(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.1.12))(jose@6.1.0)(kysely@0.28.5)(nanostores@1.0.1) '@better-fetch/fetch': 1.1.21 - better-auth: 1.4.13(@prisma/client@6.6.0(prisma@6.6.0(typescript@5.9.3))(typescript@5.9.3))(@tanstack/start-server-core@1.150.0)(better-sqlite3@11.9.1)(drizzle-kit@0.31.7)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20251121.0)(@neondatabase/serverless@1.0.2)(@opentelemetry/api@1.9.0)(@prisma/client@6.6.0(prisma@6.6.0(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.15.5)(better-sqlite3@11.9.1)(gel@2.0.1)(kysely@0.28.5)(pg@8.16.3)(postgres@3.4.7)(prisma@6.6.0(typescript@5.9.3)))(pg@8.16.3)(prisma@6.6.0(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@4.0.13(@opentelemetry/api@1.9.0)(@types/debug@4.1.12)(@types/node@24.10.1)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2))(vue@3.5.26(typescript@5.9.3)) + better-auth: 1.4.13(@prisma/client@6.6.0(prisma@6.6.0(typescript@5.9.3))(typescript@5.9.3))(@tanstack/start-server-core@1.150.0)(better-sqlite3@11.9.1)(drizzle-kit@0.31.7)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20251121.0)(@neondatabase/serverless@1.0.2)(@opentelemetry/api@1.9.0)(@prisma/client@6.6.0(prisma@6.6.0(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.15.5)(better-sqlite3@11.9.1)(gel@2.0.1)(kysely@0.28.5)(pg@8.16.3)(postgres@3.4.7)(prisma@6.6.0(typescript@5.9.3)))(pg@8.16.3)(prisma@6.6.0(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@4.0.13(@opentelemetry/api@1.9.0)(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2))(vue@3.5.26(typescript@5.9.3)) better-call: 1.1.7(zod@4.1.12) expo-network: 8.0.8(expo@54.0.8(@babel/core@7.29.0)(react-native@0.81.4(@babel/core@7.29.0)(@types/react@19.2.7)(react@19.2.3))(react@19.2.3))(react@19.2.3) zod: 4.1.12 @@ -16295,7 +16313,7 @@ snapshots: dependencies: '@changesets/types': 6.1.0 - '@changesets/cli@2.29.7(@types/node@24.10.13)': + '@changesets/cli@2.29.7(@types/node@25.2.3)': dependencies: '@changesets/apply-release-plan': 7.0.13 '@changesets/assemble-release-plan': 6.0.9 @@ -16311,7 +16329,7 @@ snapshots: '@changesets/should-skip-package': 0.1.2 '@changesets/types': 6.1.0 '@changesets/write': 0.4.0 - '@inquirer/external-editor': 1.0.1(@types/node@24.10.13) + '@inquirer/external-editor': 1.0.1(@types/node@25.2.3) '@manypkg/get-packages': 1.1.3 ansi-colors: 4.1.3 ci-info: 3.9.0 @@ -16471,7 +16489,7 @@ snapshots: optionalDependencies: workerd: 1.20251118.0 - '@cloudflare/vite-plugin@1.15.2(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2))(workerd@1.20251118.0)(wrangler@4.50.0(@cloudflare/workers-types@4.20251121.0))': + '@cloudflare/vite-plugin@1.15.2(vite@7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2))(workerd@1.20251118.0)(wrangler@4.50.0(@cloudflare/workers-types@4.20251121.0))': dependencies: '@cloudflare/unenv-preset': 2.7.11(unenv@2.0.0-rc.24)(workerd@1.20251118.0) '@remix-run/node-fetch-server': 0.8.1 @@ -16480,7 +16498,7 @@ snapshots: picocolors: 1.1.1 tinyglobby: 0.2.15 unenv: 2.0.0-rc.24 - vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2) + vite: 7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2) wrangler: 4.50.0(@cloudflare/workers-types@4.20251121.0) ws: 8.18.0 transitivePeerDependencies: @@ -18258,12 +18276,12 @@ snapshots: '@img/sharp-win32-x64@0.33.5': optional: true - '@inquirer/external-editor@1.0.1(@types/node@24.10.13)': + '@inquirer/external-editor@1.0.1(@types/node@25.2.3)': dependencies: chardet: 2.1.0 iconv-lite: 0.6.3 optionalDependencies: - '@types/node': 24.10.13 + '@types/node': 25.2.3 '@inquirer/figures@1.0.13': {} @@ -18308,14 +18326,14 @@ snapshots: dependencies: '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 24.10.13 + '@types/node': 25.2.3 jest-mock: 29.7.0 '@jest/fake-timers@29.7.0': dependencies: '@jest/types': 29.6.3 '@sinonjs/fake-timers': 10.3.0 - '@types/node': 24.10.13 + '@types/node': 25.2.3 jest-message-util: 29.7.0 jest-mock: 29.7.0 jest-util: 29.7.0 @@ -18349,7 +18367,7 @@ snapshots: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 24.10.13 + '@types/node': 25.2.3 '@types/yargs': 17.0.35 chalk: 4.1.2 @@ -18604,13 +18622,13 @@ snapshots: '@adobe/css-tools': 4.4.4 hast-util-select: 6.0.4 hast-util-to-string: 3.0.1 - jsx-email: 2.8.1(@jsx-email/plugin-inline@1.0.1)(@jsx-email/plugin-minify@1.0.2)(@jsx-email/plugin-pretty@1.0.0)(@types/node@24.10.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(jiti@2.6.1)(lightningcss@1.31.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(terser@5.46.0)(ts-node@10.9.2(@swc/core@1.13.5)(@swc/wasm@1.13.21)(@types/node@24.10.1)(typescript@5.9.3))(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.2) + jsx-email: 2.8.1(@jsx-email/plugin-inline@1.0.1)(@jsx-email/plugin-minify@1.0.2)(@jsx-email/plugin-pretty@1.0.0)(@types/node@25.2.3)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(jiti@2.6.1)(lightningcss@1.31.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(terser@5.46.0)(ts-node@10.9.2(@swc/core@1.13.5)(@swc/wasm@1.13.21)(@types/node@25.2.3)(typescript@5.9.3))(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.2) unist-util-remove: 4.0.0 unist-util-visit: 5.1.0 '@jsx-email/plugin-minify@1.0.2(jsx-email@2.8.1)': dependencies: - jsx-email: 2.8.1(@jsx-email/plugin-inline@1.0.1)(@jsx-email/plugin-minify@1.0.2)(@jsx-email/plugin-pretty@1.0.0)(@types/node@24.10.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(jiti@2.6.1)(lightningcss@1.31.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(terser@5.46.0)(ts-node@10.9.2(@swc/core@1.13.5)(@swc/wasm@1.13.21)(@types/node@24.10.1)(typescript@5.9.3))(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.2) + jsx-email: 2.8.1(@jsx-email/plugin-inline@1.0.1)(@jsx-email/plugin-minify@1.0.2)(@jsx-email/plugin-pretty@1.0.0)(@types/node@25.2.3)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(jiti@2.6.1)(lightningcss@1.31.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(terser@5.46.0)(ts-node@10.9.2(@swc/core@1.13.5)(@swc/wasm@1.13.21)(@types/node@25.2.3)(typescript@5.9.3))(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.2) rehype-minify-attribute-whitespace: 4.0.1 rehype-minify-css-style: 4.0.1 rehype-minify-enumerated-attribute: 5.0.2 @@ -18632,7 +18650,7 @@ snapshots: '@jsx-email/plugin-pretty@1.0.0(jsx-email@2.8.1)': dependencies: - jsx-email: 2.8.1(@jsx-email/plugin-inline@1.0.1)(@jsx-email/plugin-minify@1.0.2)(@jsx-email/plugin-pretty@1.0.0)(@types/node@24.10.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(jiti@2.6.1)(lightningcss@1.31.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(terser@5.46.0)(ts-node@10.9.2(@swc/core@1.13.5)(@swc/wasm@1.13.21)(@types/node@24.10.1)(typescript@5.9.3))(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.2) + jsx-email: 2.8.1(@jsx-email/plugin-inline@1.0.1)(@jsx-email/plugin-minify@1.0.2)(@jsx-email/plugin-pretty@1.0.0)(@types/node@25.2.3)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(jiti@2.6.1)(lightningcss@1.31.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(terser@5.46.0)(ts-node@10.9.2(@swc/core@1.13.5)(@swc/wasm@1.13.21)(@types/node@25.2.3)(typescript@5.9.3))(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.2) pretty: 2.0.0 '@lezer/common@1.5.0': {} @@ -21540,7 +21558,7 @@ snapshots: dependencies: defer-to-connect: 2.0.1 - '@t3-oss/env-core@0.13.8(arktype@2.1.29)(typescript@5.9.3)(zod@4.1.12)': + '@t3-oss/env-core@0.13.10(arktype@2.1.29)(typescript@5.9.3)(zod@4.1.12)': optionalDependencies: arktype: 2.1.29 typescript: 5.9.3 @@ -21607,12 +21625,12 @@ snapshots: '@tailwindcss/oxide-win32-arm64-msvc': 4.1.17 '@tailwindcss/oxide-win32-x64-msvc': 4.1.17 - '@tailwindcss/vite@4.1.17(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2))': + '@tailwindcss/vite@4.1.17(vite@7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2))': dependencies: '@tailwindcss/node': 4.1.17 '@tailwindcss/oxide': 4.1.17 tailwindcss: 4.1.17 - vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2) + vite: 7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2) '@tanstack/history@1.145.7': {} @@ -21686,19 +21704,19 @@ snapshots: transitivePeerDependencies: - crossws - '@tanstack/react-start@1.150.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2))(webpack@5.99.5(@swc/core@1.13.5))': + '@tanstack/react-start@1.150.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2))(webpack@5.99.5(@swc/core@1.13.5))': dependencies: '@tanstack/react-router': 1.150.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/react-start-client': 1.150.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/react-start-server': 1.150.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/router-utils': 1.143.11 '@tanstack/start-client-core': 1.150.0 - '@tanstack/start-plugin-core': 1.150.0(@tanstack/react-router@1.150.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2))(webpack@5.99.5(@swc/core@1.13.5)) + '@tanstack/start-plugin-core': 1.150.0(@tanstack/react-router@1.150.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2))(webpack@5.99.5(@swc/core@1.13.5)) '@tanstack/start-server-core': 1.150.0 pathe: 2.0.3 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2) + vite: 7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2) transitivePeerDependencies: - '@rsbuild/core' - crossws @@ -21751,7 +21769,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.150.0(@tanstack/react-router@1.150.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2))(webpack@5.99.5(@swc/core@1.13.5))': + '@tanstack/router-plugin@1.150.0(@tanstack/react-router@1.150.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2))(webpack@5.99.5(@swc/core@1.13.5))': dependencies: '@babel/core': 7.28.5 '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.5) @@ -21769,7 +21787,7 @@ snapshots: zod: 3.25.76 optionalDependencies: '@tanstack/react-router': 1.150.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2) + vite: 7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2) webpack: 5.99.5(@swc/core@1.13.5) transitivePeerDependencies: - supports-color @@ -21802,7 +21820,7 @@ snapshots: '@tanstack/start-fn-stubs@1.143.8': {} - '@tanstack/start-plugin-core@1.150.0(@tanstack/react-router@1.150.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2))(webpack@5.99.5(@swc/core@1.13.5))': + '@tanstack/start-plugin-core@1.150.0(@tanstack/react-router@1.150.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2))(webpack@5.99.5(@swc/core@1.13.5))': dependencies: '@babel/code-frame': 7.27.1 '@babel/core': 7.28.5 @@ -21810,7 +21828,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.40 '@tanstack/router-core': 1.150.0 '@tanstack/router-generator': 1.150.0 - '@tanstack/router-plugin': 1.150.0(@tanstack/react-router@1.150.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2))(webpack@5.99.5(@swc/core@1.13.5)) + '@tanstack/router-plugin': 1.150.0(@tanstack/react-router@1.150.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2))(webpack@5.99.5(@swc/core@1.13.5)) '@tanstack/router-utils': 1.143.11 '@tanstack/start-client-core': 1.150.0 '@tanstack/start-server-core': 1.150.0 @@ -21821,8 +21839,8 @@ snapshots: srvx: 0.10.0 tinyglobby: 0.2.15 ufo: 1.6.1 - vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2) - vitefu: 1.1.1(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2)) + vite: 7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2) + vitefu: 1.1.1(vite@7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2)) xmlbuilder2: 4.0.3 zod: 3.25.76 transitivePeerDependencies: @@ -21997,7 +22015,7 @@ snapshots: '@tsconfig/node16@1.0.4': {} - '@turbo/gen@2.6.1(@swc/core@1.13.5)(@swc/wasm@1.13.21)(@types/node@24.10.13)(typescript@5.9.3)': + '@turbo/gen@2.6.1(@swc/core@1.13.5)(@swc/wasm@1.13.21)(@types/node@25.2.3)(typescript@5.9.3)': dependencies: '@turbo/workspaces': 2.6.1 commander: 10.0.1 @@ -22007,7 +22025,7 @@ snapshots: node-plop: 0.26.3 picocolors: 1.0.1 proxy-agent: 6.5.0 - ts-node: 10.9.2(@swc/core@1.13.5)(@swc/wasm@1.13.21)(@types/node@24.10.13)(typescript@5.9.3) + ts-node: 10.9.2(@swc/core@1.13.5)(@swc/wasm@1.13.21)(@types/node@25.2.3)(typescript@5.9.3) update-check: 1.5.4 validate-npm-package-name: 5.0.1 transitivePeerDependencies: @@ -22075,7 +22093,7 @@ snapshots: '@types/cors@2.8.19': dependencies: - '@types/node': 24.10.1 + '@types/node': 25.2.3 '@types/d3-array@3.2.2': {} @@ -22225,11 +22243,11 @@ snapshots: '@types/glob@7.2.0': dependencies: '@types/minimatch': 5.1.2 - '@types/node': 24.10.1 + '@types/node': 25.2.3 '@types/graceful-fs@4.1.9': dependencies: - '@types/node': 24.10.13 + '@types/node': 25.2.3 '@types/hast@3.0.4': dependencies: @@ -22254,7 +22272,7 @@ snapshots: '@types/jsdom@21.1.7': dependencies: - '@types/node': 24.10.1 + '@types/node': 25.2.3 '@types/tough-cookie': 4.0.5 parse5: 7.3.0 @@ -22280,7 +22298,7 @@ snapshots: '@types/node-fetch@2.6.13': dependencies: - '@types/node': 24.10.1 + '@types/node': 25.2.3 form-data: 4.0.5 '@types/node@12.20.55': {} @@ -22297,18 +22315,14 @@ snapshots: dependencies: undici-types: 6.21.0 - '@types/node@24.10.1': - dependencies: - undici-types: 7.16.0 - - '@types/node@24.10.13': + '@types/node@25.2.3': dependencies: undici-types: 7.16.0 '@types/nodemailer@7.0.4': dependencies: '@aws-sdk/client-sesv2': 3.938.0 - '@types/node': 24.10.1 + '@types/node': 25.2.3 transitivePeerDependencies: - aws-crt @@ -22316,7 +22330,7 @@ snapshots: '@types/pg@8.15.5': dependencies: - '@types/node': 24.10.1 + '@types/node': 25.2.3 pg-protocol: 1.10.3 pg-types: 2.2.0 @@ -22340,7 +22354,7 @@ snapshots: '@types/sax@1.2.7': dependencies: - '@types/node': 24.10.1 + '@types/node': 25.2.3 '@types/stack-utils@2.0.3': {} @@ -22360,7 +22374,7 @@ snapshots: '@types/through@0.0.33': dependencies: - '@types/node': 24.10.1 + '@types/node': 25.2.3 '@types/tinycolor2@1.4.6': {} @@ -22453,7 +22467,7 @@ snapshots: '@vercel/oidc@3.1.0': {} - '@vitejs/plugin-react@5.1.1(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2))': + '@vitejs/plugin-react@5.1.1(vite@7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2))': dependencies: '@babel/core': 7.28.5 '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5) @@ -22461,7 +22475,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.47 '@types/babel__core': 7.20.5 react-refresh: 0.18.0 - vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2) + vite: 7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2) transitivePeerDependencies: - supports-color @@ -22474,21 +22488,13 @@ snapshots: chai: 6.2.1 tinyrainbow: 3.0.3 - '@vitest/mocker@4.0.13(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2))': - dependencies: - '@vitest/spy': 4.0.13 - estree-walker: 3.0.3 - magic-string: 0.30.21 - optionalDependencies: - vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2) - - '@vitest/mocker@4.0.13(vite@7.2.4(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2))': + '@vitest/mocker@4.0.13(vite@7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2))': dependencies: '@vitest/spy': 4.0.13 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.2.4(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2) + vite: 7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2) '@vitest/pretty-format@4.0.13': dependencies: @@ -23032,7 +23038,7 @@ snapshots: before-after-hook@4.0.0: {} - better-auth@1.4.13(@prisma/client@6.6.0(prisma@6.6.0(typescript@5.9.3))(typescript@5.9.3))(@tanstack/start-server-core@1.150.0)(better-sqlite3@11.9.1)(drizzle-kit@0.31.7)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20251121.0)(@neondatabase/serverless@1.0.2)(@opentelemetry/api@1.9.0)(@prisma/client@6.6.0(prisma@6.6.0(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.15.5)(better-sqlite3@11.9.1)(gel@2.0.1)(kysely@0.28.5)(pg@8.16.3)(postgres@3.4.7)(prisma@6.6.0(typescript@5.9.3)))(pg@8.16.3)(prisma@6.6.0(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@4.0.13(@opentelemetry/api@1.9.0)(@types/debug@4.1.12)(@types/node@24.10.1)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2))(vue@3.5.26(typescript@5.9.3)): + better-auth@1.4.13(@prisma/client@6.6.0(prisma@6.6.0(typescript@5.9.3))(typescript@5.9.3))(@tanstack/start-server-core@1.150.0)(better-sqlite3@11.9.1)(drizzle-kit@0.31.7)(drizzle-orm@0.44.7(@cloudflare/workers-types@4.20251121.0)(@neondatabase/serverless@1.0.2)(@opentelemetry/api@1.9.0)(@prisma/client@6.6.0(prisma@6.6.0(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.15.5)(better-sqlite3@11.9.1)(gel@2.0.1)(kysely@0.28.5)(pg@8.16.3)(postgres@3.4.7)(prisma@6.6.0(typescript@5.9.3)))(pg@8.16.3)(prisma@6.6.0(typescript@5.9.3))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vitest@4.0.13(@opentelemetry/api@1.9.0)(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2))(vue@3.5.26(typescript@5.9.3)): dependencies: '@better-auth/core': 1.4.13(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.1.12))(jose@6.1.0)(kysely@0.28.5)(nanostores@1.0.1) '@better-auth/telemetry': 1.4.13(@better-auth/core@1.4.13(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.7(zod@4.1.12))(jose@6.1.0)(kysely@0.28.5)(nanostores@1.0.1)) @@ -23056,7 +23062,7 @@ snapshots: prisma: 6.6.0(typescript@5.9.3) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - vitest: 4.0.13(@opentelemetry/api@1.9.0)(@types/debug@4.1.12)(@types/node@24.10.1)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2) + vitest: 4.0.13(@opentelemetry/api@1.9.0)(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2) vue: 3.5.26(typescript@5.9.3) better-call@1.1.7(zod@4.1.12): @@ -23415,7 +23421,7 @@ snapshots: chrome-launcher@0.15.2: dependencies: - '@types/node': 24.10.13 + '@types/node': 25.2.3 escape-string-regexp: 4.0.0 is-wsl: 2.2.0 lighthouse-logger: 1.4.2 @@ -23427,7 +23433,7 @@ snapshots: chromium-edge-launcher@0.2.0: dependencies: - '@types/node': 24.10.13 + '@types/node': 25.2.3 escape-string-regexp: 4.0.0 is-wsl: 2.2.0 lighthouse-logger: 1.4.2 @@ -24246,7 +24252,7 @@ snapshots: dependencies: '@types/cookie': 0.4.1 '@types/cors': 2.8.19 - '@types/node': 24.10.1 + '@types/node': 25.2.3 accepts: 1.3.8 base64id: 2.0.0 cookie: 0.4.2 @@ -26143,7 +26149,7 @@ snapshots: '@jest/environment': 29.7.0 '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 24.10.13 + '@types/node': 25.2.3 jest-mock: 29.7.0 jest-util: 29.7.0 @@ -26153,7 +26159,7 @@ snapshots: dependencies: '@jest/types': 29.6.3 '@types/graceful-fs': 4.1.9 - '@types/node': 24.10.13 + '@types/node': 25.2.3 anymatch: 3.1.3 fb-watchman: 2.0.2 graceful-fs: 4.2.11 @@ -26180,7 +26186,7 @@ snapshots: jest-mock@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 24.10.13 + '@types/node': 25.2.3 jest-util: 29.7.0 jest-regex-util@29.6.3: {} @@ -26188,7 +26194,7 @@ snapshots: jest-util@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 24.10.13 + '@types/node': 25.2.3 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 @@ -26205,14 +26211,14 @@ snapshots: jest-worker@27.5.1: dependencies: - '@types/node': 24.10.13 + '@types/node': 25.2.3 merge-stream: 2.0.0 supports-color: 8.1.1 optional: true jest-worker@29.7.0: dependencies: - '@types/node': 24.10.13 + '@types/node': 25.2.3 jest-util: 29.7.0 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -26358,7 +26364,7 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 - jsx-email@2.8.1(@jsx-email/plugin-inline@1.0.1)(@jsx-email/plugin-minify@1.0.2)(@jsx-email/plugin-pretty@1.0.0)(@types/node@24.10.1)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(jiti@2.6.1)(lightningcss@1.31.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(terser@5.46.0)(ts-node@10.9.2(@swc/core@1.13.5)(@swc/wasm@1.13.21)(@types/node@24.10.1)(typescript@5.9.3))(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.2): + jsx-email@2.8.1(@jsx-email/plugin-inline@1.0.1)(@jsx-email/plugin-minify@1.0.2)(@jsx-email/plugin-pretty@1.0.0)(@types/node@25.2.3)(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(jiti@2.6.1)(lightningcss@1.31.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(terser@5.46.0)(ts-node@10.9.2(@swc/core@1.13.5)(@swc/wasm@1.13.21)(@types/node@25.2.3)(typescript@5.9.3))(tsx@4.20.6)(typescript@5.9.3)(yaml@2.8.2): dependencies: '@dot/log': 0.1.5 '@jsx-email/doiuse-email': 1.0.4 @@ -26380,7 +26386,7 @@ snapshots: '@unocss/preset-wind': 0.65.4 '@unocss/transformer-compile-class': 0.65.4 '@unocss/transformer-variant-group': 0.65.4 - '@vitejs/plugin-react': 5.1.1(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2)) + '@vitejs/plugin-react': 5.1.1(vite@7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2)) autoprefixer: 10.4.21(postcss@8.5.6) chalk: 4.1.2 classnames: 2.5.1 @@ -26408,11 +26414,11 @@ snapshots: shiki: 1.29.2 source-map-support: 0.5.21 std-env: 3.9.0 - tailwindcss: 3.4.15(ts-node@10.9.2(@swc/core@1.13.5)(@swc/wasm@1.13.21)(@types/node@24.10.1)(typescript@5.9.3)) + tailwindcss: 3.4.15(ts-node@10.9.2(@swc/core@1.13.5)(@swc/wasm@1.13.21)(@types/node@25.2.3)(typescript@5.9.3)) titleize: 4.0.0 unist-util-visit: 5.0.0 valibot: 0.42.1(typescript@5.9.3) - vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2) + vite: 7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2) yargs-parser: 21.1.1 transitivePeerDependencies: - '@emotion/is-prop-valid' @@ -26453,10 +26459,10 @@ snapshots: kleur@4.1.5: {} - knip@5.70.1(@types/node@24.10.13)(typescript@5.9.3): + knip@5.70.1(@types/node@25.2.3)(typescript@5.9.3): dependencies: '@nodelib/fs.walk': 1.2.8 - '@types/node': 24.10.13 + '@types/node': 25.2.3 fast-glob: 3.3.3 formatly: 0.3.0 jiti: 2.6.1 @@ -28462,13 +28468,13 @@ snapshots: camelcase-css: 2.0.1 postcss: 8.5.6 - postcss-load-config@4.0.2(postcss@8.5.6)(ts-node@10.9.2(@swc/core@1.13.5)(@swc/wasm@1.13.21)(@types/node@24.10.1)(typescript@5.9.3)): + postcss-load-config@4.0.2(postcss@8.5.6)(ts-node@10.9.2(@swc/core@1.13.5)(@swc/wasm@1.13.21)(@types/node@25.2.3)(typescript@5.9.3)): dependencies: lilconfig: 3.1.3 yaml: 2.8.1 optionalDependencies: postcss: 8.5.6 - ts-node: 10.9.2(@swc/core@1.13.5)(@swc/wasm@1.13.21)(@types/node@24.10.1)(typescript@5.9.3) + ts-node: 10.9.2(@swc/core@1.13.5)(@swc/wasm@1.13.21)(@types/node@25.2.3)(typescript@5.9.3) postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.20.6)(yaml@2.8.2): dependencies: @@ -28740,7 +28746,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.0 - '@types/node': 24.10.1 + '@types/node': 25.2.3 long: 5.3.2 proxy-agent@6.5.0: @@ -30258,7 +30264,7 @@ snapshots: dependencies: tailwindcss: 4.1.17 - tailwindcss@3.4.15(ts-node@10.9.2(@swc/core@1.13.5)(@swc/wasm@1.13.21)(@types/node@24.10.1)(typescript@5.9.3)): + tailwindcss@3.4.15(ts-node@10.9.2(@swc/core@1.13.5)(@swc/wasm@1.13.21)(@types/node@25.2.3)(typescript@5.9.3)): dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -30277,7 +30283,7 @@ snapshots: postcss: 8.5.6 postcss-import: 15.1.0(postcss@8.5.6) postcss-js: 4.0.1(postcss@8.5.6) - postcss-load-config: 4.0.2(postcss@8.5.6)(ts-node@10.9.2(@swc/core@1.13.5)(@swc/wasm@1.13.21)(@types/node@24.10.1)(typescript@5.9.3)) + postcss-load-config: 4.0.2(postcss@8.5.6)(ts-node@10.9.2(@swc/core@1.13.5)(@swc/wasm@1.13.21)(@types/node@25.2.3)(typescript@5.9.3)) postcss-nested: 6.2.0(postcss@8.5.6) postcss-selector-parser: 6.1.2 resolve: 1.22.10 @@ -30514,36 +30520,14 @@ snapshots: '@ts-morph/common': 0.28.1 code-block-writer: 13.0.3 - ts-node@10.9.2(@swc/core@1.13.5)(@swc/wasm@1.13.21)(@types/node@24.10.1)(typescript@5.9.3): + ts-node@10.9.2(@swc/core@1.13.5)(@swc/wasm@1.13.21)(@types/node@25.2.3)(typescript@5.9.3): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.11 '@tsconfig/node12': 1.0.11 '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.4 - '@types/node': 24.10.1 - acorn: 8.15.0 - acorn-walk: 8.3.4 - arg: 4.1.3 - create-require: 1.1.1 - diff: 4.0.2 - make-error: 1.3.6 - typescript: 5.9.3 - v8-compile-cache-lib: 3.0.1 - yn: 3.1.1 - optionalDependencies: - '@swc/core': 1.13.5 - '@swc/wasm': 1.13.21 - optional: true - - ts-node@10.9.2(@swc/core@1.13.5)(@swc/wasm@1.13.21)(@types/node@24.10.13)(typescript@5.9.3): - dependencies: - '@cspotcode/source-map-support': 0.8.1 - '@tsconfig/node10': 1.0.11 - '@tsconfig/node12': 1.0.11 - '@tsconfig/node14': 1.0.3 - '@tsconfig/node16': 1.0.4 - '@types/node': 24.10.13 + '@types/node': 25.2.3 acorn: 8.15.0 acorn-walk: 8.3.4 arg: 4.1.3 @@ -30941,27 +30925,27 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vite-plugin-mkcert@1.17.9(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2)): + vite-plugin-mkcert@1.17.9(vite@7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2)): dependencies: axios: 1.13.2(debug@4.4.3) debug: 4.4.3 picocolors: 1.1.1 - vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2) + vite: 7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2) transitivePeerDependencies: - supports-color - vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2)): + vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2)): dependencies: debug: 4.4.1 globrex: 0.1.2 tsconfck: 3.1.6(typescript@5.9.3) optionalDependencies: - vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2) + vite: 7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2) transitivePeerDependencies: - supports-color - typescript - vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2): + vite@7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.3) @@ -30970,7 +30954,7 @@ snapshots: rollup: 4.49.0 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 24.10.1 + '@types/node': 25.2.3 fsevents: 2.3.3 jiti: 2.6.1 lightningcss: 1.31.1 @@ -30978,72 +30962,14 @@ snapshots: tsx: 4.20.6 yaml: 2.8.2 - vite@7.2.4(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2): - dependencies: - esbuild: 0.25.12 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - postcss: 8.5.6 - rollup: 4.49.0 - tinyglobby: 0.2.15 - optionalDependencies: - '@types/node': 24.10.13 - fsevents: 2.3.3 - jiti: 2.6.1 - lightningcss: 1.31.1 - terser: 5.46.0 - tsx: 4.20.6 - yaml: 2.8.2 - - vitefu@1.1.1(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2)): - optionalDependencies: - vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2) - - vitest@4.0.13(@opentelemetry/api@1.9.0)(@types/debug@4.1.12)(@types/node@24.10.1)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2): - dependencies: - '@vitest/expect': 4.0.13 - '@vitest/mocker': 4.0.13(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2)) - '@vitest/pretty-format': 4.0.13 - '@vitest/runner': 4.0.13 - '@vitest/snapshot': 4.0.13 - '@vitest/spy': 4.0.13 - '@vitest/utils': 4.0.13 - debug: 4.4.3 - es-module-lexer: 1.7.0 - expect-type: 1.2.2 - magic-string: 0.30.21 - pathe: 2.0.3 - picomatch: 4.0.3 - std-env: 3.10.0 - tinybench: 2.9.0 - tinyexec: 0.3.2 - tinyglobby: 0.2.15 - tinyrainbow: 3.0.3 - vite: 7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2) - why-is-node-running: 2.3.0 + vitefu@1.1.1(vite@7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2)): optionalDependencies: - '@opentelemetry/api': 1.9.0 - '@types/debug': 4.1.12 - '@types/node': 24.10.1 - jsdom: 26.1.0 - transitivePeerDependencies: - - jiti - - less - - lightningcss - - msw - - sass - - sass-embedded - - stylus - - sugarss - - supports-color - - terser - - tsx - - yaml + vite: 7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2) - vitest@4.0.13(@opentelemetry/api@1.9.0)(@types/debug@4.1.12)(@types/node@24.10.13)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2): + vitest@4.0.13(@opentelemetry/api@1.9.0)(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2): dependencies: '@vitest/expect': 4.0.13 - '@vitest/mocker': 4.0.13(vite@7.2.4(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2)) + '@vitest/mocker': 4.0.13(vite@7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2)) '@vitest/pretty-format': 4.0.13 '@vitest/runner': 4.0.13 '@vitest/snapshot': 4.0.13 @@ -31060,12 +30986,12 @@ snapshots: tinyexec: 0.3.2 tinyglobby: 0.2.15 tinyrainbow: 3.0.3 - vite: 7.2.4(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2) + vite: 7.2.4(@types/node@25.2.3)(jiti@2.6.1)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.0 '@types/debug': 4.1.12 - '@types/node': 24.10.13 + '@types/node': 25.2.3 jsdom: 26.1.0 transitivePeerDependencies: - jiti From 239f39a728c01dcc728836a14b6eeef97347897a Mon Sep 17 00:00:00 2001 From: ElasticBottle Date: Wed, 25 Feb 2026 07:55:59 +0800 Subject: [PATCH 2/9] chore: remove unused tools and workflows --- .cursor/rules/base-rules.mdc | 131 ---- .../-components/project-chat-panel.tsx | 372 +---------- apps/seo/src/server.ts | 1 - apps/seo/wrangler.jsonc | 15 - packages/api-seo/src/context.ts | 1 - .../api-seo/src/lib/ai/strategist-agent.ts | 229 ------- .../src/lib/ai/tools/create-article-tool.ts | 135 ---- .../lib/ai/tools/data-analysis-agent-tool.ts | 172 ------ .../api-seo/src/lib/ai/tools/file-tool.ts | 278 --------- .../api-seo/src/lib/ai/tools/planner-tools.ts | 38 +- .../src/lib/ai/tools/screenshot-script.ts | 140 ----- .../api-seo/src/lib/ai/tools/skill-tools.ts | 129 ---- .../src/lib/ai/tools/strategy-tools.ts | 58 -- .../api-seo/src/lib/ai/tools/todo-tool.ts | 176 ------ packages/api-seo/src/lib/ai/writer-agent.ts | 239 ------- packages/api-seo/src/lib/task.ts | 7 - packages/api-seo/src/routes/task.ts | 3 - packages/api-seo/src/types.ts | 10 +- packages/api-seo/src/workflows/index.ts | 4 - .../api-seo/src/workflows/planner-workflow.ts | 584 ------------------ packages/core/src/schemas/task-parsers.ts | 18 - pnpm-lock.yaml | 22 +- 22 files changed, 7 insertions(+), 2755 deletions(-) delete mode 100644 .cursor/rules/base-rules.mdc delete mode 100644 packages/api-seo/src/lib/ai/strategist-agent.ts delete mode 100644 packages/api-seo/src/lib/ai/tools/create-article-tool.ts delete mode 100644 packages/api-seo/src/lib/ai/tools/data-analysis-agent-tool.ts delete mode 100644 packages/api-seo/src/lib/ai/tools/file-tool.ts delete mode 100644 packages/api-seo/src/lib/ai/tools/screenshot-script.ts delete mode 100644 packages/api-seo/src/lib/ai/tools/skill-tools.ts delete mode 100644 packages/api-seo/src/lib/ai/tools/strategy-tools.ts delete mode 100644 packages/api-seo/src/lib/ai/tools/todo-tool.ts delete mode 100644 packages/api-seo/src/lib/ai/writer-agent.ts delete mode 100644 packages/api-seo/src/workflows/planner-workflow.ts diff --git a/.cursor/rules/base-rules.mdc b/.cursor/rules/base-rules.mdc deleted file mode 100644 index 32e2c1ef7..000000000 --- a/.cursor/rules/base-rules.mdc +++ /dev/null @@ -1,131 +0,0 @@ ---- -alwaysApply: true -globs: -description: General development ---- - -This file provides guidance when working with code in this repository. - -## Development Commands - -- `pnpm dev` - Start development server (requires Docker for PostgreSQL) -- `pnpm build` - Build all packages and apps -- `pnpm build:preview` - Build with preview environment (uses `.env`) -- `pnpm build:production` - Build for production (uses `.env.production`) -- `pnpm lint` - Run Biome linting across all workspaces -- `pnpm typecheck` - Run TypeScript type checking across all workspaces -- `pnpm format` - Format code using Biome -- `pnpm check` - Run Biome check with auto-fix - -## Database Commands - -- `pnpm db:push` - Push schema changes to database (force) -- `pnpm db:migrate-generate` - Generate migration files -- `pnpm db:migrate-push` - Apply migrations to database -- `pnpm db:studio` - Open Drizzle Studio for database management - -## Testing - -Run individual package tests with: - -```bash -pnpm run --filter test -``` - -## Environment Management - -Environment variables are encrypted using dotenvx: - -- `.env.production` - Production builds -- `.env` - Local development and preview builds -- `.env.local` - Local-only settings (git-ignored) - -Set variables with: `pnpm env:set ` -Use `-f` flag to target specific env file: `pnpm env:set -f .env.local` - -## Architecture Overview - -This is a TypeScript monorepo using Turborepo with the following structure: - -### Core Packages - -- **`packages/api`** - ORPC-based API server with OpenAPI support - - Uses ArkType for schema validation - - Authentication via passkeys - - Todo CRUD operations example - - Located at `packages/api/src/server.ts` - -- **`packages/db`** - Database layer using Drizzle ORM - - PostgreSQL with snake_case column naming - - User and credential schemas for authentication - - Factory function `createDb()` - - Provides `_helpers.ts` to manage timestamps - -- **`packages/ui`** - Shared UI components - - Shadcn/ui components - - Custom chat components - - Tailwind CSS styling - - Theme provider for dark/light modes - -### Applications - -- **`apps/www`** - Main web application - - TanStack Start (React) frontend - - Vite build system with HTTPS dev server (port 6969) - - Authentication routes with passkey support - - ORPC client integration - -### Tooling - -- **Biome** - Formatting, linting, and import organization -- **Turborepo** - Monorepo build orchestration with caching -- **pnpm** - Package management with workspaces -- **TypeScript** - Strict configuration with ES2024 target -- **Docker Compose** - PostgreSQL development database - -## First-Time Setup - -1. Ensure Docker is running -2. `docker compose up -d` (start PostgreSQL) -3. `pnpm db:push` (initialize database schema) -4. `pnpm dev` (start development - may need to accept HTTPS certificates) -5. Visit `https://localhost:6969` - -## Package Generation - -Preferred for AI/non-interactive: use `--args` to bypass prompts. - -- Argument order: - - `name`, `type`, `features` - -- Features: - - Comma-separated list from: `docs`, `env`, `react`, `styles` (order doesn't matter) - - Use an empty string `""` for no features - -- Examples: - - Public with no features: - - `pnpm new:package --args "my-lib" "public" ""` - - Public with docs only: - - `pnpm new:package --args "my-lib" "public" "docs"` - - Private with docs, env, React UI, and styles: - - `pnpm new:package --args "my-private-lib" "private" "docs,env,react,styles"` - - Private with env only: - - `pnpm new:package --args "my-svc" "private" "env"` - - Private with env and React UI (no docs, no styles): - - `pnpm new:package --args "my-svc" "private" "env,react"` - -Interactive (for humans): `pnpm new:package` and answer prompts. The scope `@rectangular-labs/` in the name is optional and normalized. - -Behavioral notes: - -- Public packages get `tsup.config.ts`; private do not. -- Dependencies are auto-normalized to latest (workspace deps remain `workspace:*`). -- After scaffolding, install, format, and lint run automatically. - -## Code Conventions - -- React hooks must be at component top level -- Avoid enums, prefer union types -- After generating code, you MUST make sure that the linter and formatter is happy by running the linter and formatter. -- NEVER use `as any` or casting generally unless specifically asked to. If you need type either use `satisfies` or type the object directly as needed - e.g. `const test:number = 3`. ASK FOR HELP, when you're lost and tempted to start using type casts. -- When using the database object ALWAYS prefer using the `.query` function over the `.select().from()` syntax diff --git a/apps/seo/src/routes/_authed/$organizationSlug/-components/project-chat-panel.tsx b/apps/seo/src/routes/_authed/$organizationSlug/-components/project-chat-panel.tsx index 6d933ca3c..29a4f827b 100644 --- a/apps/seo/src/routes/_authed/$organizationSlug/-components/project-chat-panel.tsx +++ b/apps/seo/src/routes/_authed/$organizationSlug/-components/project-chat-panel.tsx @@ -32,18 +32,6 @@ import { PromptInputTextarea, PromptInputTools, } from "@rectangular-labs/ui/components/ai-elements/prompt-input"; -import { - Queue, - QueueItem, - QueueItemContent, - QueueItemDescription, - QueueItemIndicator, - QueueList, - QueueSection, - QueueSectionContent, - QueueSectionLabel, - QueueSectionTrigger, -} from "@rectangular-labs/ui/components/ai-elements/queue"; import { Reasoning, ReasoningContent, @@ -61,13 +49,7 @@ import { Suggestion, Suggestions, } from "@rectangular-labs/ui/components/ai-elements/suggestion"; -import { - Task, - TaskContent, - TaskItem, - TaskItemFile, - TaskTrigger, -} from "@rectangular-labs/ui/components/ai-elements/task"; +import { TaskItemFile } from "@rectangular-labs/ui/components/ai-elements/task"; import { Copy, File, @@ -79,12 +61,6 @@ import { } from "@rectangular-labs/ui/components/icon"; import { Button } from "@rectangular-labs/ui/components/ui/button"; import { Checkbox } from "@rectangular-labs/ui/components/ui/checkbox"; -import { - DialogDrawer, - DialogDrawerFooter, - DialogDrawerHeader, - DialogDrawerTitle, -} from "@rectangular-labs/ui/components/ui/dialog-drawer"; import { DropDrawer, DropDrawerContent, @@ -112,11 +88,10 @@ import { } from "@rectangular-labs/ui/components/ui/tooltip"; import { useIsApple } from "@rectangular-labs/ui/hooks/use-apple"; import { cn } from "@rectangular-labs/ui/utils/cn"; -import { useQueryClient } from "@tanstack/react-query"; import { useLocation } from "@tanstack/react-router"; import { useEffect, useMemo, useRef, useState } from "react"; import { Fragment } from "react/jsx-runtime"; -import { getApiClient, getApiClientRq } from "~/lib/api"; +import { getApiClient } from "~/lib/api"; import { useProjectChat } from "./project-chat-provider"; type ChatMessagePart = SeoChatMessage["parts"][number]; @@ -323,125 +298,6 @@ function AskQuestionsToolPart({ ); } -function CreatePlanToolPart({ - part, - onApprove, - onReject, -}: { - part: ChatToolPart; - onApprove: () => void; - onReject: () => void; -}) { - const [open, setOpen] = useState(false); - if ( - part.type !== "tool-create_plan" || - part.state === "input-streaming" || - part.state === "output-error" - ) - return null; - const plan = part.input; - const title = plan.name?.trim() || "Proposed plan"; - const overview = plan.overview?.trim(); - const markdown = plan.plan ?? ""; - - return ( -
- -
-
-
{title}
- {overview ? ( -
- {overview} -
- ) : ( -
- Click to view details -
- )} -
-
- {part.state === "output-available" ? "Ready" : "Running"} -
-
- - } - > - - {title} - {overview ? ( -
{overview}
- ) : null} -
- -
- {markdown} -
- - - - - -
- -
- - -
-
- ); -} - -type TodoSnapshot = Extract< - Extract, - { state: `output-available` } ->["output"]["todos"]; function inferCurrentPage(pathname: string): ProjectChatCurrentPage { if (pathname.includes("/content")) return "content-list"; if (pathname.includes("/settings")) return "settings"; @@ -470,11 +326,6 @@ function ChatConversation({ const { pathname } = useLocation(); const currentPage = inferCurrentPage(pathname); - const queryClient = useQueryClient(); - const todoSnapshotSetRef = useRef>(new Set()); - const [todoSnapshot, setTodoSnapshot] = useState>( - [], - ); const textareaRef = useRef(null); const chatIdRef = useRef(chatId); @@ -523,45 +374,8 @@ function ChatConversation({ if (!chatId) { setMessages([]); } - setTodoSnapshot([]); - todoSnapshotSetRef.current.clear(); }, [chatId, setMessages]); - useEffect(() => { - for (const message of messages) { - for (const part of message.parts) { - if ( - part.type === "tool-use_skills" && - part.state === "output-available" && - (part.input.skill === "create_articles" || - part.input.skill === "write_file") - ) { - void queryClient.invalidateQueries({ - queryKey: getApiClientRq().content.list.key({ - type: "query", - }), - }); - } - - if ( - part.type === "tool-manage_todo" && - part.state === "output-available" && - !todoSnapshotSetRef.current.has(part.toolCallId) - ) { - todoSnapshotSetRef.current.add(part.toolCallId); - if (part.output.todos) { - setTodoSnapshot(part.output.todos); - } - } - } - } - }, [messages, queryClient]); - - const rejectPlanPrefill = () => { - setInput("Let's change the following:\n1. "); - textareaRef.current?.focus(); - }; - const handleSuggestion = (suggestion: string) => { if (isMessagesLoading) return; handleSubmit({ text: suggestion }); @@ -583,10 +397,6 @@ function ChatConversation({ } }; - const openTodos = useMemo(() => { - return todoSnapshot.filter((t) => t.status === "open"); - }, [todoSnapshot]); - const isInputDisabled = isMessagesLoading; const showMessagesLoading = isMessagesLoading && messages.length === 0; @@ -687,152 +497,6 @@ function ChatConversation({ ); } - case "tool-create_plan": { - if (part.state === "output-available") { - return ( - - handleSubmit({ - text: "plan looks good, let's start.", - }) - } - onReject={() => rejectPlanPrefill()} - part={part} - /> - ); - } - return Creating plan; - } - case "tool-manage_todo": { - if (part.state === "input-streaming") { - return ( - - Managing tasks - - ); - } - - if (part.state === "input-available") { - const { action } = part.input; - const actionLabel = (() => { - switch (action) { - case "create": - return "Creating task"; - case "update": - return "Updating task"; - case "list": - return "Listing tasks"; - } - })(); - return ( - - {actionLabel} - - ); - } - - if (part.state === "output-available") { - const { action } = part.input; - const actionLabel = (() => { - switch (action) { - case "create": - return `Creating task ${part.input.todo?.title}`; - case "update": - return `Updating task`; - case "list": - return "Listed tasks"; - } - })(); - return ( -
- {actionLabel} -
- ); - } - return ( -
- Something went wrong managing tasks -
- ); - } - case "tool-read_skills": { - if (part.state === "output-available") { - const skillName = part.input.skill.replaceAll("_", " "); - return ( -
- Done reading skill {skillName}. -
- ); - } - return ( - - Reading skill... - - ); - } - - case "tool-use_skills": { - const taskName = - part.input?.taskName?.trim() || "Executing Task"; - const state = (() => { - switch (part.state) { - case "output-available": { - const result = (() => { - if (part.output?.success) { - return part.output?.result ?? "Completed"; - } - return "Failed"; - })(); - return result; - } - case "output-error": - return part.errorText; - default: - return part.input?.instructions ?? "Running"; - } - })(); - const isRunning = - part.state !== "output-available" && - part.state !== "output-error"; - return ( - - {isRunning ? ( - - {taskName} - - ) : ( - - )} - - {state} - - - ); - } - default: { return null; } @@ -861,38 +525,6 @@ function ChatConversation({ /> )} - {openTodos.length > 0 && ( -
- - - - - - - - {openTodos.map((t) => ( - -
- - {t.title} -
- {t.notes ? ( - - {t.notes} - - ) : null} -
- ))} -
-
-
-
-
- )} - ', - `User is currently on: ${currentPage}`, - "", - ...focus, - "", - ].join("\n"); -} - -/** -What to deliver: -- Improving existing content: - - Use google_search_console_query across time ranges to identify decaying pages/queries; propose specific refresh actions, target queries, and expected impact. - - Find pages with weak CTR vs position; propose improved titles/meta, angle changes, and SERP feature optimizations (rich results, Frequently Asked Questions, etc.). -- Creating new content: - - Build a data-driven keyword universe with get_keyword_suggestions and get_keywords_overview; group by intent and funnel stage; output a prioritized content plan with working titles, target queries, angle, format, and internal links. - - Map the end-to-end user journey; recommend content to cover each step. Ask for missing journey details if needed. - - Perform competitor analysis using get_ranked_keywords_for_site and get_ranked_pages_for_site on competitor hostnames to uncover opportunities and quick wins. - - Create Q&A style content to answer common user questions; list Frequently Asked Questions and provide brief outlines. -- Highlighting opportunities: - - Suggest guerrilla marketing and distribution tactics (e.g., targeted Reddit threads, X posts, community forums, PR/backlinks, features) with concrete next steps and candidate targets. - - Recommend internal linking, schema, and technical quick wins where relevant. - -Output requirements: -- Be concise but actionable; use bullet points and short sections. -- When more data is needed, ask clearly for what you need. -- Prefer specific, measurable recommendations with expected impact. -- If proposing edits to existing content, describe them clearly - */ - -export async function createStrategistAgent({ - messages, - gscProperty, - project, - context, - currentPage, -}: { - messages: SeoChatMessage[]; - gscProperty: ChatContext["cache"]["gscProperty"]; - project: NonNullable; - context: ChatContext; - currentPage: ProjectChatCurrentPage; -}): Promise[0]> { - const hasGsc = !!( - gscProperty?.accessToken && - gscProperty?.config.domain && - gscProperty?.config.propertyType - ); - - const utcDate = new Intl.DateTimeFormat("en-US", { - timeZone: "UTC", - year: "numeric", - month: "long", - day: "numeric", - }).format(new Date()); - - const plannerTools = createPlannerToolsWithMetadata(); - const todoTools = createTodoToolWithMetadata({ messages }); - - const settingsTools = createSettingsToolsWithMetadata({ - context: { - db: context.db, - projectId: project.id, - organizationId: project.organizationId, - }, - }); - const fileTools = createFileToolsWithMetadata({ - userId: context.userId, - db: context.db, - organizationId: project.organizationId, - projectId: project.id, - chatId: context.chatId, - }); - const readOnlyFileToolDefinitions = fileTools.toolDefinitions.filter( - (tool) => tool.toolName === "ls" || tool.toolName === "cat", - ); - - const webTools = createWebToolsWithMetadata(project, context.cacheKV); - const gscTools = createGscToolWithMetadata({ - accessToken: gscProperty?.accessToken ?? null, - siteUrl: gscProperty?.config.domain ?? null, - siteType: gscProperty?.config.propertyType ?? null, - }); - const dataforseoTools = createDataforseoToolWithMetadata( - project, - context.cacheKV, - ); - const createArticleTool = createCreateArticleToolWithMetadata({ - userId: context.userId, - project, - context, - }); - const strategyTools = createStrategyToolsWithMetadata({ - db: context.db, - projectId: project.id, - organizationId: project.organizationId, - }); - - const skillDefinitions: AgentToolDefinition[] = [ - ...settingsTools.toolDefinitions, - ...webTools.toolDefinitions, - ...gscTools.toolDefinitions, - ...dataforseoTools.toolDefinitions, - ...createArticleTool.toolDefinitions, - ...strategyTools.toolDefinitions, - ...readOnlyFileToolDefinitions, - ]; - const skillsSection = formatToolSkillsSection(skillDefinitions); - - const systemPrompt = ` -You are an expert SEO/GEO strategist and planner. - -Your job is to help the user create a coherent, prioritized content map with clear -topical structure and ontology. You do this by helping the user understand their site's stats, manage project settings to optimize their site for SEO, and build a concrete content plan with high-quality article suggestions based on SERP, site, and keyword data. - -You have two power tools \`read_skills\` and \`use_skills\`. Prefer using them instead of guessing. You make sure to use the \`read_skills\` tool before using the \`use_skills\` tool to understand how to use the skill properly and take note of any specific instructions that the skill requires. - -You ALWAYS make use of the \`manage_todo\` tool to track your work and keep the todo list current. - -Ontology SEO constraint: -- When suggesting new articles, make sure that it is nested in appropriate ontological subfolders in a way groups related topics together so that we can build relevant topical authority appropriately. - - - -1. Understand: Restate the user's ask in 1-2 sentences; list assumptions that might change the answer. -2. Clarify: Ask targeted questions to clarify the intended behavior as needed before -making any plans or executing any tasks. Dig deep and make sure you understand before beginning analysis. If things pop up mid way through analysis, STOP your analysis and clarify. -3. Diagnose: When discussing performance, focus on what the stats imply (position vs CTR vs impressions vs clicks), what's likely causing it, and what to test next. -4. Plan: Propose a prioritized plan (clusters, parent/child pages, intent mapping), then produce concrete article suggestions (primary keyword + slug). Use as many tools as required to come up with a clear scope of plan. You can formalize your plans at any time using the \`create_plan\` tool if it's big and you want to user to make sure it's correct before executing on it. -5. Execute: Use tools (GSC, SERP/keyword data, web) to ground recommendations. Link claims to source URLs when using web_search/web_fetch. -6. Track work: Use \`manage_todo\` tool to add tasks, mark tasks done, and keep the todo list current. Keep them atomic and execution-oriented. Make sure that the list reflects the current state of things at all times. - - - -${skillsSection} - - - -- Today's date: ${utcDate} (UTC timezone) -- Website: ${project.websiteUrl}${formatBusinessBackground(project.businessBackground)} -- Project name: ${project.name ?? "(none)"} -- Google Search Console: ${ - hasGsc ? `Connected (${gscProperty?.config.domain})` : "Not connected" - } -- Guidance: - - If GSC is not connected and the user asks for performance/decay/CTR, prioritize connecting via manage_integrations. - - When using web_search/web_fetch, use claims as the anchor text to the source URLs. -`; - - return { - model: openai("gpt-5.2"), - providerOptions: { - openai: { - reasoningEffort: "medium", - } satisfies OpenAIResponsesProviderOptions, - }, - system: systemPrompt, - messages: await convertToModelMessages(messages), - tools: { - ...createSkillTools({ toolDefinitions: skillDefinitions }), - ...plannerTools.tools, - ...todoTools.tools, - }, - prepareStep: ({ messages }) => { - return { - messages: [ - ...messages, - { - role: "system", - content: formatCurrentPageFocusReminder(currentPage), - }, - { - role: "system", - content: formatTodoFocusReminder({ - todos: todoTools.getSnapshot(), - maxOpen: 5, - }), - }, - ], - }; - }, - } satisfies Parameters[0]; -} diff --git a/packages/api-seo/src/lib/ai/tools/create-article-tool.ts b/packages/api-seo/src/lib/ai/tools/create-article-tool.ts deleted file mode 100644 index e47e20eca..000000000 --- a/packages/api-seo/src/lib/ai/tools/create-article-tool.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { articleTypeSchema } from "@rectangular-labs/core/schemas/content-parsers"; -import type { DB, schema } from "@rectangular-labs/db"; -import { tool } from "ai"; -import { type } from "arktype"; -import { normalizeContentSlug } from "../../content/normalize-content-slug"; -import { writeContentDraft } from "../../content/write-content-draft"; -import type { AgentToolDefinition } from "./utils"; - -const createArticleInputSchema = type({ - primaryKeyword: type("string").describe( - "Primary keyword the article targets (required).", - ), - slug: type("string").describe( - "SEO/GEO optimized slug for the targeted primary keyword. Must start with '/'. e.g. /business/how-to-start-a-business.", - ), - "title?": type("string|null").describe( - "Article title should only be present if explicitly provided by the user or confirmed by the user in response to your suggestion.", - ), - "description?": type("string|null").describe( - "Article description should only be present if explicitly provided by the user or confirmed by the user in response to your suggestion.", - ), - "outline?": type("string|null").describe( - "Article outline should only be present if explicitly provided by the user or worked on together with the user. This should be a list of H2/H3 headings that the article will cover along with any additional context or notes for each section.", - ), - "articleType?": articleTypeSchema - .or(type.null) - .describe( - "Article type should only be present IF explicitly provided by the user or confirmed by the user in response to your suggestion. pick only a SINGLE category and conform to the schema", - ), - "notes?": type("string|null").describe( - "Notes or guidance on what the user wants to focus on in the article/section/topic. No need to be provided if we already received/worked on an outline together with the user.", - ), -}); - -export function createCreateArticleToolWithMetadata({ - userId, - project, - context, -}: { - userId: string; - project: Pick< - typeof schema.seoProject.$inferSelect, - "publishingSettings" | "id" | "organizationId" - >; - context: { - db: DB; - chatId: string | null; - }; -}) { - const createArticle = tool({ - description: - "Create a new article draft for a primary keyword, optionally including title, description, outline, article type, and notes. Use this when the user wants to create a new article.", - inputSchema: createArticleInputSchema, - async execute({ - primaryKeyword, - slug: rawSlug, - title, - description, - outline, - articleType, - }) { - const keyword = primaryKeyword.trim(); - if (!keyword) { - return { success: false, message: "primaryKeyword is required." }; - } - - if (!rawSlug.startsWith("/")) { - return { success: false, message: "slug must start with '/'." }; - } - - // Normalize slug to kebab-case (lowercase, hyphens) - const slug = normalizeContentSlug(rawSlug); - - const status = project.publishingSettings?.requireSuggestionReview - ? "suggested" - : "queued"; - - const validArticleType = articleType - ? articleTypeSchema(articleType) - : null; - if (validArticleType instanceof type.errors) { - return { success: false, message: validArticleType.summary }; - } - - const writeResult = await writeContentDraft({ - db: context.db, - userId, - projectId: project.id, - organizationId: project.organizationId, - chatId: context.chatId, - lookup: { type: "slug", slug, primaryKeyword: keyword }, - draftNewValues: { - slug, - status, - primaryKeyword: keyword, - title: title?.trim(), - description: description?.trim(), - outline: outline?.trim(), - articleType, - }, - }); - - if (!writeResult.ok) { - return { success: false, message: writeResult.error.message }; - } - - return { - success: true, - message: "Article draft created.", - slug, - status, - primaryKeyword, - articleType, - }; - }, - }); - - const tools = { create_article: createArticle } as const; - const toolDefinitions: AgentToolDefinition[] = [ - { - toolName: "create_article", - toolDescription: - "Create a new article draft for a primary keyword and SEO/GEO optimized slug (optionally with title, description, outline, articleType, and notes).", - toolInstruction: [ - "Provide at least primaryKeyword and SEO/GEO optimized slug. Provide title/description/outline/articleType if the user explicitly provided them (or confirmed your suggestion).", - "If the user only gave guidance without confirming fields, put those pointers in notes.", - "If only primaryKeyword and slug are available, pass just that and let the tool fill the rest.", - "This tool creates a draft and triggers planner or writer workflows based on project publishing settings.", - ].join("\n"), - tool: createArticle, - }, - ]; - - return { toolDefinitions, tools }; -} diff --git a/packages/api-seo/src/lib/ai/tools/data-analysis-agent-tool.ts b/packages/api-seo/src/lib/ai/tools/data-analysis-agent-tool.ts deleted file mode 100644 index 4f83838cf..000000000 --- a/packages/api-seo/src/lib/ai/tools/data-analysis-agent-tool.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { openai } from "@ai-sdk/openai"; -import { NO_SEARCH_CONSOLE_ERROR_MESSAGE } from "@rectangular-labs/core/schemas/gsc-property-parsers"; -import type { GscConfig } from "@rectangular-labs/core/schemas/integration-parsers"; -import type { schema } from "@rectangular-labs/db"; -import { generateText, tool } from "ai"; -import { type } from "arktype"; -import type { InitialContext } from "../../../types"; -import { createDataforseoToolWithMetadata } from "./dataforseo-tool"; -import { createGscToolWithMetadata } from "./google-search-console-tool"; -import type { AgentToolDefinition } from "./utils"; - -const dataAnalysisAgentInputSchema = type({ - question: "string", -}); - -export function createDataAnalysisAgentToolWithMetadata({ - project, - gscProperty, - cacheKV, -}: { - project: typeof schema.seoProject.$inferSelect; - gscProperty: { - config: GscConfig; - accessToken?: string | null; - } | null; - cacheKV: InitialContext["cacheKV"]; -}) { - const hasGsc = !!( - gscProperty?.accessToken && - gscProperty?.config.domain && - gscProperty?.config.propertyType - ); - - const systemPrompt = `You are a specialized SEO data analysis agent. Your role is to analyze SEO performance data using Google Search Console and keyword research data source tools to provide actionable insights. - -${ - hasGsc - ? `## Google Search Console Available - -You have access to Google Search Console data for ${gscProperty.config.domain}. - -### Required Analysis Workflow: - -1. **Historical Performance Comparison**: - - Query GSC for the last ${28} days vs the previous ${28} days - - Use dimensions: ["query", "page"] to identify trends - - Look for: declining clicks, impressions, or positions (decay detection) - - Identify pages/queries with weak CTR relative to position - -2. **CTR Optimization Opportunities**: - - Find pages ranking in positions 4-10 with CTR below expected benchmarks - - Compare CTR by position to identify underperforming pages - - Recommend title/meta description improvements - -3. **Content Decay Detection**: - - Compare current period vs previous period - - Identify pages with significant drops in clicks/impressions - - Flag potential cannibalization (multiple pages ranking for same queries) - -4. **Supplement with keyword research data source tools**: - - Use get_ranked_keywords_for_site to see what keywords the site ranks for - - Use get_ranked_pages_for_site to see top-performing pages - - Use get_keyword_suggestions to expand keyword universe - - Use get_serp_for_keyword to analyze SERP competition - -### Analysis Output Format: -- Summarize key findings with specific metrics -- Identify top 3-5 opportunities with expected impact -- Provide concrete next steps for each opportunity` - : `## Google Search Console NOT Connected - -**CRITICAL**: The project does not have a Google Search Console property connected. - -**You CANNOT perform historical performance analysis, CTR analysis, or decay detection without GSC data.** - -### What You Should Do: -1. **Immediately inform the user** that GSC connection is required for site performance analysis -2. **Call the manage_google_search_property tool** to initiate the connection process -3. **Do NOT attempt** to analyze the user's site performance using only keyword research data source tools -4. You can still: - - Perform competitor analysis using get_ranked_keywords_for_site and get_ranked_pages_for_site - - Research keyword opportunities using get_keyword_suggestions - - Analyze SERP competition using get_serp_for_keyword - - But you MUST state clearly that site-specific performance analysis requires GSC connection - -### Error Message to Reference: -${NO_SEARCH_CONSOLE_ERROR_MESSAGE}` -} - -## Project Context: -- Website: ${project.websiteUrl} -- Business Background: ${ - project.businessBackground - ? JSON.stringify(project.businessBackground) - : "(none provided)" - } - -## Instructions: -- Answer the user's question using available tools -- Be data-driven and cite specific metrics -- If GSC is not connected, prioritize connecting it before attempting site analysis -- Provide actionable recommendations with expected impact`; - - const seoDataAnalysisAgent = tool({ - description: - "Run SEO data analysis using Google Search Console and keyword research data source tools. This agent specializes in analyzing historical performance, CTR optimization, content decay, and keyword opportunities. If Google Search Console is not connected, it will guide you to connect it first.", - inputSchema: dataAnalysisAgentInputSchema, - async execute({ question }) { - const result = await generateText({ - model: openai("gpt-5.1"), - system: systemPrompt, - messages: [ - { - role: "user", - content: question, - }, - ], - tools: { - ...createGscToolWithMetadata({ - accessToken: gscProperty?.accessToken ?? null, - siteUrl: gscProperty?.config.domain ?? null, - siteType: gscProperty?.config.propertyType ?? null, - }).tools, - ...createDataforseoToolWithMetadata(project, cacheKV).tools, - web_search: openai.tools.webSearch({ - externalWebAccess: true, - searchContextSize: "medium", - userLocation: { - type: "approximate", - city: "San Francisco", - region: "California", - }, - }), - }, - onStepFinish: (step) => { - console.log("data analysis agent step", step); - }, - }); - - return { - analysis: result.text, - toolCalls: result.toolCalls, - toolResults: result.toolResults, - }; - }, - }); - - const tools = { seo_data_analysis_agent: seoDataAnalysisAgent } as const; - const toolDefinitions: AgentToolDefinition[] = [ - { - toolName: "seo_data_analysis_agent", - toolDescription: - "Specialized sub-agent for SEO performance analysis (GSC + keyword research data source).", - toolInstruction: - "Provide a concrete question and desired timeframe. Use for deep analysis: CTR opportunities, content decay, query/page pivots, and prioritization. If GSC isn’t connected, it will guide connection first.", - tool: seoDataAnalysisAgent, - }, - ]; - - return { toolDefinitions, tools }; -} - -export function createDataAnalysisAgentTool(args: { - project: typeof schema.seoProject.$inferSelect; - gscProperty: { - config: GscConfig; - accessToken?: string | null; - } | null; - cacheKV: InitialContext["cacheKV"]; -}): ReturnType["tools"] { - return createDataAnalysisAgentToolWithMetadata(args).tools; -} diff --git a/packages/api-seo/src/lib/ai/tools/file-tool.ts b/packages/api-seo/src/lib/ai/tools/file-tool.ts deleted file mode 100644 index 5b6fbfc20..000000000 --- a/packages/api-seo/src/lib/ai/tools/file-tool.ts +++ /dev/null @@ -1,278 +0,0 @@ -import { - articleTypeSchema, - contentStatusSchema, -} from "@rectangular-labs/core/schemas/content-parsers"; -import type { DB } from "@rectangular-labs/db"; -import { tool } from "ai"; -import { type } from "arktype"; -import { - deleteDraftForSlug, - getContentForSlug, - listContentTree, - normalizeContentSlug, - sliceContentLines, -} from "../../content"; -import { - type WriteContentDraftArgs, - writeContentDraft, -} from "../../content/write-content-draft"; -import type { AgentToolDefinition } from "./utils"; - -const lsInputSchema = type({ - slug: type("string").describe( - "The slug of the directory to list (e.g. '/').", - ), -}); - -const catInputSchema = type({ - slug: type("string").describe("The slug of the file to read."), - "startLine?": type("number|null").describe( - "The line number to start at. If not provided, startLine will default to 1.", - ), - "endLine?": type("number|null").describe( - "The line number to end at. If not provided, endLine will default to the last line of the file.", - ), -}); - -const rmInputSchema = type({ - slug: type("string").describe("The slug of the file to delete."), -}); - -const _mvInputSchema = type({ - fromSlug: type("string").describe("The slug of the file/directory to move."), - toSlug: type("string").describe("The destination slug."), -}); - -const writeFileInputSchema = type({ - "id?": type("string|null").describe("The ID of the draft to update."), - "slug?": type("string|null").describe( - "The slug of the file to update (required when creating a new file).", - ), - "primaryKeyword?": type("string").describe( - "The primary keyword for the file (required when creating a new file).", - ), - "contentMarkdown?": type("string|null").describe( - "The Markdown content for the file.", - ), - "title?": type("string|null").describe("The title of the file."), - "description?": type("string|null").describe( - "The meta description for the file.", - ), - "notes?": type("string|null").describe("Internal notes about the file."), - "outline?": type("string|null").describe("The outline for the file."), - "articleType?": articleTypeSchema - .or(type.null) - .describe("The article type (if setting)."), - "status?": contentStatusSchema - .or(type.null) - .describe("The draft status (e.g. 'writing', 'pending-review', etc.)."), -}); - -export function createFileToolsWithMetadata(args: { - db: DB; - organizationId: string; - projectId: string; - userId: string | undefined; - chatId: string | null; -}) { - const ls = tool({ - description: - "List files and directories in the virtual workspace filesystem, similar to `ls`.", - inputSchema: lsInputSchema, - async execute({ slug }) { - return await listContentTree({ - db: args.db, - organizationId: args.organizationId, - projectId: args.projectId, - originatingChatId: args.chatId, - slug, - }); - }, - }); - - const cat = tool({ - description: - "Read the contents of a file in the virtual workspace filesystem, similar to `cat` (supports line ranges).", - inputSchema: catInputSchema, - async execute({ slug, startLine, endLine }) { - const result = await getContentForSlug({ - db: args.db, - organizationId: args.organizationId, - projectId: args.projectId, - slug: normalizeContentSlug(slug), - withContent: true, - }); - if (!result.ok) { - return { - success: false, - message: result.error.message, - }; - } - - const content = result.value.data.content.contentMarkdown ?? ""; - const sliced = sliceContentLines({ - content, - startLine: startLine ?? undefined, - endLine: endLine ?? undefined, - }); - if (!sliced.ok) { - return { success: false, message: sliced.error.message }; - } - return { success: true, data: sliced.value.text }; - }, - }); - - const rm = tool({ - description: - "Delete a file in the virtual workspace filesystem, similar to `rm`.", - inputSchema: rmInputSchema, - async execute({ slug }) { - const result = await deleteDraftForSlug({ - db: args.db, - organizationId: args.organizationId, - projectId: args.projectId, - slug: normalizeContentSlug(slug), - }); - if (!result.ok) { - return { success: false, message: result.error.message }; - } - return { success: true, message: "File or directory deleted." }; - }, - }); - - // const mv = tool({ - // description: - // "Move or rename a file or directory in the virtual workspace filesystem, similar to `mv`.", - // inputSchema: mvInputSchema, - // async execute({ fromSlug, toSlug }) { - // const from = normalizeContentSlug(fromSlug); - // const to = normalizeContentSlug(toSlug); - // if (!from || !to) { - // return { success: false, message: "Invalid slugs." }; - // } - // return await moveDraftSlug({ - // db: args.db, - // organizationId: args.organizationId, - // projectId: args.projectId, - // fromSlug: from, - // toSlug: to, - // originatingChatId: args.chatId ?? null, - // userId: args.userId ?? null, - // }); - // }, - // }); - - const writeFile = tool({ - description: - "Create or update a file in the virtual workspace filesystem by slug (content, title, description, notes, outline, etc.).", - inputSchema: writeFileInputSchema, - async execute({ - id, - slug, - contentMarkdown, - primaryKeyword, - title, - description, - notes, - outline, - articleType, - status, - }) { - if (!id && !slug && !primaryKeyword) { - return { - success: false, - message: - "slug or primaryKeyword is required to create a new file. If trying to update an existing file, provide the file ID.", - }; - } - const lookup = id - ? { type: "id" as const, id } - : { - type: "slug" as const, - slug: slug ?? "", - primaryKeyword: primaryKeyword ?? undefined, - }; - - const result = await writeContentDraft({ - db: args.db, - chatId: args.chatId ?? null, - userId: args.userId ?? null, - projectId: args.projectId, - organizationId: args.organizationId, - // cast to keep ts happy - lookup: lookup as Extract< - WriteContentDraftArgs["lookup"], - { type: "slug" } - >, - draftNewValues: { - slug: slug ?? undefined, - ...(primaryKeyword != null ? { primaryKeyword } : {}), - ...(contentMarkdown != null ? { contentMarkdown } : {}), - ...(title != null ? { title } : {}), - ...(description != null ? { description } : {}), - ...(notes != null ? { notes } : {}), - ...(outline != null ? { outline } : {}), - ...(articleType != null ? { articleType } : {}), - ...(status != null ? { status } : {}), - }, - }); - if (!result.ok) { - return { success: false, message: result.error.message }; - } - return { - success: true, - message: "File updated.", - draft: result.value.draft, - }; - }, - }); - - const tools = { - ls, - cat, - rm, - // mv, - write_file: writeFile, - } as const; - - const toolDefinitions: AgentToolDefinition[] = [ - { - toolName: "ls", - toolDescription: "List files/directories in the chat workspace.", - toolInstruction: - "Provide a slug prefix to list (e.g. '/' or '/finance/guides'). Use to discover existing content before editing.", - tool: ls, - }, - { - toolName: "cat", - toolDescription: "Read a file from the chat workspace.", - toolInstruction: - "Provide a file slug (e.g. '/finance/guides/how-to-budget') plus optional startLine/endLine. Use to fetch existing content before proposing changes.", - tool: cat, - }, - { - toolName: "rm", - toolDescription: "Delete a file or directory in the chat workspace.", - toolInstruction: - "Provide a slug. Prefer to ask before destructive deletes unless the user explicitly requested.", - tool: rm, - }, - // { - // toolName: "mv", - // toolDescription: "Move/rename a file or directory in the chat workspace.", - // toolInstruction: - // "Provide fromSlug and toSlug. Use to reorganize content or rename files.", - // tool: mv, - // }, - { - toolName: "write_file", - toolDescription: - "Create or overwrite various parts of a file in the chat workspace.", - toolInstruction: - "Provide id to update an existing file or slug + primaryKeyword to create a new file. Provide the fields to set (contentMarkdown/title/description/notes/outline/slug/primaryKeyword/articleType/status/etc.).", - tool: writeFile, - }, - ]; - - return { toolDefinitions, tools }; -} diff --git a/packages/api-seo/src/lib/ai/tools/planner-tools.ts b/packages/api-seo/src/lib/ai/tools/planner-tools.ts index c73fefa25..78bee779a 100644 --- a/packages/api-seo/src/lib/ai/tools/planner-tools.ts +++ b/packages/api-seo/src/lib/ai/tools/planner-tools.ts @@ -18,19 +18,6 @@ const askQuestionInputSchema = type({ }).array(), }); -const createPlanInputSchema = type({ - "name?": "string", - "overview?": "string", - plan: "string", - "todos?": type({ - id: "string", - content: "string", - "dependencies?": "string[]", - }).array(), - "old_str?": "string", - "new_str?": "string", -}); - export function createPlannerToolsWithMetadata() { const askQuestions = tool({ description: @@ -44,22 +31,7 @@ export function createPlannerToolsWithMetadata() { }, }); - const createPlan = tool({ - description: - "Create or update a plan artifact for the SEO/GEO task (overview + steps/todos).", - inputSchema: createPlanInputSchema, - async execute() { - return await Promise.resolve({ - success: true, - message: "Plan created/updated", - }); - }, - }); - - const tools = { - ask_questions: askQuestions, - create_plan: createPlan, - } as const; + const tools = { ask_questions: askQuestions } as const; const toolDefinitions: AgentToolDefinition[] = [ { @@ -70,14 +42,6 @@ export function createPlannerToolsWithMetadata() { "Use when missing info would materially change the approach. Provide 1-6 questions. Each question needs an id, prompt, and options[] (id+label). Set allow_multiple=true only if multiple selections are valid. Keep questions crisp and decision-driving.", tool: askQuestions, }, - { - toolName: "create_plan", - toolDescription: - "Publish a structured plan artifact (overview + step-by-step plan + optional todos with dependencies).", - toolInstruction: - "Use for larger/multi-step work. Provide: plan (markdown), optional name/overview, and optional todos[] with ids and dependencies. Only include old_str/new_str when proposing a text diff-style transformation.", - tool: createPlan, - }, ]; return { toolDefinitions, tools }; diff --git a/packages/api-seo/src/lib/ai/tools/screenshot-script.ts b/packages/api-seo/src/lib/ai/tools/screenshot-script.ts deleted file mode 100644 index ee78d57ff..000000000 --- a/packages/api-seo/src/lib/ai/tools/screenshot-script.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { Buffer } from "node:buffer"; -import { mkdirSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { safe } from "@rectangular-labs/result"; -import Cloudflare from "cloudflare"; -import { Jimp } from "jimp"; -import { apiEnv } from "../../../env"; - -const TARGET_SITES = [ - { name: "Skool", slug: "skool", url: "https://skool.com" }, - { name: "Circle", slug: "circle", url: "https://circle.so/" }, -] as const; - -const GOOGLEBOT_USER_AGENT = - "Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)"; - -type SiteDefinition = (typeof TARGET_SITES)[number]; - -async function main() { - const env = apiEnv(); - const client = new Cloudflare({ - apiToken: env.CLOUDFLARE_BROWSER_RENDERING_API_TOKEN, - }); - const envOutputDir = join(process.cwd(), "screenshots"); - mkdirSync(envOutputDir, { recursive: true }); - - for (const site of TARGET_SITES) { - console.log(`Capturing ${site.name}`); - await captureSite({ - client, - env, - outputDir: envOutputDir, - site, - }); - } -} - -async function captureSite({ - client, - env, - outputDir, - site, -}: { - client: Cloudflare; - env: ReturnType; - outputDir: string; - site: SiteDefinition; -}) { - const screenshotResult = await safe(async () => { - const response = await client.browserRendering.screenshot - .create({ - account_id: env.CLOUDFLARE_ACCOUNT_ID, - url: site.url, - viewport: { - width: 1300, - height: 1200, - }, - screenshotOptions: { - type: "jpeg", - quality: 100, - encoding: "binary", - }, - gotoOptions: { - waitUntil: "networkidle2", - }, - userAgent: GOOGLEBOT_USER_AGENT, - }) - .asResponse(); - - if (!response.ok) { - const text = await response.text().catch(() => ""); - throw new Error( - `Cloudflare screenshot request failed (${response.status}): ${text || response.statusText}`, - ); - } - - const contentType = response.headers.get("content-type")?.toLowerCase(); - if (contentType?.includes("application/json")) { - const json = (await response.json().catch(() => undefined)) as unknown; - throw new Error( - `Unexpected JSON response from Cloudflare screenshot endpoint: ${JSON.stringify(json)}`, - ); - } - - const bytes = await response.arrayBuffer(); - return Buffer.from(bytes); - }); - - if (!screenshotResult.ok) { - console.error(`Failed to capture ${site.url}`, screenshotResult.error); - return; - } - - const croppedBuffer = await cropBottom(screenshotResult.value, 400); - const fileName = `20251219_${site.slug}.jpeg`; - const outputPath = join(outputDir, fileName); - writeFileSync(outputPath, croppedBuffer); - console.log(`Wrote ${outputPath}`); -} - -async function cropBottom( - input: Buffer, - removeHeight: number, -): Promise { - // const Jimp = createJimp({ - // formats: [...defaultFormats, webp], - // plugins: defaultPlugins, - // }); - const image = await Jimp.read(input); - const width = image.width; - const height = image.height; - const targetHeight = height - removeHeight; - - if (targetHeight <= 0) { - return input; - } - - image.crop({ - h: targetHeight, - w: width, - x: 0, - y: 0, - }); - const mime = image.mime; - - if (!mime) { - return input; - } - - return image.getBuffer("image/jpeg"); -} - -main() - .then(() => { - process.exit(0); - }) - .catch((error) => { - console.error(error); - process.exit(1); - }); diff --git a/packages/api-seo/src/lib/ai/tools/skill-tools.ts b/packages/api-seo/src/lib/ai/tools/skill-tools.ts deleted file mode 100644 index 6fbb27b87..000000000 --- a/packages/api-seo/src/lib/ai/tools/skill-tools.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { openai } from "@ai-sdk/openai"; -import { uuidv7 } from "@rectangular-labs/db"; -import { generateText, stepCountIs, tool } from "ai"; -import { type } from "arktype"; -import type { AgentToolDefinition } from "./utils"; - -function findSkillDefinition({ - skill, - toolDefinitions, -}: { - skill: string; - toolDefinitions: readonly AgentToolDefinition[]; -}): AgentToolDefinition | undefined { - return toolDefinitions.find((d) => d.toolName === skill); -} - -function generateMissingSkillResponse({ - skill, - toolDefinitions, -}: { - skill: string; - toolDefinitions: readonly AgentToolDefinition[]; -}) { - const known = toolDefinitions.map((d) => d.toolName).sort(); - return { - success: false, - message: [ - `Skill "${skill}" not found.`, - "", - "Known skills:", - known.length > 0 ? known.map((k) => `- ${k}`).join("\n") : "- (none)", - ].join("\n"), - } as const; -} - -const readSkillsInputSchema = type({ - skill: type("string").describe( - "The name of the skill you want instructions for.", - ), -}); -const useSkillsInputSchema = type({ - taskName: type("string").describe( - "The name of the task that encompasses what the current skill is trying to achieve. Should be short and descriptive.", - ), - skill: type("string").describe("The name of the skill you want to use."), - instructions: type("string").describe("The instructions for the skill."), -}); - -export function createSkillTools({ - toolDefinitions, -}: { - toolDefinitions: readonly AgentToolDefinition[]; -}) { - const readSkills = tool({ - description: - "Read usage instructions for a given skill. Returns guidance on how to best use the skill.", - inputSchema: readSkillsInputSchema, - async execute({ skill }) { - const skillDefinition = findSkillDefinition({ skill, toolDefinitions }); - if (!skillDefinition) { - return generateMissingSkillResponse({ skill, toolDefinitions }); - } - - return await Promise.resolve({ - success: true, - usageInstructions: [ - `## Skill: ${skillDefinition.toolName}`, - "", - "### How to use it", - skillDefinition.toolInstruction, - ].join("\n"), - }); - }, - }); - - const useSkills = tool({ - description: - "Use a given skill. Returns the result of the skill execution.", - inputSchema: useSkillsInputSchema, - async execute({ skill, instructions }) { - const skillDefinition = findSkillDefinition({ skill, toolDefinitions }); - if (!skillDefinition) { - return generateMissingSkillResponse({ skill, toolDefinitions }); - } - - console.log({ - skillName: skillDefinition.toolName, - skillInstruction: instructions, - }); - - if (skillDefinition.callDirect === true) { - const result = await skillDefinition.tool.execute?.( - { instructions }, - { - toolCallId: uuidv7(), - messages: [], - }, - ); - // note casting here for now since the tool response is not typed - return result as { success: true; result: string }; - } - - const { text } = await generateText({ - model: openai("gpt-5.2"), - prompt: `Use the ${skillDefinition.toolName} tool to perform the task: -${instructions} - -For the response, simply output the result of the task and include any failures and/or results from executing the task.`, - tools: { - [skillDefinition.toolName]: skillDefinition.tool, - }, - onStepFinish: (step) => { - console.log("skill step", step); - }, - stopWhen: [stepCountIs(15)], - }); - console.log("skill response text", text); - return { - success: true, - result: text, - }; - }, - }); - - return { - read_skills: readSkills, - use_skills: useSkills, - } as const; -} diff --git a/packages/api-seo/src/lib/ai/tools/strategy-tools.ts b/packages/api-seo/src/lib/ai/tools/strategy-tools.ts deleted file mode 100644 index 2f3318124..000000000 --- a/packages/api-seo/src/lib/ai/tools/strategy-tools.ts +++ /dev/null @@ -1,58 +0,0 @@ -import type { DB } from "@rectangular-labs/db"; -import { getStrategyDetails } from "@rectangular-labs/db/operations"; -import { tool } from "ai"; -import { type } from "arktype"; -import type { AgentToolDefinition } from "./utils"; - -const strategyDetailsInputSchema = type({ - strategyId: "string.uuid", -}); - -export function createStrategyToolsWithMetadata(args: { - db: DB; - projectId: string; - organizationId: string; -}) { - const getStrategyDetailsTool = tool({ - description: - "Fetch detailed strategy information, including phases, phase contents, and recent snapshots.", - inputSchema: strategyDetailsInputSchema, - async execute({ strategyId }) { - const detailResult = await getStrategyDetails({ - db: args.db, - projectId: args.projectId, - strategyId, - organizationId: args.organizationId, - }); - if (!detailResult.ok) { - return { success: false, message: detailResult.error.message }; - } - if (!detailResult.value) { - return { success: false, message: "Strategy not found" }; - } - - return { - success: true, - strategy: detailResult.value, - snapshots: detailResult.value.snapshots, - }; - }, - }); - - const tools = { - get_strategy_details: getStrategyDetailsTool, - } as const; - - const toolDefinitions: AgentToolDefinition[] = [ - { - toolName: "get_strategy_details", - toolDescription: - "Fetch full strategy details including phases, content items, and recent snapshots.", - toolInstruction: - "Use when you need full context for a specific strategy. Provide strategyId and optional snapshotLimit.", - tool: getStrategyDetailsTool, - }, - ]; - - return { toolDefinitions, tools }; -} diff --git a/packages/api-seo/src/lib/ai/tools/todo-tool.ts b/packages/api-seo/src/lib/ai/tools/todo-tool.ts deleted file mode 100644 index de42567c9..000000000 --- a/packages/api-seo/src/lib/ai/tools/todo-tool.ts +++ /dev/null @@ -1,176 +0,0 @@ -import { uuidv7 } from "@rectangular-labs/db"; -import { tool } from "ai"; -import { type } from "arktype"; -import type { SeoChatMessage } from "../../../types"; -import type { AgentToolDefinition } from "./utils"; - -interface TodoItem { - id: string; - title: string; - status: "open" | "done"; - notes?: string; - dependencies: string[]; -} - -function extractManageTodoSnapshotsFromMessages( - messages: SeoChatMessage[], -): TodoItem[] { - const snapshots: TodoItem[][] = []; - - for (const message of messages) { - const parts = message.parts; - for (const part of parts) { - if (part.type !== "tool-manage_todo") continue; - const result = part.output?.todos; - - if (result?.length) { - snapshots.push(result); - } - } - } - - const last = snapshots.at(-1); - return last ?? []; -} - -export function formatTodoFocusReminder({ - todos, - maxOpen, -}: { - todos: readonly TodoItem[]; - maxOpen: number; -}): string { - const open = todos.filter((t) => t.status === "open"); - const current = open[0]; - const openPreview = open.slice(0, Math.max(0, maxOpen)); - - const currentLine = current - ? `Current focus (next open todo): ${current.title} (id: ${current.id})` - : "Current focus: (no open todos)"; - - const openLines = - openPreview.length > 0 - ? openPreview.map((t) => `- [ ] ${t.title} (id: ${t.id})`).join("\n") - : "- (none)"; - - return [ - '', - currentLine, - "", - "Open todos (top):", - openLines, - "", - "Use manage_todo to add new tasks and to mark tasks done as you complete them.", - "", - ].join("\n"); -} - -const manageTodoInputSchema = type({ - action: "'create'|'update'|'list'", - "todo?": type({ - "id?": "string", - title: "string", - "status?": "'open'|'done'", - "notes?": "string", - "dependencies?": "string[]", - }), -}); - -export function createTodoToolWithMetadata(args: { - messages: SeoChatMessage[]; -}) { - let todos = extractManageTodoSnapshotsFromMessages(args.messages); - - const manageTodo = tool({ - description: - "Manage todos for the SEO chat. Todos are stored in chat history via tool results (not in the workspace filesystem).", - inputSchema: manageTodoInputSchema, - async execute({ action, todo }) { - await Promise.resolve(); - if (action === "list") { - return { - success: true, - todos, - }; - } - - if (action === "create") { - if (!todo?.title) { - return { success: false, message: "Todo title is required" }; - } - const newTodo: TodoItem = { - id: todo.id ?? uuidv7(), - title: todo.title, - status: todo.status ?? "open", - notes: todo.notes, - dependencies: todo.dependencies ?? [], - }; - - todos = [...todos, newTodo]; - - return { - success: true, - message: `Created todo: ${newTodo.title}`, - todos, - }; - } - - if (action === "update") { - if (!todo?.id) { - return { - success: false, - message: "Todo id is required for update action", - }; - } - - const index = todos.findIndex((t) => t.id === todo.id); - if (index === -1) { - return { - success: false, - message: `Todo with id ${todo.id} not found`, - }; - } - const existingTodo = todos[index]; - if (!existingTodo) { - throw new Error(`BAD STATE: Todo with id ${todo.id} not found`); - } - const title = todo.title ?? existingTodo.title; - todos[index] = { - ...existingTodo, - title, - status: todo.status ?? existingTodo.status, - notes: todo.notes !== undefined ? todo.notes : existingTodo.notes, - dependencies: todo.dependencies ?? existingTodo.dependencies, - }; - - return { - success: true, - message: `Updated todo: ${title}`, - todos, - }; - } - - return { - success: false, - message: `Unknown action: ${action}. Action must be one of: create, list, update.`, - }; - }, - }); - - const tools = { manage_todo: manageTodo } as const; - const toolDefinitions: AgentToolDefinition[] = [ - { - toolName: "manage_todo", - toolDescription: "Create, list, and update chat todos.", - toolInstruction: - "Use action='list' to see current tasks. Use action='create' with todo.title (and optional notes/status/dependencies). Use action='update' with todo.id and updated fields; mark done by setting status='done'. Provide dependencies as a list of todo ids that must be completed before this todo can be started to help keep organize of what order things should be done in. Otherwise simply give them in order of execution. Keep todos atomic and execution-oriented.", - tool: manageTodo, - }, - ]; - - return { - toolDefinitions, - tools, - getSnapshot: () => todos, - }; -} diff --git a/packages/api-seo/src/lib/ai/writer-agent.ts b/packages/api-seo/src/lib/ai/writer-agent.ts deleted file mode 100644 index 75aa97588..000000000 --- a/packages/api-seo/src/lib/ai/writer-agent.ts +++ /dev/null @@ -1,239 +0,0 @@ -import { type OpenAIResponsesProviderOptions, openai } from "@ai-sdk/openai"; -import type { schema } from "@rectangular-labs/db"; -import { convertToModelMessages, type streamText } from "ai"; -import type { ChatContext, SeoChatMessage } from "../../types"; -import { - ARTICLE_TYPE_TO_WRITER_RULE, - type ArticleType, -} from "../workspace/workflow.constant"; -import { createFileToolsWithMetadata } from "./tools/file-tool"; -import { createInternalLinksToolWithMetadata } from "./tools/internal-links-tool"; -import { createPlannerToolsWithMetadata } from "./tools/planner-tools"; -import { createSkillTools } from "./tools/skill-tools"; -import { - createTodoToolWithMetadata, - formatTodoFocusReminder, -} from "./tools/todo-tool"; -import { - type AgentToolDefinition, - formatToolSkillsSection, -} from "./tools/utils"; -import { createWebToolsWithMetadata } from "./tools/web-tools"; -import { formatBusinessBackground } from "./utils/format-business-background"; - -export type WriterPromptProjectContext = Pick< - typeof schema.seoProject.$inferSelect, - "websiteUrl" | "businessBackground" | "name" | "writingSettings" ->; - -export function buildWriterSystemPrompt(args: { - project: WriterPromptProjectContext; - skillsSection: string; - mode: "chat" | "workflow"; - articleType?: ArticleType; - primaryKeyword?: string; - outline?: string; -}): string { - const utcDate = new Intl.DateTimeFormat("en-US", { - timeZone: "UTC", - year: "numeric", - month: "long", - day: "numeric", - }).format(new Date()); - - const articleTypeRule = args.articleType - ? ARTICLE_TYPE_TO_WRITER_RULE[args.articleType] - : undefined; - - return ` -You are an elite SEO/GEO content writer and a strict editorial persona. You produce publish-ready, high quality Markdown articles with a consistent, authoritative voice. - -You are methodical in your approach and use the \`manage_todo\` tool to track your work and keep the todo list current. - -${ - args.mode === "chat" - ? ` -Your job is to help the user edit and finalize the article. - -You have two power tools \`read_skills\` and \`use_skills\`. Prefer using them instead of guessing about workspace state.` - : ` -Write a comprehensive Markdown article using the provided outline in outline tags below. It is important that you adhere to the outline provided as closely as possible.` -} - -${ - args.mode === "chat" - ? ` -1. If the user is editing an article, prioritize helping them improve the draft directly (structure, clarity, correctness, SEO, and citations). -4. Ask clarifying questions only when absolutely necessary. -5. Keep outputs concise and action-oriented. - - - -${args.skillsSection} - -` - : "" -} - -- Use the primary keyword naturally in the opening paragraph and key headings. -- Provide clear, direct answers that AI systems can extract. -- Use structured formatting (lists/tables) for scannability when helpful. -- Use semantic variations and LSI keywords where they fit naturally. -- Follow the outline in outline tags closely; expand each section into helpful, grounded prose. -- Include 2-4 authoritative external links (use web_search if outline lacks them). -- Write as an authoritative editor, not a conversational assistant -- Never emit meta labels like "Opinion:", "Caption:", "HeroImage:", or "CTA:" -- Avoid "Introduction" as a section heading -- Include a section at the end that summarizes what was covered; vary the heading instead of always using "Conclusion". For example, we can talk about wrapping up, summarizing what we covered, etc. (of course, conclusion could also be a good fit) -- If a "Frequently Asked Questions" section is present, it must come after the wrap-up section and use the heading "Frequently Asked Questions" -- Expand abbreviations on first use. -- Keep Markdown clean: normal word spacing, no excessive blank lines, and straight quotes (") -- NEVER use thematic breaks (---) or HTML line breaks (
or
) anywhere in the article. -- Headings must be clear, direct, and concise. NEVER include parenthetical elaborations or explanations in headings (e.g., use "Model data and integrations" NOT "Model your data and integrations (so the thing stays true)"). -- Opening paragraph rules: - - Start with a hook that directly addresses the reader's problem or goal. - - Avoid generic statements like "In today's world..." or "Many businesses...". - - Get to the point within the first two sentences. - - Naturally introduce the primary keyword without forcing it. -- For images: - - Select a hero image that visually represents the topic; return it in the heroImage field and include heroImageCaption if needed. - - Do not embed the hero image or its caption in the Markdown output. - - Outside of screenshots/stock photos/generated images required based on the article type rule, have at least one image for one of the H2 section in the article. Identify which section has the potential to have the best visual. Sections which describe a process, concept, or system are the best candidates for image generation. - - Use the markdown syntax to embed the image in the article along with relevant descriptive alt text (describe what the image shows, not just "1.00" or generic text). - - Do NOT include image captions in the Markdown output unless they are stock photo attributions. - - Place images immediately after the section title they belong to. - - NEVER inline image data as base64 or data URIs. Always use proper URLs returned from image generation/stock photo tools. -- For Bullet points: - - Bold the heading of the bullet point, and use a colon after that before the explanation of the bullet point - - Always substantiate the bullet point by explaining what it means, what it entails, or how to use it. -- For Tables: - - use tables when comparing (pricing, specs, rankings), as a summary for long listicle, and for any structured content that might have too many entries. - - Bold the headings for the tables (first row, and if applicable, first column) -- External links rules - - Add external links only when they directly support a specific claim or statistic. All external links must be validated (page exists, no 404, relevant to the claim) via web_fetch or are returned from web_search. DO NOT put link placeholders or un-validated links, and DO NOT invent or guess URLs. Embed links inline within the exact phrase or sentence they support. Do not add standalone “Source:” sentences. - - Statistics rules (strict) - - Use numbers only if the source explicitly states them as findings (research, report, benchmark). - - Do not treat marketing or CTA language as evidence. (e.g. “See how X reduces effort by 80%” is not necessarily a verified statistic). - - If a number cannot be verified exactly, remove the number and rewrite the claim qualitatively. - - The statistic must match the source exactly — no rounding, no reinterpretation. - - Source quality rules - - Prefer research, standards bodies, reputable publications, or industry reports. - - Vendor pages are acceptable only for definitions or explanations — not performance claims. - - If the page does not clearly support the statement being made, do not use it. - - Duplicate invoices typically represent a small but real portion of AP leakage, often cited as [well under 1% of annual spend](https://www.example.com/well+under+annual+spend). - - - According to the [Harvard Business Review](https://www.example.com/link-here), the most successful companies of the future will be those that can innovate fast. - - - Up to [20% of companies](https://www.example.com/link-here) will be disrupted by AI in the next 5 years. - -- Internal links rules - - Use the internal_links tool or web_search to find relevant internal pages to link to. - - Use 5-10 internal links throughout the article where they naturally fit. - - CRITICAL: Copy URLs exactly as returned by tools. Do NOT add, remove, or modify any characters in the URL (no trailing punctuation, no possessive 's, no apostrophes). - - Embed links on descriptive anchor text (2-5 words), not on generic phrases like "click here", "here", "this", or "learn more". - - Do NOT place links at the very end of sentences in parentheses like "(this)" or "(here)". - - Explore our [home renovation guide](/home-renovation) to understand the key benefits. - - - Teams using [workflow templates](/templates/workflow-templates) save significant time on setup. - - - Learn more about automation (here)[/automation]. - -- You must follow the brand voice and user instructions provided in the context section below. -- Output must be JSON with the full final Markdown article (no title, no hero image, no hero image caption), plus heroImage and heroImageCaption (if any). -${articleTypeRule ? `- Article-type rule for ${args.articleType}: ${articleTypeRule}` : ""} -
- - -- Today's date: ${utcDate} (UTC timezone) -- Website: ${args.project.websiteUrl}${formatBusinessBackground(args.project.businessBackground ?? null)} -- Article type: ${args.articleType ?? "other"} -- Primary keyword: ${args.primaryKeyword ?? "(missing)"} -- Project name: ${args.project.name ?? "(none)"} - - - -${args.project.writingSettings?.brandVoice} - - - -${args.project.writingSettings?.customInstructions} - - - -${args.outline ?? "(missing)"} -`; -} - -export async function createWriterAgent({ - messages, - project, - context, -}: { - messages: SeoChatMessage[]; - project: NonNullable; - context: ChatContext; -}): Promise[0]> { - const plannerTools = createPlannerToolsWithMetadata(); - const todoTools = createTodoToolWithMetadata({ messages }); - - const fileTools = createFileToolsWithMetadata({ - userId: undefined, - db: context.db, - organizationId: context.organizationId, - projectId: context.projectId, - chatId: context.chatId, - }); - const webTools = createWebToolsWithMetadata(project, context.cacheKV); - const internalLinksTools = createInternalLinksToolWithMetadata( - project.websiteUrl, - ); - - const skillDefinitions: AgentToolDefinition[] = [ - ...fileTools.toolDefinitions, - ...webTools.toolDefinitions, - ...internalLinksTools.toolDefinitions, - ]; - const skillsSection = formatToolSkillsSection(skillDefinitions); - - const systemPrompt = buildWriterSystemPrompt({ - project, - skillsSection, - mode: "chat", - }); - - return { - model: openai("gpt-5.2"), - providerOptions: { - openai: { - reasoningEffort: "medium", - } satisfies OpenAIResponsesProviderOptions, - }, - system: systemPrompt, - messages: await convertToModelMessages(messages), - tools: { - ...createSkillTools({ toolDefinitions: skillDefinitions }), - ...plannerTools.tools, - ...todoTools.tools, - }, - prepareStep: ({ messages }) => { - return { - messages: [ - ...messages, - { - role: "system", - content: formatTodoFocusReminder({ - todos: todoTools.getSnapshot(), - maxOpen: 5, - }), - }, - ], - }; - }, - } satisfies Parameters[0]; -} diff --git a/packages/api-seo/src/lib/task.ts b/packages/api-seo/src/lib/task.ts index a6d7c8ed3..aa99ad15b 100644 --- a/packages/api-seo/src/lib/task.ts +++ b/packages/api-seo/src/lib/task.ts @@ -67,13 +67,6 @@ export async function createTask({ }); return { provider: "cloudflare" as const, taskId: instance.id }; } - case "seo-plan-keyword": { - const instance = await workflows.seoPlannerWorkflow.create({ - id: workflowInstanceId ?? `plan_${crypto.randomUUID()}`, - params: input, - }); - return { provider: "cloudflare" as const, taskId: instance.id }; - } case "seo-write-article": { const instance = await workflows.seoWriterWorkflow.create({ id: workflowInstanceId ?? `write_${crypto.randomUUID()}`, diff --git a/packages/api-seo/src/routes/task.ts b/packages/api-seo/src/routes/task.ts index 8b17f9e52..3ffdb81ef 100644 --- a/packages/api-seo/src/routes/task.ts +++ b/packages/api-seo/src/routes/task.ts @@ -95,9 +95,6 @@ const status = protectedBase if (taskRun.provider === "cloudflare") { const instance = await (() => { - if (taskRun.inputData.type === "seo-plan-keyword") { - return context.seoPlannerWorkflow.get(taskRun.taskId); - } if (taskRun.inputData.type === "seo-write-article") { return context.seoWriterWorkflow.get(taskRun.taskId); } diff --git a/packages/api-seo/src/types.ts b/packages/api-seo/src/types.ts index b7fb3ac61..11509c131 100644 --- a/packages/api-seo/src/types.ts +++ b/packages/api-seo/src/types.ts @@ -11,15 +11,13 @@ import type { DB, schema } from "@rectangular-labs/db"; import type { InferUITools, UIDataTypes, UIMessage, UIMessageChunk } from "ai"; import type { Scheduler } from "partywhen"; import type { createPlannerToolsWithMetadata } from "./lib/ai/tools/planner-tools"; -import type { createSkillTools } from "./lib/ai/tools/skill-tools"; -import type { createTodoToolWithMetadata } from "./lib/ai/tools/todo-tool"; +import type { createSettingsToolsWithMetadata } from "./lib/ai/tools/settings-tools"; import type { createPublicImagesBucket, createWorkspaceBucket, } from "./lib/bucket"; import type { router } from "./routes"; import type { SeoOnboardingWorkflowBinding } from "./workflows/onboarding-workflow"; -import type { SeoPlannerWorkflowBinding } from "./workflows/planner-workflow"; import type { SeoStrategyPhaseGenerationWorkflowBinding } from "./workflows/strategy-phase-generation-workflow"; import type { SeoStrategySnapshotWorkflowBinding } from "./workflows/strategy-snapshot-workflow"; import type { SeoStrategySuggestionsWorkflowBinding } from "./workflows/strategy-suggestions-workflow"; @@ -31,9 +29,8 @@ export type RouterInputs = InferRouterInputs; export type RouterOutputs = InferRouterOutputs; type AiTools = InferUITools< - ReturnType & - ReturnType["tools"] & - ReturnType["tools"] + ReturnType["tools"] & + ReturnType["tools"] >; export type SeoChatMessage = UIMessage< typeof chatMessageMetadataSchema.infer, @@ -60,7 +57,6 @@ export interface InitialContext extends BaseContextWithAuth { url: URL; workspaceBucket: ReturnType; publicImagesBucket: ReturnType; - seoPlannerWorkflow: SeoPlannerWorkflowBinding; seoWriterWorkflow: SeoWriterWorkflowBinding; seoOnboardingWorkflow: SeoOnboardingWorkflowBinding; seoStrategySuggestionsWorkflow: SeoStrategySuggestionsWorkflowBinding; diff --git a/packages/api-seo/src/workflows/index.ts b/packages/api-seo/src/workflows/index.ts index e7d90e263..d9aa26ad7 100644 --- a/packages/api-seo/src/workflows/index.ts +++ b/packages/api-seo/src/workflows/index.ts @@ -1,6 +1,5 @@ import { env as cloudflareEnv } from "cloudflare:workers"; import type { SeoOnboardingWorkflowBinding } from "./onboarding-workflow"; -import type { SeoPlannerWorkflowBinding } from "./planner-workflow"; import type { SeoStrategyPhaseGenerationWorkflowBinding } from "./strategy-phase-generation-workflow"; import type { SeoStrategySnapshotWorkflowBinding } from "./strategy-snapshot-workflow"; import type { SeoStrategySuggestionsWorkflowBinding } from "./strategy-suggestions-workflow"; @@ -8,7 +7,6 @@ import type { SeoWriterWorkflowBinding } from "./writer-workflow"; export const createWorkflows = () => { const castEnv = cloudflareEnv as { - SEO_PLANNER_WORKFLOW: SeoPlannerWorkflowBinding; SEO_WRITER_WORKFLOW: SeoWriterWorkflowBinding; SEO_ONBOARDING_WORKFLOW: SeoOnboardingWorkflowBinding; SEO_STRATEGY_SUGGESTIONS_WORKFLOW: SeoStrategySuggestionsWorkflowBinding; @@ -16,7 +14,6 @@ export const createWorkflows = () => { SEO_STRATEGY_SNAPSHOT_WORKFLOW: SeoStrategySnapshotWorkflowBinding; }; return { - seoPlannerWorkflow: castEnv.SEO_PLANNER_WORKFLOW, seoWriterWorkflow: castEnv.SEO_WRITER_WORKFLOW, seoOnboardingWorkflow: castEnv.SEO_ONBOARDING_WORKFLOW, seoStrategySuggestionsWorkflow: castEnv.SEO_STRATEGY_SUGGESTIONS_WORKFLOW, @@ -26,7 +23,6 @@ export const createWorkflows = () => { }; }; export { SeoOnboardingWorkflow } from "./onboarding-workflow"; -export { SeoPlannerWorkflow } from "./planner-workflow"; export { SeoStrategyPhaseGenerationWorkflow } from "./strategy-phase-generation-workflow"; export { SeoStrategySnapshotWorkflow } from "./strategy-snapshot-workflow"; export { SeoStrategySuggestionsWorkflow } from "./strategy-suggestions-workflow"; diff --git a/packages/api-seo/src/workflows/planner-workflow.ts b/packages/api-seo/src/workflows/planner-workflow.ts deleted file mode 100644 index 7b0bff455..000000000 --- a/packages/api-seo/src/workflows/planner-workflow.ts +++ /dev/null @@ -1,584 +0,0 @@ -import { - WorkflowEntrypoint, - type WorkflowEvent, - type WorkflowStep, -} from "cloudflare:workers"; -import { NonRetryableError } from "cloudflare:workflows"; -import { type GoogleGenerativeAIProviderOptions, google } from "@ai-sdk/google"; -import { type OpenAIResponsesProviderOptions, openai } from "@ai-sdk/openai"; -import type { searchItemSchema } from "@rectangular-labs/core/schemas/keyword-parsers"; -import type { - seoPlanKeywordTaskInputSchema, - seoPlanKeywordTaskOutputSchema, -} from "@rectangular-labs/core/schemas/task-parsers"; -import { createDb, type schema } from "@rectangular-labs/db"; -import { - getDraftById, - getSeoProjectByIdentifierAndOrgId, -} from "@rectangular-labs/db/operations"; -import { err, ok, type Result, safe } from "@rectangular-labs/result"; -import { generateText, Output, stepCountIs } from "ai"; -import { type } from "arktype"; -import { arktypeToAiJsonSchema } from "../lib/ai/arktype-json-schema"; -import { - createTodoToolWithMetadata, - formatTodoFocusReminder, -} from "../lib/ai/tools/todo-tool"; -import { createWebToolsWithMetadata } from "../lib/ai/tools/web-tools"; -import { writeContentDraft } from "../lib/content/write-content-draft"; -import { - configureDataForSeoClient, - fetchSerpWithCache, - getLocationAndLanguage, - getSerpCacheOptions, -} from "../lib/dataforseo/utils"; -import type { InitialContext } from "../types"; - -function logInfo(message: string, data?: Record) { - console.info(`[SeoPlannerWorkflow] ${message}`, data ?? {}); -} - -function logError(message: string, data?: Record) { - console.error(`[SeoPlannerWorkflow] ${message}`, data ?? {}); -} - -type SearchItem = typeof searchItemSchema.infer; -async function addOrganicOutlinesToSerp({ serp }: { serp: SearchItem[] }) { - const organicItems = serp.filter((item) => item.type === "organic"); - const organicForPrompt = organicItems.map((item) => ({ - title: item.title ?? "", - url: item.url, - })); - if (organicForPrompt.length === 0) return serp; - - const serpValue = JSON.stringify(organicForPrompt); - const prompt = `For each of the organic serp items, extract out the H2 and H3 to gather the outline of the article and return a JSON form in the form of { title: string, outline: string }[] where the outline is a markdown format of all the H2 and H3 extracted from the urls - -Rules: -- Use the url_context tool for each URL to extract headings. -- Output must be ONLY valid JSON (no markdown, no commentary) matching exactly: { title: string, outline: string }[]. -- Preserve the input order exactly and return the same number of items as provided in . -- If a URL is null or cannot be fetched, return an empty outline string for that item. -- The outline must be markdown headings only: use \\"##\\" for H2 and \\"###\\" for H3, in the order they appear on the page. -- Exclude navigation/footer/sidebar headings; include only main-article headings. - - -${serpValue} -`; - - const schema = type({ - outlines: type({ - title: type("string").describe("The title of the SERP organic item"), - outline: type("string").describe("The outline of the SERP organic item"), - }) - .array() - .describe( - "SERP organic outlines: { title: string, outline: string }[] where outline is markdown headings", - ), - }); - const extraction = await safe(() => - generateText({ - model: google("gemini-3-flash-preview"), - tools: { - url_context: google.tools.urlContext({}), - }, - prompt, - stopWhen: [stepCountIs(40)], - output: Output.object({ - schema: arktypeToAiJsonSchema(schema), - }), - }), - ); - - if (!extraction.ok) { - logError("failed to extract competitor outlines from SERP urls", { - error: extraction.error, - }); - return serp; - } - - const extracted = extraction.value.output.outlines; - logInfo("SERP outlines extracted", { - extracted, - }); - - let organicIdx = 0; - return serp.map((item) => { - if (item.type !== "organic") return item; - - const next = extracted[organicIdx]; - organicIdx += 1; - - return { - ...item, - outline: next?.outline?.trim() ? next.outline.trim() : null, - }; - }); -} - -async function generateOutline({ - project, - notes, - primaryKeyword, - locationName, - languageCode, - serp, - cache, -}: { - project: Omit; - notes?: string; - cache: InitialContext["cacheKV"]; - primaryKeyword: string; - locationName: string; - languageCode: string; - serp: SearchItem[]; -}): Promise< - Result< - { - outline: string; - title: string; - description: string; - }, - Error - > -> { - const utcDate = new Intl.DateTimeFormat("en-US", { - timeZone: "UTC", - year: "numeric", - month: "long", - day: "numeric", - }).format(new Date()); - const haveAiOverview = serp.some((s) => s.type === "ai_overview"); - const havePeopleAlsoAsk = serp.some((s) => s.type === "people_also_ask"); - const system = ` -You are an expert SEO article researcher and strategist. Your job is to produce a writer-ready plan and outline for the BEST possible article for the target keyword. You MUST synthesize findings from competitor pages, people also ask questions, related searches, and AI overview should they exists. - -The goal is to create a plan that a writer can follow to: -1. Outranks the current top 10 SERP results -2. Gets featured in AI Overviews and answer boxes -3. Drives organic traffic and conversions -4. Establishes E-E-A-T (Experience, Expertise, Authoritativeness, Trustworthiness) for the site ${project.websiteUrl}. - -The way to do that is to focus on Search intent of the primary keyword: ${primaryKeyword}. The search intent what the user would expect to find when searching for this particular keyword. - -We identify the search intent of the primary keyword from the SERPs, your natural understanding of the primary keyword. Focus on the following when identifying the search intent: -1. what information the user is intending to extract from the query. -2. what they already know or are aware of (eg. are they aware of the existence of a product or solution, or not yet). -3. what they are not aware of. - -Once the search intent is identified, the article should be focused on answering precisely it, substantiated with all the information, evidence, explanation and sourcing found from doing research on the web/links to relevant articles that we've written in the past. Refer to workflow for more details on how we prepare the article outline - -EVERYTHING in the article should be focused AND in service of the search intent. - - - -1) Analyze search intent from the SERPs, your natural understanding of the primary keyword for the primary keyword ${primaryKeyword} (SERPs provided in live-serp-data). Use the SERP outline of competitors pages ${haveAiOverview ? "and the AI Overview" : ""} to find out - i) groups of information that is useful to the searcher - ii) questions the searcher would find useful to be answered - iii) how the article flows from one topic to another, and how it's segmented into sections -2) Analyze the SERPs titles, description, and url slugs - i) title - how to use the primary keyword in the title, phrase it to capture attention, and promise the searcher their search intent will be fulfilled - ii) slug - the most efficient way to include keywords in the url - iii) description - summarize content succinctly and accurately while stating the keyword -3) Gather sources: YOU MUST USE web_search for fresh stats, studies, definitions, quotes, and other articles that we might've wrote in the past Alternatively, you are free to suggest URLS, but they must be validated via web_fetch. DO NOT cite URLs or sources not returned from web_search or validated via web_fetch. MAKE SEPARATE web_fetch TOOL CALLS for searches for internal links and external sources. -4) Synthesize into a brief article outline. Follow the critical-plan-requirements and the project context. -5) Output ONLY valid JSON (no markdown, no commentary) matching the required schema. - - - -- The plan should be targeted at solving for the main search intent of the reader for the primary keyword ${primaryKeyword}. Use the distilled information from competitor pages ${haveAiOverview ? "and the AI Overview" : ""} while also adding unique insights and angles to structure the overall article. Note that if NONE of the competitors page fulfill/matches the search intent of the primary keyword, you SHOULD ignore the competitors page structure. -- The final artifact MUST be a cohesive plan that a writer can follow end-to-end. -- Prefer tool-grounded claims. When you cite stats or claims, have the claims be in the anchor text and the link be the source URL itself in markdown link syntax. -- The plan MUST include a title and the description for the article, follow these rules: - - Meta description (max 160 characters): clear, succinct, keyword-rich summary that directly signals the article fulfills the user's main search intent (what they want to learn/answer) - - Title + meta title (max 60 characters): clear and enticing for the user to click on, includes the primary keyword once (natural/organic), and answers the search intent directly; do not append extra qualifiers (no "X, Y and Z" and no parentheses), just the title -- The plan MUST also include the H2/H3 outline with section-by-section notes (what to say, what to cite, and any unique angle to add). - - Headings must be clear, direct, and concise. Reader should immediately understand what the section covers. - - NEVER include parenthetical elaborations in headings (e.g., use "Model data and integrations" NOT "Model your data and integrations (so the portal stays true)"). - - First H2 - incredibly focused and targeted toward answering the main intent of the searcher - - Most H2s should have the primary keyword ${primaryKeyword} naturally written into it - - H3 be related to the H2, and to the point -- Lead section (no "Introduction" heading) - maximum of two paragraphs of 3 sentences long each. Should - 1. intrigue the reader - why this is important for the reader and include any interesting stats - 2. contextualize the topic for the reader's search intent - 3. contextualize how this has been recently important and what recent developments in the space are (if applicable - i.e. if the topic has significant recent updates) - 4. summarize the topics that the article would cover -- Include a section at the end that summarizes what was covered; vary the heading instead of always using "Conclusion". For example, we can talk about wrapping up, summarizing what we covered, etc. (of course, conclusion could also be a good fit) -- Add in a target word count to the plan so the writer knows how much to write. - - Most articles should have a word count around 1,000 to 1,500 words. Long research pieces can have more, but 95% of articles should fall within 1,000 to 1,500 words. - - If there is a struggle to keep it below this word count, look to focus the article up, and talk about fewer ideas. -${ - havePeopleAlsoAsk - ? `- Suggest a Frequently Asked Questions section based on the People Also Ask data. We should be using the questions from the People Also Ask data almost verbatim to guide the Frequently Asked Questions section and limit 5 questions maximum unless asked to write more. - - Short and to the point. No more than 2 sentences long. - - Answer the question directly, substantiated succinctly. - - Plug the service and product of the company by directly stating the company name, but only naturally and when relevant to the question posed. - - Have the question mirror the People Also Ask question almost verbatim. - - Place the Frequently Asked Questions section after the wrap-up section.` - : "- Do not suggest a Frequently Asked Questions section." -} -- Use the related_searches in the live-serp-data to suggest semantic variations and LSI keywords to naturally insert in various sections. -- Add external links only when they directly support a specific claim or statistic. All external links must be validated (page exists, no 404, relevant to the claim) via web_fetch or are returned from web_search. DO NOT put link placeholders or un-validated links, and DO NOT invent or guess URLs. Embed links inline within the exact phrase or sentence they support. - - Do not add standalone "Source:" sentences where we state some facts and then add a link at the end. - - The average home renovation [costs $25,000](https://example.com/home-renovation-facts) which is a [10% increase](https://example.com/home-renovation-facts-2) from last year. - - - The average home renovation costs $25,000 (source: https://example.com/home-renovation-facts) which is a 10% increase from last year (source: https://example.com/home-renovation-facts-2). - -- Internal links rules: - - Propose 5-10 highly relevant internal links to include (and suggested anchor text) in markdown link syntax. - - CRITICAL: Copy URLs exactly as returned by web_search. Do NOT add, remove, or modify any characters in the URL (no trailing punctuation, no possessive 's, no apostrophes). - - Use descriptive anchor text (2-5 words), not generic phrases like "click here", "here", "this", or "learn more". - - Do NOT place links at the very end of sentences in parentheses like "(this)" or "(here)". - - Explore our [home renovation guide](/home-renovation) to understand the key benefits. - - - Teams using [workflow templates](/templates/workflow-templates) save significant time on setup. - - - Learn more about automation (here)[/automation]. - -- Statistics rules (strict) - - Use numbers only if the source explicitly states them as findings (research, report, benchmark). - - Do not treat marketing or CTA language as evidence. (e.g. "See how X reduces effort by 80%" is not necessarily a verified statistic). - - If a number cannot be verified exactly, remove the number and rewrite the claim qualitatively. - - The statistic must match the source exactly — no rounding, no reinterpretation. -- Source quality rules - - Prefer research, standards bodies, reputable publications, or industry reports. - - Vendor pages are acceptable only for definitions or explanations — not performance claims. - - If the page does not clearly support the statement being made, do not use it. - - Duplicate invoices typically represent a small but real portion of AP leakage, [often cited as well under 1% of annual spend](/path/to/industry overview). - - - According to the [Harvard Business Review](url_link), the most successful companies of the future will be those that can innovate fast. - - - Up to [20% of companies](url_link) will be disrupted by AI in the next 5 years. - - - - - -- Today's date: ${utcDate} (UTC timezone) -- Website: ${project.websiteUrl} -- Primary keyword: ${primaryKeyword} -- Target country: ${locationName} -- Target language: ${languageCode} - - - - -${JSON.stringify(serp)} -`; - - const todoTool = createTodoToolWithMetadata({ messages: [] }); - const webTools = createWebToolsWithMetadata(project, cache); - - const outputSchema = type({ - title: type("string").describe( - "Meta title (max 60 characters): clear, enticing, includes the primary keyword once naturally, directly answers search intent.", - ), - description: type("string").describe( - "Meta description (max 160 characters): succinct, keyword-rich, clearly signals the article fulfills the main search intent.", - ), - outline: type("string").describe( - "Writer-ready markdown plan/outline with H2/H3 structure, section notes, sources to cite, internal links, and target word count.", - ), - }).describe( - "SEO plan output as JSON with title, description, and outline markdown.", - ); - const result = await safe(() => - generateText({ - model: openai("gpt-5.1-codex-mini"), - providerOptions: { - openai: { - reasoningEffort: "medium", - } satisfies OpenAIResponsesProviderOptions, - google: { - thinkingConfig: { - includeThoughts: true, - thinkingLevel: "medium", - }, - } satisfies GoogleGenerativeAIProviderOptions, - }, - tools: { - ...webTools.tools, - ...todoTool.tools, - }, - system, - messages: [ - { - role: "user", - content: `Generate the plan for the primary keyword: ${primaryKeyword}. The output must be ONLY valid JSON matching the required schema (no extra commentary). ${notes ? `Here are some notes about the article that I want you to consider: ${notes}` : ""}`, - }, - ], - onStepFinish: (step) => { - logInfo(`[generateOutline] Step completed:`, { - text: step.text, - toolResults: JSON.stringify(step.toolResults, null, 2), - usage: step.usage, - }); - }, - prepareStep: ({ messages }) => { - return { - messages: [ - ...messages, - { - role: "assistant", - content: formatTodoFocusReminder({ - todos: todoTool.getSnapshot(), - maxOpen: 5, - }), - }, - ], - }; - }, - stopWhen: [stepCountIs(25)], - output: Output.object({ - schema: arktypeToAiJsonSchema(outputSchema), - }), - }), - ); - if (!result.ok) return err(result.error); - const output = result.value.output; - const outline = output.outline.trim(); - const title = output.title.trim(); - const description = output.description.trim(); - if (!outline) return err(new Error("Empty outline returned by model")); - if (!title) return err(new Error("Empty title returned by model")); - return ok({ outline, title, description }); -} - -type PlannerInput = type.infer; -export type SeoPlannerWorkflowBinding = Workflow; -export class SeoPlannerWorkflow extends WorkflowEntrypoint< - { - SEO_WRITER_WORKFLOW: InitialContext["seoWriterWorkflow"]; - CACHE: InitialContext["cacheKV"]; - }, - PlannerInput -> { - async run(event: WorkflowEvent, step: WorkflowStep) { - const input = event.payload; - - logInfo("start", { - instanceId: event.instanceId, - draftId: input.draftId, - organizationId: input.organizationId, - projectId: input.projectId, - chatId: input.chatId, - hasCallbackInstanceId: Boolean(input.callbackInstanceId), - }); - configureDataForSeoClient(); - - try { - const { - serpKey, - primaryKeyword, - locationName, - languageCode, - project, - notes, - } = await step.do("fetch SERP for keyword", async () => { - const db = createDb(); - - const contentResult = await getDraftById({ - db, - organizationId: input.organizationId, - projectId: input.projectId, - id: input.draftId, - withContent: true, - }); - if (!contentResult.ok) { - throw new NonRetryableError(contentResult.error.message); - } - if (!contentResult.value) { - throw new NonRetryableError(`Draft not found for ${input.draftId}`); - } - const content = contentResult.value; - const primaryKeyword = content.primaryKeyword; - - const projectResult = await getSeoProjectByIdentifierAndOrgId( - db, - input.projectId, - input.organizationId, - { - publishingSettings: true, - writingSettings: true, - imageSettings: true, - businessBackground: true, - }, - ); - if (!projectResult.ok) { - throw new NonRetryableError(projectResult.error.message); - } - if (!projectResult.value) { - throw new NonRetryableError(`Project (${input.projectId}) not found`); - } - const project = projectResult.value; - - const { locationName, languageCode } = getLocationAndLanguage(project); - const serpCacheOptions = getSerpCacheOptions( - primaryKeyword, - locationName, - languageCode, - ); - const serpResult = await fetchSerpWithCache({ - keyword: primaryKeyword, - locationName, - languageCode, - cacheKV: this.env.CACHE, - }); - if (!serpResult.ok) throw serpResult.error; - return { - serpKey: serpCacheOptions.key, - primaryKeyword, - locationName, - languageCode, - project, - notes: content.notes ?? undefined, - }; - }); - - logInfo("inputs ready", { - instanceId: event.instanceId, - serpKey, - primaryKeyword, - locationName, - languageCode, - hasNotes: Boolean(notes?.trim()), - }); - - const outlineResult = await step.do( - "generate outline", - { - timeout: "30 minutes", - }, - async () => { - logInfo("fetching SERP (cached) for outline", { - instanceId: event.instanceId, - draftId: input.draftId, - serpKey, - primaryKeyword, - locationName, - languageCode, - }); - - const serp = await fetchSerpWithCache({ - keyword: primaryKeyword, - locationName, - languageCode, - cacheKV: this.env.CACHE, - }); - if (!serp.ok) { - throw serp.error; - } - - logInfo("Getting SERP outlines", { - instanceId: event.instanceId, - draftId: input.draftId, - primaryKeyword, - serpItems: serp.value, - }); - - const serpWithOutlines = await addOrganicOutlinesToSerp({ - serp: serp.value.searchResult, - }); - - logInfo("generating outline", { - instanceId: event.instanceId, - draftId: input.draftId, - primaryKeyword, - serpItems: serp.value, - }); - - const result = await generateOutline({ - project, - notes, - primaryKeyword, - locationName, - languageCode, - serp: serpWithOutlines, - cache: this.env.CACHE, - }); - if (!result.ok) throw result.error; - const { outline, title, description } = result.value; - logInfo("outline generated", { - instanceId: event.instanceId, - outlineChars: outline.length, - titleChars: title.length, - descriptionChars: description.length, - }); - return { outline, title, description }; - }, - ); - - await step.do("store outline in node", async () => { - const db = createDb(); - const writeResult = await writeContentDraft({ - db, - chatId: input.chatId ?? null, - userId: input.userId ?? null, - projectId: input.projectId, - organizationId: input.organizationId, - lookup: { type: "id", id: input.draftId }, - draftNewValues: { - outline: outlineResult.outline, - title: outlineResult.title, - description: outlineResult.description, - }, - }); - if (!writeResult.ok) throw new Error(writeResult.error.message); - }); - - logInfo("outline stored", { - instanceId: event.instanceId, - draftId: input.draftId, - }); - - const { callbackInstanceId } = input; - if (callbackInstanceId) { - await step.do("notify callback workflow", async () => { - const instance = - await this.env.SEO_WRITER_WORKFLOW.get(callbackInstanceId); - await instance.sendEvent({ - type: "planner_complete", - payload: { draftId: input.draftId }, - }); - }); - logInfo("callback notified", { - instanceId: event.instanceId, - draftId: input.draftId, - callbackInstanceId, - }); - } else { - logInfo("no callbackInstanceId, skipping notify", { - instanceId: event.instanceId, - draftId: input.draftId, - }); - } - - logInfo("complete", { - instanceId: event.instanceId, - draftId: input.draftId, - }); - - return { - type: "seo-plan-keyword", - draftId: input.draftId, - outline: outlineResult.outline, - } satisfies typeof seoPlanKeywordTaskOutputSchema.infer; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - logError("failed", { - instanceId: event.instanceId, - draftId: input.draftId, - message, - }); - throw error; - } - } -} diff --git a/packages/core/src/schemas/task-parsers.ts b/packages/core/src/schemas/task-parsers.ts index a86fe2085..89d57737a 100644 --- a/packages/core/src/schemas/task-parsers.ts +++ b/packages/core/src/schemas/task-parsers.ts @@ -37,16 +37,6 @@ export const seoGenerateStrategySnapshotTaskInputSchema = type({ "userId?": "string", }); -export const seoPlanKeywordTaskInputSchema = type({ - type: "'seo-plan-keyword'", - projectId: "string.uuid", - organizationId: "string", - chatId: "string|null", - draftId: "string.uuid", - "callbackInstanceId?": "string", - "userId?": "string", -}); - export const seoWriteArticleTaskInputSchema = type({ type: "'seo-write-article'", projectId: "string", @@ -58,7 +48,6 @@ export const seoWriteArticleTaskInputSchema = type({ export const taskInputSchema = type.or( understandSiteTaskInputSchema, - seoPlanKeywordTaskInputSchema, seoWriteArticleTaskInputSchema, seoUnderstandSiteTaskInputSchema, seoStrategySuggestionsTaskInputSchema, @@ -71,12 +60,6 @@ export const understandSiteTaskOutputSchema = type({ websiteInfo: businessBackgroundSchema.merge(type({ name: "string" })), }); -export const seoPlanKeywordTaskOutputSchema = type({ - type: "'seo-plan-keyword'", - draftId: "string.uuid", - outline: "string", -}); - export const seoWriteArticleTaskOutputSchema = type({ type: "'seo-write-article'", draftId: "string.uuid", @@ -114,7 +97,6 @@ export const seoGenerateStrategySnapshotTaskOutputSchema = type({ export const taskOutputSchema = type.or( understandSiteTaskOutputSchema, - seoPlanKeywordTaskOutputSchema, seoWriteArticleTaskOutputSchema, seoUnderstandSiteTaskOutputSchema, seoStrategySuggestionsTaskOutputSchema, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 02bdb040b..810a9580d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,8 +51,6 @@ importers: specifier: ^4.0.13 version: 4.0.13(@opentelemetry/api@1.9.0)(@types/debug@4.1.12)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@26.1.0)(lightningcss@1.31.1)(terser@5.46.0)(tsx@4.20.6)(yaml@2.8.2) - apps/queries: {} - apps/seo: dependencies: '@ai-sdk/react': @@ -519,25 +517,6 @@ importers: specifier: ^4.50.0 version: 4.50.0(@cloudflare/workers-types@4.20251121.0) - packages/agents: - dependencies: - '@t3-oss/env-core': - specifier: ^0.13.10 - version: 0.13.10(arktype@2.1.29)(typescript@5.9.3)(zod@4.1.12) - arktype: - specifier: ^2.1.29 - version: 2.1.29 - devDependencies: - '@rectangular-labs/typescript': - specifier: workspace:* - version: link:../../tooling/typescript - '@types/node': - specifier: ^25.2.3 - version: 25.2.3 - typescript: - specifier: ^5.9.3 - version: 5.9.3 - packages/api-core: dependencies: '@orpc/arktype': @@ -12470,6 +12449,7 @@ packages: prebuild-install@7.1.3: resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. hasBin: true prelude-ls@1.2.1: From 0e27bd9debada353ba8c02b2872da35f5f886ada Mon Sep 17 00:00:00 2001 From: ElasticBottle Date: Thu, 26 Feb 2026 07:39:17 +0800 Subject: [PATCH 3/9] feat(api-seo): consolidate and update ai agent tools --- .../api-seo/src/lib/ai/arktype-json-schema.ts | 61 -- .../src/lib/ai/tools/dataforseo-tool.ts | 385 +++++++----- .../ai/tools/google-search-console-tool.ts | 148 ++++- .../api-seo/src/lib/ai/tools/image-tools.ts | 267 ++++---- .../src/lib/ai/tools/internal-links-tool.ts | 81 +-- .../api-seo/src/lib/ai/tools/planner-tools.ts | 48 -- packages/api-seo/src/lib/ai/tools/utils.ts | 16 - .../api-seo/src/lib/ai/tools/web-tools.ts | 178 ++++-- .../workspace-tools.ask-question-tool.ts | 109 ++++ .../tools/workspace-tools.data-access-tool.ts | 582 ++++++++++++++++++ ...s.ts => workspace-tools.settings-tools.ts} | 149 +++-- .../src/lib/ai/tools/workspace-tools.ts | 46 ++ packages/api-seo/src/lib/dataforseo/utils.ts | 51 +- packages/dataforseo/src/index.ts | 45 +- 14 files changed, 1544 insertions(+), 622 deletions(-) delete mode 100644 packages/api-seo/src/lib/ai/arktype-json-schema.ts delete mode 100644 packages/api-seo/src/lib/ai/tools/planner-tools.ts delete mode 100644 packages/api-seo/src/lib/ai/tools/utils.ts create mode 100644 packages/api-seo/src/lib/ai/tools/workspace-tools.ask-question-tool.ts create mode 100644 packages/api-seo/src/lib/ai/tools/workspace-tools.data-access-tool.ts rename packages/api-seo/src/lib/ai/tools/{settings-tools.ts => workspace-tools.settings-tools.ts} (53%) create mode 100644 packages/api-seo/src/lib/ai/tools/workspace-tools.ts diff --git a/packages/api-seo/src/lib/ai/arktype-json-schema.ts b/packages/api-seo/src/lib/ai/arktype-json-schema.ts deleted file mode 100644 index d0788b45c..000000000 --- a/packages/api-seo/src/lib/ai/arktype-json-schema.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { arkTypeJsonSchemaTransformer } from "@rectangular-labs/core/schema/arktype-json-schema-transformer"; -import { type JSONSchema7, jsonSchema } from "ai"; - -type ArkTypeWithInfer = { - infer: unknown; - toJsonSchema: () => unknown; -}; - -function addAdditionalPropertiesToJsonSchema( - jsonSchema: JSONSchema7, -): JSONSchema7 { - if (jsonSchema.type === "object") { - jsonSchema.additionalProperties = false; - const properties = jsonSchema.properties; - if (properties != null) { - for (const property in properties) { - properties[property] = addAdditionalPropertiesToJsonSchema( - properties[property] as JSONSchema7, - ); - } - } - } - if (jsonSchema.type === "array" && jsonSchema.items != null) { - if (Array.isArray(jsonSchema.items)) { - jsonSchema.items = jsonSchema.items.map((item) => - addAdditionalPropertiesToJsonSchema(item as JSONSchema7), - ); - } else { - jsonSchema.items = addAdditionalPropertiesToJsonSchema( - jsonSchema.items as JSONSchema7, - ); - } - } - if (jsonSchema.anyOf != null) { - jsonSchema.anyOf = jsonSchema.anyOf.map((schema) => - addAdditionalPropertiesToJsonSchema(schema as JSONSchema7), - ); - } - if (jsonSchema.allOf != null) { - jsonSchema.allOf = jsonSchema.allOf.map((schema) => - addAdditionalPropertiesToJsonSchema(schema as JSONSchema7), - ); - } - if (jsonSchema.oneOf != null) { - jsonSchema.oneOf = jsonSchema.oneOf.map((schema) => - addAdditionalPropertiesToJsonSchema(schema as JSONSchema7), - ); - } - return jsonSchema; -} - -export function arktypeToAiJsonSchema( - schema: TSchema, -) { - const intermediate = arkTypeJsonSchemaTransformer(schema) as JSONSchema7; - - const result = jsonSchema( - addAdditionalPropertiesToJsonSchema(intermediate), - ); - return result; -} diff --git a/packages/api-seo/src/lib/ai/tools/dataforseo-tool.ts b/packages/api-seo/src/lib/ai/tools/dataforseo-tool.ts index 23a0bddce..108a44c81 100644 --- a/packages/api-seo/src/lib/ai/tools/dataforseo-tool.ts +++ b/packages/api-seo/src/lib/ai/tools/dataforseo-tool.ts @@ -1,6 +1,12 @@ +import type { + FetchKeywordSuggestionsArgs, + FetchKeywordsOverviewArgs, + FetchRankedKeywordsForSiteArgs, + FetchRankedPagesForSiteArgs, + FetchSerpArgs, +} from "@rectangular-labs/dataforseo"; import type { schema } from "@rectangular-labs/db"; -import { tool } from "ai"; -import { type } from "arktype"; +import { jsonSchema, tool } from "ai"; import type { InitialContext } from "../../../types"; import { configureDataForSeoClient, @@ -11,104 +17,28 @@ import { fetchSerpWithCache, getLocationAndLanguage, } from "../../dataforseo/utils"; -import type { AgentToolDefinition } from "./utils"; - -const KEYWORD_RESEARCH_DATA_SOURCE = "keyword research data source"; function keywordDataSourceError(operation: string) { - return `Failed to fetch ${operation} from the ${KEYWORD_RESEARCH_DATA_SOURCE}.`; + return `Failed to fetch ${operation} from the keyword research data source.`; } -const hostnameSchema = type("string").describe( - "The domain name of the target website. The domain should be specified without 'https://' and 'www.'. Example: 'example.xyz', 'fluidposts.ai', 'google.com'", -); - -const limitSchema = (noun: string, defaultLimit: number) => - type("number") - .describe( - `The maximum number of ${noun} to fetch. The default of ${defaultLimit} means that up to ${defaultLimit} ${noun} will be fetched.`, - ) - .default(defaultLimit); -const offsetSchema = (noun: string, defaultOffset: number) => - type("number") - .describe( - `The number of ${noun} to skip. The default of ${defaultOffset} means that ${defaultOffset} ${noun} will be skipped before the first ${noun} is fetched.`, - ) - .default(defaultOffset); -const genderAndAgeDistributionSchema = type("boolean") - .describe( - "Whether to include gender and age distribution data in the keyword data. By default gender and age distribution data will not be included.", - ) - .default(false); - -// 1) Ranked Keywords for site -const rankedKeywordsInputSchema = type({ - hostname: hostnameSchema, - positionFrom: type("number") - .describe( - "The highest position ranking keywords that the site currently ranks for. The default of 1 means that any search keyword that has the site ranking at position 1 or lower will be fetched.", - ) - .default(1), - positionTo: type("number") - .describe( - "The lowest position ranking keywords that the site currently ranks for. The default of 100 means that any search keyword that has the site ranking at position 100 or higher will be fetched.", - ) - .default(100), - includeGenderAndAgeDistribution: genderAndAgeDistributionSchema, - limit: limitSchema("keywords", 100), - offset: offsetSchema("keywords", 0), -}); - -// 2) Ranked Pages for site -const rankedPagesInputSchema = type({ - hostname: hostnameSchema, - includeGenderAndAgeDistribution: genderAndAgeDistributionSchema, - limit: limitSchema("pages", 100), - offset: offsetSchema("pages", 0), -}); +const commonListOptionsProperties = { + includeGenderAndAgeDistribution: { + type: "boolean", + description: + "Whether to include demographic distribution estimates in the response (age and gender).", + }, + limit: { + type: "number", + description: "Maximum number of records to return.", + }, + offset: { + type: "number", + description: "Number of records to skip before returning results.", + }, +} as const; -// 3) Keyword Suggestions -const keywordSuggestionsInputSchema = type({ - seedKeyword: type("string").describe( - "The seed keyword to fetch suggestions for.", - ), - includeSeedKeyword: type("boolean") - .describe( - "Whether to include the seed keyword's data. By default the seed keyword's data is included.", - ) - .default(true), - includeGenderAndAgeDistribution: genderAndAgeDistributionSchema, - limit: limitSchema("suggestions", 100), - offset: offsetSchema("suggestions", 0), -}); - -// 4) Keywords Overview -const keywordsOverviewInputSchema = type({ - keywords: "string[]", - includeGenderAndAgeDistribution: genderAndAgeDistributionSchema, -}); - -// 5) SERP (Advanced) -const serpInputSchema = type({ - keyword: "string", - depth: type("number") - .describe( - "The number of results in the SERP to fetch. The default of 10 means that the top 10 results will be fetched.", - ) - .default(10), - device: type("'desktop'|'mobile'") - .describe( - "The device to fetch the SERP for. The default uses the 'desktop' device.", - ) - .default("desktop"), - os: type("'windows'|'macos'|'android'|'ios'") - .describe( - "The operating system to fetch the SERP for. The default uses the 'macos' operating system.", - ) - .default("macos"), -}); - -export function createDataforseoToolWithMetadata( +export function createDataforseoTool( project: typeof schema.seoProject.$inferSelect, cacheKV: InitialContext["cacheKV"], ) { @@ -116,15 +46,53 @@ export function createDataforseoToolWithMetadata( const { locationName, languageCode } = getLocationAndLanguage(project); const getRankedKeywordsForSite = tool({ - description: "Fetch keywords that the site currently ranks for.", - inputSchema: rankedKeywordsInputSchema, + description: + "Fetch keywords that the site currently ranks for. Use for profiling your site or competitors and finding opportunity clusters.", + inputSchema: jsonSchema< + Omit + >({ + type: "object", + additionalProperties: false, + required: ["hostname"], + properties: { + hostname: { + type: "string", + description: + "Domain without protocol or www. Example: example.com, fluidposts.ai", + }, + positionFrom: { + type: "number", + }, + positionTo: { + type: "number", + }, + ...commonListOptionsProperties, + }, + }), + inputExamples: [ + { + input: { + hostname: "zapier.com", + }, + }, + { + input: { + hostname: "notion.so", + positionFrom: 1, + positionTo: 20, + limit: 50, + offset: 0, + includeGenderAndAgeDistribution: false, + }, + }, + ], async execute({ hostname, - positionFrom, - positionTo, - limit, - offset, - includeGenderAndAgeDistribution, + positionFrom = 1, + positionTo = 100, + limit = 100, + offset = 0, + includeGenderAndAgeDistribution = false, }) { console.log("fetchRankedKeywordsForSite", { hostname, @@ -186,13 +154,45 @@ export function createDataforseoToolWithMetadata( // Ranked Pages tool const getRankedPagesForSite = tool({ - description: "Fetch pages of the site that are currently ranked.", - inputSchema: rankedPagesInputSchema, + description: + "Fetch ranked pages for a site. Use to infer top-performing templates and content patterns.", + inputSchema: jsonSchema< + Omit< + FetchRankedPagesForSiteArgs, + "target" | "locationName" | "languageCode" + > & { hostname: string } + >({ + type: "object", + additionalProperties: false, + required: ["hostname"], + properties: { + hostname: { + type: "string", + description: + "Domain without protocol or www. Example: example.com, fluidposts.ai", + }, + ...commonListOptionsProperties, + }, + }), + inputExamples: [ + { + input: { + hostname: "hubspot.com", + }, + }, + { + input: { + hostname: "ahrefs.com", + limit: 25, + offset: 25, + }, + }, + ], async execute({ hostname, - limit, - offset, - includeGenderAndAgeDistribution, + limit = 100, + offset = 0, + includeGenderAndAgeDistribution = false, }) { console.log("fetchRankedPagesForSite", { hostname, @@ -224,14 +224,46 @@ export function createDataforseoToolWithMetadata( // Keyword Suggestions tool const getKeywordSuggestions = tool({ - description: "Generate keyword suggestions based of a seed keyword.", - inputSchema: keywordSuggestionsInputSchema, + description: + "Generate keyword suggestions from a seed keyword. Use to expand a topic cluster before prioritization.", + inputSchema: jsonSchema< + Omit + >({ + type: "object", + additionalProperties: false, + required: ["seedKeyword"], + properties: { + seedKeyword: { + type: "string", + description: "Seed keyword to generate suggestions for.", + }, + includeSeedKeyword: { + type: "boolean", + }, + ...commonListOptionsProperties, + }, + }), + inputExamples: [ + { + input: { + seedKeyword: "invoice automation", + }, + }, + { + input: { + seedKeyword: "local seo audit", + includeSeedKeyword: false, + limit: 30, + offset: 0, + }, + }, + ], async execute({ seedKeyword, - limit, - offset, - includeSeedKeyword, - includeGenderAndAgeDistribution, + limit = 100, + offset = 0, + includeSeedKeyword = true, + includeGenderAndAgeDistribution = false, }) { console.log("fetchKeywordSuggestions", { seedKeyword, @@ -286,9 +318,45 @@ export function createDataforseoToolWithMetadata( // Keywords Overview tool const getKeywordOverview = tool({ - description: "Fetch data for a list of keywords.", - inputSchema: keywordsOverviewInputSchema, - async execute({ keywords, includeGenderAndAgeDistribution }) { + description: + "Fetch overview metrics for a list of keywords. Use to compare volume, difficulty, and intent for shortlist decisions.", + inputSchema: jsonSchema< + Omit + >({ + type: "object", + additionalProperties: false, + required: ["keywords"], + properties: { + keywords: { + type: "array", + minItems: 1, + items: { + type: "string", + }, + }, + includeGenderAndAgeDistribution: { + ...commonListOptionsProperties.includeGenderAndAgeDistribution, + }, + }, + }), + inputExamples: [ + { + input: { + keywords: ["invoice automation", "accounts payable automation"], + }, + }, + { + input: { + keywords: [ + "best seo tools for agencies", + "seo platform comparison", + "enterprise seo software", + ], + includeGenderAndAgeDistribution: false, + }, + }, + ], + async execute({ keywords, includeGenderAndAgeDistribution = false }) { console.log("fetchKeywordsOverview", { keywords, includeGenderAndAgeDistribution, @@ -336,9 +404,47 @@ export function createDataforseoToolWithMetadata( // SERP (Advanced) tool const getSerpForKeyword = tool({ - description: "Fetch search engine results page (SERP) for a keyword.", - inputSchema: serpInputSchema, - async execute({ keyword, depth, device, os }) { + description: + "Inspect live SERP results for a keyword. Use to understand intent, ranking patterns, and SERP features.", + inputSchema: jsonSchema< + Omit + >({ + type: "object", + additionalProperties: false, + required: ["keyword"], + properties: { + keyword: { + type: "string", + }, + depth: { + type: "number", + }, + device: { + type: "string", + enum: ["desktop", "mobile"], + }, + os: { + type: "string", + enum: ["windows", "macos", "android", "ios"], + }, + }, + }), + inputExamples: [ + { + input: { + keyword: "best ai writing tools", + }, + }, + { + input: { + keyword: "seo audit checklist", + depth: 20, + device: "mobile", + os: "android", + }, + }, + ], + async execute({ keyword, depth = 10, device = "desktop", os = "macos" }) { console.log("fetchSerp", { keyword, depth, device, os }); const result = await fetchSerpWithCache({ keyword, @@ -368,44 +474,5 @@ export function createDataforseoToolWithMetadata( get_keywords_overview: getKeywordOverview, get_serp_for_keyword: getSerpForKeyword, } as const; - - const toolDefinitions: AgentToolDefinition[] = [ - { - toolName: "get_ranked_keywords_for_site", - toolDescription: "Fetch keywords a site currently ranks for.", - toolInstruction: - "Provide hostname without protocol. Use for profiling your site or competitors, and to discover ranking keyword clusters. Tune positionFrom/positionTo and limit/offset.", - tool: getRankedKeywordsForSite, - }, - { - toolName: "get_ranked_pages_for_site", - toolDescription: "Fetch ranked pages for a site.", - toolInstruction: - "Provide hostname without protocol. Use to find top pages and infer content strategy and templates.", - tool: getRankedPagesForSite, - }, - { - toolName: "get_keyword_suggestions", - toolDescription: "Generate keyword suggestions from a seed keyword.", - toolInstruction: - "Provide seedKeyword. Use to expand a topic cluster; then follow with get_keywords_overview for prioritization.", - tool: getKeywordSuggestions, - }, - { - toolName: "get_keywords_overview", - toolDescription: "Fetch overview metrics for a list of keywords.", - toolInstruction: - "Provide keywords[]. Use to compare search volume, difficulty, and intent across a short shortlist.", - tool: getKeywordOverview, - }, - { - toolName: "get_serp_for_keyword", - toolDescription: "Inspect live SERP results for a keyword.", - toolInstruction: - "Provide keyword and optional device/os/depth. Use to understand intent, SERP features, and top-ranking page patterns.", - tool: getSerpForKeyword, - }, - ]; - - return { toolDefinitions, tools }; + return { tools }; } diff --git a/packages/api-seo/src/lib/ai/tools/google-search-console-tool.ts b/packages/api-seo/src/lib/ai/tools/google-search-console-tool.ts index e4b2422f2..cde9d22fb 100644 --- a/packages/api-seo/src/lib/ai/tools/google-search-console-tool.ts +++ b/packages/api-seo/src/lib/ai/tools/google-search-console-tool.ts @@ -1,31 +1,139 @@ import { getSearchAnalyticsArgsSchema, + gscFilterSchema, NO_SEARCH_CONSOLE_ERROR_MESSAGE, - type seoGscPropertyTypeSchema, } from "@rectangular-labs/core/schemas/gsc-property-parsers"; import { getSearchAnalytics } from "@rectangular-labs/google-apis/google-search-console"; -import { tool } from "ai"; -import { arktypeToAiJsonSchema } from "../arktype-json-schema"; -import type { AgentToolDefinition } from "./utils"; +import { jsonSchema, tool } from "ai"; -const gscQueryInputSchema = getSearchAnalyticsArgsSchema.omit( - "siteType", - "siteUrl", -); +const gscQueryInputSchema = jsonSchema< + Omit +>({ + type: "object", + additionalProperties: false, + required: ["startDate", "endDate"], + properties: { + startDate: { + type: "string", + pattern: "^\\d{4}-\\d{2}-\\d{2}$", + description: getSearchAnalyticsArgsSchema.get("startDate").description, + }, + endDate: { + type: "string", + pattern: "^\\d{4}-\\d{2}-\\d{2}$", + description: getSearchAnalyticsArgsSchema.get("endDate").description, + }, + dimensions: { + type: "array", + items: { + type: "string", + enum: [ + "query", + "page", + "country", + "device", + "searchAppearance", + "date", + "hour", + ], + description: getSearchAnalyticsArgsSchema.get("dimensions").description, + }, + }, + type: { + type: "string", + enum: ["discover", "googleNews", "news", "video", "image", "web"], + description: getSearchAnalyticsArgsSchema.get("type").description, + }, + aggregationType: { + type: "string", + enum: ["auto", "byNewsShowcasePanel", "byPage", "byProperty"], + description: + getSearchAnalyticsArgsSchema.get("aggregationType").description, + }, + rowLimit: { + type: "number", + maximum: 500, + description: getSearchAnalyticsArgsSchema.get("rowLimit").description, + }, + startRow: { + type: "number", + minimum: 0, + description: getSearchAnalyticsArgsSchema.get("startRow").description, + }, + filters: { + type: "array", + items: { + type: "object", + additionalProperties: false, + required: ["dimension", "operator", "expression"], + properties: { + dimension: { + type: "string", + enum: ["query", "page", "country", "device", "searchAppearance"], + description: gscFilterSchema.get("dimension").description, + }, + operator: { + type: "string", + enum: [ + "equals", + "notEquals", + "contains", + "notContains", + "includingRegex", + "excludingRegex", + ], + description: gscFilterSchema.get("operator").description, + }, + expression: { + type: "string", + description: gscFilterSchema.get("expression").description, + }, + }, + description: gscFilterSchema.description, + }, + }, + }, +}); -export function createGscToolWithMetadata({ +export function createGscTool({ accessToken, siteUrl, siteType, }: { accessToken: string | null; siteUrl: string | null; - siteType: typeof seoGscPropertyTypeSchema.infer | null; + siteType: "URL_PREFIX" | "DOMAIN" | null; }) { const gscTool = tool({ - description: - "Query the project's Google Search Console account for searchAnalytics for a given date range.", - inputSchema: arktypeToAiJsonSchema(gscQueryInputSchema), + description: `Query the project's Google Search Console account for searchAnalytics for a given date range. +Use for performance/CTR/decay analysis. Provide date range, dimensions, rowLimit, and filters. +If this returns success:false with next_step, switch to helping connect GSC.`, + inputSchema: gscQueryInputSchema, + inputExamples: [ + { + input: { + startDate: "2026-01-01", + endDate: "2026-01-31", + dimensions: ["query", "page"], + rowLimit: 100, + }, + }, + { + input: { + startDate: "2025-11-01", + endDate: "2025-11-30", + dimensions: ["page"], + type: "web", + filters: [ + { + dimension: "page", + operator: "contains", + expression: "/blog/", + }, + ], + }, + }, + ], async execute(props) { console.log("getSearchAnalytics", { siteUrl, @@ -55,17 +163,5 @@ export function createGscToolWithMetadata({ const tools = { google_search_console_query: gscTool, } as const; - - const toolDefinitions: AgentToolDefinition[] = [ - { - toolName: "google_search_console_query", - toolDescription: - "Query Google Search Console searchAnalytics for the project site.", - toolInstruction: - "Use for performance/CTR/decay analysis. Provide date range, dimensions (e.g. ['query','page']), rowLimit, and filters. If it returns success:false with next_step, switch to helping connect GSC.", - tool: gscTool, - }, - ]; - - return { toolDefinitions, tools }; + return { tools }; } diff --git a/packages/api-seo/src/lib/ai/tools/image-tools.ts b/packages/api-seo/src/lib/ai/tools/image-tools.ts index 13dfa630f..f94113adf 100644 --- a/packages/api-seo/src/lib/ai/tools/image-tools.ts +++ b/packages/api-seo/src/lib/ai/tools/image-tools.ts @@ -2,7 +2,7 @@ import { type GoogleGenerativeAIProviderOptions, google } from "@ai-sdk/google"; import { getExtensionFromMimeType } from "@rectangular-labs/core/project/get-extension-from-mimetype"; import type { imageSettingsSchema } from "@rectangular-labs/core/schemas/project-parsers"; import { uuidv7 } from "@rectangular-labs/db"; -import { generateText, Output, tool } from "ai"; +import { generateText, jsonSchema, Output, tool } from "ai"; import { type } from "arktype"; import { apiEnv } from "../../../env"; import type { InitialContext } from "../../../types"; @@ -15,35 +15,6 @@ import { searchPixabay, searchUnsplash, } from "./image-tools.image-providers"; -import type { AgentToolDefinition } from "./utils"; - -const imageAgentInputSchema = type({ - prompt: type("string").describe("The prompt to generate an image for."), -}); - -const screenshotInputSchema = type({ - url: type("string").describe( - "The URL to capture a screenshot of. Must be a valid URL.", - ), - viewportWidth: type("number.integer >= 480") - .describe("The width of the viewport to capture a screenshot of.") - .default(1280), - viewportHeight: type("number.integer >= 480") - .describe("The height of the viewport to capture a screenshot of.") - .default(720), - fullPage: type("boolean") - .describe("Whether to capture a full page screenshot.") - .default(false), -}); - -const stockImageInputSchema = type({ - searchQuery: type("string").describe( - "The search query that describes the image to search for.", - ), - orientation: type("'landscape'|'portrait'|'square'") - .describe("The orientation of the image to search for.") - .default("landscape"), -}); async function selectBestStockImageIndex(args: { query: string; @@ -80,15 +51,42 @@ Attached are ${args.candidates.length} images. Return {"index": N} where N is -1 return index; } -export function createImageToolsWithMetadata(args: { +export function createImageTools(args: { organizationId: string; projectId: string; imageSettings: typeof imageSettingsSchema.infer | null; publicImagesBucket: InitialContext["publicImagesBucket"]; }) { const generateImage = tool({ - description: "Generate an image based on a prompt.", - inputSchema: imageAgentInputSchema, + description: + "Generate a project image based on the project's image settings and a prompt. Use for custom visuals like infographics, diagrams, and conceptual hero images.", + inputSchema: jsonSchema<{ + prompt: string; + }>({ + type: "object", + additionalProperties: false, + required: ["prompt"], + properties: { + prompt: { + type: "string", + description: "The prompt to generate an image for.", + }, + }, + }), + inputExamples: [ + { + input: { + prompt: + "A clean isometric diagram of an invoice automation workflow with OCR, validation, and ERP sync.", + }, + }, + { + input: { + prompt: + "Minimal flat vector hero image showing a marketing analytics dashboard and trend lines.", + }, + }, + ], execute: async ({ prompt }) => { const { imageSettings } = args; if (!imageSettings) { @@ -169,36 +167,71 @@ export function createImageToolsWithMetadata(args: { ), }; }, - // toModelOutput(result) { - // return { - // type: "content", - // value: [ - // { - // type: "text" as const, - // text: result.success ? result.imageUris.join(", ") : result.message, - // }, - // ...(result.imageUris?.map((uri) => ({ - // type: "media" as const, - // data: uri, - // mediaType: "image/jpeg", - // })) ?? []), - // ], - // }; - // }, + toModelOutput({ output }) { + if (!output.success) { + return { + type: "content" as const, + value: [{ type: "text" as const, text: output.message }], + }; + } + + return { + type: "content" as const, + value: [ + { type: "text" as const, text: output.message }, + ...output.imageUris.map((uri) => ({ + type: "media" as const, + data: uri, + mediaType: "image/jpeg", + })), + ], + }; + }, }); const findStockImage = tool({ description: "Find a royalty-free stock image based on a search query. Returns the best match with attribution details. You must put the attribution as the image caption.", - inputSchema: stockImageInputSchema, - execute: async ({ searchQuery, orientation }) => { - const providers: (typeof imageSettingsSchema.infer)["stockImageProviders"] = - args.imageSettings?.stockImageProviders ?? [ - "pixabay", - "unsplash", - "pexels", - ]; - const effectiveOrientation = orientation ?? "landscape"; + inputSchema: jsonSchema<{ + searchQuery: string; + orientation?: "landscape" | "portrait" | "square"; + }>({ + type: "object", + additionalProperties: false, + required: ["searchQuery"], + properties: { + searchQuery: { + type: "string", + description: "The search query describing the desired image.", + }, + orientation: { + type: "string", + enum: ["landscape", "portrait", "square"], + default: "landscape", + description: "The orientation of the image to search for.", + }, + }, + }), + inputExamples: [ + { + input: { + searchQuery: "team collaborating around a laptop in office", + orientation: "landscape", + }, + }, + { + input: { + searchQuery: "city skyline at sunrise", + orientation: "portrait", + }, + }, + ], + execute: async ({ searchQuery, orientation = "landscape" }) => { + const providers = args.imageSettings?.stockImageProviders ?? [ + "pixabay", + "unsplash", + "pexels", + ]; const providerSearchers = { unsplash: searchUnsplash, @@ -212,7 +245,7 @@ export function createImageToolsWithMetadata(args: { const candidates = await search({ query: searchQuery, - orientation: effectiveOrientation, + orientation, }).catch(() => []); if (!candidates.length) continue; @@ -282,15 +315,56 @@ export function createImageToolsWithMetadata(args: { const captureScreenshotTool = tool({ description: "Capture a rendered screenshot of a given website URL using ScreenshotOne. Blocks ads, cookie banners, and common overlays. Stores the screenshot in the public bucket (optionally optimized to WebP).", - inputSchema: screenshotInputSchema, - async execute({ url, viewportWidth, viewportHeight, fullPage }) { + inputSchema: jsonSchema<{ + url: string; + viewportWidth?: number; + viewportHeight?: number; + }>({ + type: "object", + additionalProperties: false, + required: ["url"], + properties: { + url: { + type: "string", + description: + "The URL to capture a screenshot of. Must be a valid URL.", + }, + viewportWidth: { + type: "integer", + minimum: 480, + default: 1280, + description: "Viewport width for the screenshot.", + }, + viewportHeight: { + type: "integer", + minimum: 480, + default: 720, + description: "Viewport height for the screenshot.", + }, + }, + }), + inputExamples: [ + { + input: { + url: "https://example.com", + }, + }, + { + input: { + url: "https://example.com/pricing", + viewportWidth: 1440, + viewportHeight: 900, + }, + }, + ], + async execute({ url, viewportWidth = 1280, viewportHeight = 720 }) { const result = await captureScreenshotOne({ url, viewport: { width: viewportWidth, height: viewportHeight, }, - fullPage, + fullPage: false, }); if (!result.ok) { @@ -327,26 +401,26 @@ export function createImageToolsWithMetadata(args: { screenshot: `${apiEnv().SEO_PUBLIC_BUCKET_URL}/${stored.value.key}`, }; }, - // toModelOutput(result) { - // return { - // type: "content", - // value: [ - // { - // type: "text" as const, - // text: result.success ? result.screenshot : result.message, - // }, - // ...(result.screenshot - // ? [ - // { - // type: "media" as const, - // data: result.screenshot, - // mediaType: "image/jpeg", - // }, - // ] - // : []), - // ], - // }; - // }, + toModelOutput({ output }) { + if (!output.success) { + return { + type: "content" as const, + value: [{ type: "text" as const, text: output.message }], + }; + } + + return { + type: "content" as const, + value: [ + { type: "text" as const, text: output.screenshot }, + { + type: "media" as const, + data: output.screenshot, + mediaType: "image/jpeg", + }, + ], + }; + }, }); const tools = { @@ -354,32 +428,5 @@ export function createImageToolsWithMetadata(args: { find_stock_image: findStockImage, capture_screenshot: captureScreenshotTool, } as const; - const toolDefinitions: AgentToolDefinition[] = [ - { - toolName: "generate_image", - toolDescription: - "Generate a project image based on the project's image settings and a prompt. Helpful for generating a custom image for the project like an infographic, flowchart, or other visual representation.", - toolInstruction: - "Provide `prompt` describing the desired image. Use when the user asks for image generation. If image settings are missing, instruct the user to configure image settings first.", - tool: generateImage, - }, - { - toolName: "find_stock_image", - toolDescription: - "Find a royalty-free stock image based on a search query. Returns the best match with attribution details. Helpful for finding an image that is relevant to the project's content like of certain objects, places, etc.", - toolInstruction: - "Provide `searchQuery` and optionally `orientation` (landscape/portrait/square). Use when you need a royalty-free stock image with source and photographer attribution. You must put the attribution as the image caption.", - tool: findStockImage, - }, - { - toolName: "capture_screenshot", - toolDescription: - "Capture a rendered screenshot of a given website URL. Helpful for capturing a screenshot of a landing or product page.", - toolInstruction: - "Provide `url` to capture a screenshot of. Optionally specify `viewportWidth`, `viewportHeight`, and `fullPage`. This tool is best used when you need to capture a screenshot of a website, normally of a landing or product page.", - tool: captureScreenshotTool, - }, - ]; - - return { toolDefinitions, tools }; + return { tools }; } diff --git a/packages/api-seo/src/lib/ai/tools/internal-links-tool.ts b/packages/api-seo/src/lib/ai/tools/internal-links-tool.ts index d4dff2972..db0c6fd3a 100644 --- a/packages/api-seo/src/lib/ai/tools/internal-links-tool.ts +++ b/packages/api-seo/src/lib/ai/tools/internal-links-tool.ts @@ -1,40 +1,54 @@ -import { COUNTRY_CODE_MAP } from "@rectangular-labs/core/schemas/project-parsers"; import { fetchSerp } from "@rectangular-labs/dataforseo"; -import { tool } from "ai"; -import { type } from "arktype"; -import { configureDataForSeoClient } from "../../dataforseo/utils"; -import type { AgentToolDefinition } from "./utils"; +import type { schema } from "@rectangular-labs/db"; +import { jsonSchema, tool } from "ai"; +import { + configureDataForSeoClient, + getLocationAndLanguage, +} from "../../dataforseo/utils"; -const internalLinksInputSchema = type({ - query: type("string") - .atLeastLength(1) - .describe("Query/keyword to search for in SERP."), - countryCode: type("string") - .atLeastLength(2) - .describe("2-letter country code (e.g. US, GB, CA)."), - languageCode: type("string") - .atLeastLength(2) - .describe("2-letter language code (e.g. en, es)."), -}); +export function createInternalLinksTool( + project: typeof schema.seoProject.$inferSelect, +) { + const { locationName, languageCode } = getLocationAndLanguage(project); + const targetUrl = project.websiteUrl; -type InternalLinksInput = typeof internalLinksInputSchema.infer; - -export function createInternalLinksToolWithMetadata(targetUrl: string) { const internalLinks = tool({ description: "Query SERP results for a query, constrained to a target URL, and return the organic results.", - inputSchema: internalLinksInputSchema, - execute: async (input: InternalLinksInput) => { + inputSchema: jsonSchema<{ + query: string; + }>({ + type: "object", + additionalProperties: false, + required: ["query"], + properties: { + query: { + type: "string", + minLength: 1, + description: + "Query/keyword to search for that we want to find related content previously written on.", + }, + }, + }), + inputExamples: [ + { + input: { + query: "invoice automation software", + }, + }, + { + input: { + query: "AI content generation", + }, + }, + ], + execute: async (input) => { configureDataForSeoClient(); - const locationName = - COUNTRY_CODE_MAP[input.countryCode] ?? "United States"; - const serpResult = await fetchSerp({ - keyword: input.query, + keyword: `${input.query} site:${targetUrl}`, locationName, - languageCode: input.languageCode, - targetUrl, + languageCode, }); if (!serpResult.ok) { @@ -71,16 +85,5 @@ export function createInternalLinksToolWithMetadata(targetUrl: string) { }); const tools = { internal_links: internalLinks } as const; - const toolDefinitions: AgentToolDefinition[] = [ - { - toolName: "internal_links", - toolDescription: - "Query SERP results for a query, constrained to a target URL, and return the organic results.", - toolInstruction: - "Provide query, countryCode, and languageCode. The tool will query SERP and return the organic results (url, title, description, extendedSnippet).", - tool: internalLinks, - }, - ]; - - return { toolDefinitions, tools }; + return { tools }; } diff --git a/packages/api-seo/src/lib/ai/tools/planner-tools.ts b/packages/api-seo/src/lib/ai/tools/planner-tools.ts deleted file mode 100644 index 78bee779a..000000000 --- a/packages/api-seo/src/lib/ai/tools/planner-tools.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { tool } from "ai"; -import { type } from "arktype"; -import type { AgentToolDefinition } from "./utils"; - -const askQuestionInputSchema = type({ - questions: type({ - id: type("string").describe("Unique identifier for the question."), - prompt: type("string").describe("The question to ask the user."), - options: type({ - id: type("string").describe("Unique identifier for the option."), - label: type("string").describe( - "Label for the option in respond to the question prompt", - ), - }).array(), - "allow_multiple?": type("boolean").describe( - "Whether to allow the user to select multiple options in respond to the question prompt", - ), - }).array(), -}); - -export function createPlannerToolsWithMetadata() { - const askQuestions = tool({ - description: - 'Ask the user clarification questions to help provide clarity to the request. Returns immediately while waiting for the user\'s response. By default the "other" option is always added to each question so you can omit that option from the options array.', - inputSchema: askQuestionInputSchema, - async execute() { - return await Promise.resolve({ - success: true, - message: "Questions posted. Pending user response.", - }); - }, - }); - - const tools = { ask_questions: askQuestions } as const; - - const toolDefinitions: AgentToolDefinition[] = [ - { - toolName: "ask_questions", - toolDescription: - "Ask the user structured follow-up questions (multiple choice) and pause for an answer.", - toolInstruction: - "Use when missing info would materially change the approach. Provide 1-6 questions. Each question needs an id, prompt, and options[] (id+label). Set allow_multiple=true only if multiple selections are valid. Keep questions crisp and decision-driving.", - tool: askQuestions, - }, - ]; - - return { toolDefinitions, tools }; -} diff --git a/packages/api-seo/src/lib/ai/tools/utils.ts b/packages/api-seo/src/lib/ai/tools/utils.ts deleted file mode 100644 index 42576b151..000000000 --- a/packages/api-seo/src/lib/ai/tools/utils.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { ToolSet } from "ai"; - -export interface AgentToolDefinition { - toolName: string; - toolDescription: string; - toolInstruction: string; - callDirect?: boolean; - tool: ToolSet[string]; -} - -export function formatToolSkillsSection( - definitions: readonly AgentToolDefinition[], -): string { - const lines = definitions.map((d) => `- ${d.toolName}: ${d.toolDescription}`); - return lines.length > 0 ? lines.join("\n") : "- (no tools available)"; -} diff --git a/packages/api-seo/src/lib/ai/tools/web-tools.ts b/packages/api-seo/src/lib/ai/tools/web-tools.ts index 63656c7a8..fac58ff58 100644 --- a/packages/api-seo/src/lib/ai/tools/web-tools.ts +++ b/packages/api-seo/src/lib/ai/tools/web-tools.ts @@ -1,7 +1,6 @@ import { google } from "@ai-sdk/google"; import type { schema } from "@rectangular-labs/db"; -import { generateText, Output, stepCountIs, tool } from "ai"; -import { type } from "arktype"; +import { generateText, jsonSchema, Output, stepCountIs, tool } from "ai"; import type { InitialContext } from "../../../types"; import { configureDataForSeoClient, @@ -9,41 +8,8 @@ import { getLocationAndLanguage, } from "../../dataforseo/utils"; import { logAgentStep } from "../utils/log-agent-step"; -import type { AgentToolDefinition } from "./utils"; -const webFetchInputSchema = type({ - url: type("string").describe("The URL to fetch."), - query: type("string").describe( - "What to find or answer from the page content.", - ), -}); - -const webSearchInputSchema = type({ - instruction: type("string").describe( - "The instruction for what the web search should focus on. The instruction should be focused on a singular goal or topic. Break out multiple goals into multiple instructions via multiple web_search tool calls.", - ), - queries: type("string[]").describe( - "The queries to that the search itself should be focused on that would help fulfill the instruction.", - ), -}); - -const webSearchOutputSchema = type({ - results: type({ - url: type("string").describe( - "The URL of the site that contains the relevant information.", - ), - siteGroundingText: type("string").describe( - "The text from the site that grounds the relevant information.", - ), - relevantInformation: type("string").describe( - "The relevant information from the site that helps fulfill the instruction.", - ), - }) - .array() - .describe("Array of relevant web search results."), -}); - -export function createWebToolsWithMetadata( +export function createWebTools( project: typeof schema.seoProject.$inferSelect, cacheKV: InitialContext["cacheKV"], ) { @@ -53,7 +19,38 @@ export function createWebToolsWithMetadata( const webFetch = tool({ description: "Fetch a webpage and answer a specific query regarding a webpage.", - inputSchema: webFetchInputSchema, + inputSchema: jsonSchema<{ + url: string; + query: string; + }>({ + type: "object", + additionalProperties: false, + required: ["url", "query"], + properties: { + url: { + type: "string", + description: "The URL to fetch.", + }, + query: { + type: "string", + description: "What to find or answer from the page content.", + }, + }, + }), + inputExamples: [ + { + input: { + url: "https://developers.google.com/search/docs/fundamentals/seo-starter-guide", + query: "Summarize how Google recommends writing useful titles.", + }, + }, + { + input: { + url: "https://example.com/pricing", + query: "Extract only the monthly pricing tiers and limits.", + }, + }, + ], async execute({ url, query }) { const prompt = [ "Fetch the page content using url_context and answer the query.", @@ -97,8 +94,52 @@ export function createWebToolsWithMetadata( const webSearch = tool({ description: - "Run a live web search for up-to-date information. This tool uses the web to find the latest information on the given queries.", - inputSchema: webSearchInputSchema, + "Run a live web search for up-to-date information. This tool uses the web to find the latest information based off the the given instruction and queries.", + inputSchema: jsonSchema<{ + instruction: string; + queries: string[]; + }>({ + type: "object", + additionalProperties: false, + required: ["instruction", "queries"], + properties: { + instruction: { + type: "string", + description: + "The instruction for what the web search should focus on. Keep it focused on a singular goal or topic. Break out multiple goals into multiple instructions via multiple web_search tool calls.", + }, + queries: { + type: "array", + minItems: 1, + description: "Search queries that will help fulfill the instruction.", + items: { + type: "string", + }, + }, + }, + }), + inputExamples: [ + { + input: { + instruction: + "Find reliable sources comparing AI content detection false positives.", + queries: [ + "ai content detector false positives study", + "llm detector reliability research", + ], + }, + }, + { + input: { + instruction: + "Find current guidance on internal linking for large websites.", + queries: [ + "internal linking best practices large sites", + "site architecture internal links seo", + ], + }, + }, + ], async execute({ instruction, queries }) { if (queries.length === 0) { return { @@ -169,7 +210,44 @@ ${item.result const { output: object } = await generateText({ model: google("gemini-3-flash-preview"), output: Output.object({ - schema: webSearchOutputSchema, + schema: jsonSchema<{ + results: { + url: string; + siteGroundingText: string; + relevantInformation: string; + }[]; + }>({ + type: "object", + additionalProperties: false, + required: ["results"], + properties: { + results: { + type: "array", + description: "Array of relevant web search results.", + items: { + type: "object", + additionalProperties: false, + required: ["url", "siteGroundingText", "relevantInformation"], + properties: { + url: { + type: "string", + description: + "The URL of the site that contains the relevant information.", + }, + siteGroundingText: { + type: "string", + description: "Grounding text pulled from the site.", + }, + relevantInformation: { + type: "string", + description: + "Relevant information that helps fulfill the instruction.", + }, + }, + }, + }, + }, + }), }), system: "You are a precise research assistant.", prompt, @@ -198,23 +276,5 @@ ${item.result }); const tools = { web_fetch: webFetch, web_search: webSearch } as const; - const toolDefinitions: AgentToolDefinition[] = [ - { - toolName: "web_fetch", - toolDescription: - "Fetch and render a URL, returning readable Markdown of the page.", - toolInstruction: - "Provide url. Use to quote/summarize competitor pages, docs, pricing, and SERP landing pages.", - tool: webFetch, - }, - { - toolName: "web_search", - toolDescription: "Run a live web search for up-to-date information.", - toolInstruction: - "Provide an instruction for what the web search should focus on and a list of queries to that the search itself should be focused on. Use for competitor research, definitions, current best practices, and finding relevant URLs to fetch.", - tool: webSearch, - }, - ]; - - return { toolDefinitions, tools }; + return { tools }; } diff --git a/packages/api-seo/src/lib/ai/tools/workspace-tools.ask-question-tool.ts b/packages/api-seo/src/lib/ai/tools/workspace-tools.ask-question-tool.ts new file mode 100644 index 000000000..5cb43d183 --- /dev/null +++ b/packages/api-seo/src/lib/ai/tools/workspace-tools.ask-question-tool.ts @@ -0,0 +1,109 @@ +import { jsonSchema, tool } from "ai"; + +export function createAskQuestionsTool() { + const askQuestions = tool({ + description: + 'Ask the user clarification questions to help provide clarity to the request. Returns immediately while waiting for the user\'s response. By default the "other" option is always added to each question so you can omit that option from the options array.', + inputSchema: jsonSchema<{ + questions: { + id: string; + prompt: string; + options: { + id: string; + label: string; + }[]; + allow_multiple?: boolean; + }[]; + }>({ + type: "object", + additionalProperties: false, + required: ["questions"], + properties: { + questions: { + type: "array", + minItems: 1, + items: { + type: "object", + additionalProperties: false, + required: ["id", "prompt", "options"], + properties: { + id: { + type: "string", + description: "Unique identifier for the question.", + }, + prompt: { + type: "string", + description: "The question to ask the user.", + }, + options: { + type: "array", + minItems: 1, + items: { + type: "object", + additionalProperties: false, + required: ["id", "label"], + properties: { + id: { + type: "string", + description: "Unique identifier for the option.", + }, + label: { + type: "string", + description: "Label for the option shown to the user.", + }, + }, + }, + }, + allow_multiple: { + type: "boolean", + description: + "Whether to allow selecting multiple options for this question.", + }, + }, + }, + }, + }, + }), + inputExamples: [ + { + input: { + questions: [ + { + id: "target_audience", + prompt: "Who is the article for?", + options: [ + { id: "beginners", label: "Beginners" }, + { id: "technical", label: "Technical practitioners" }, + ], + }, + ], + }, + }, + { + input: { + questions: [ + { + id: "channels", + prompt: "Which channels should we prioritize?", + options: [ + { id: "seo", label: "SEO" }, + { id: "social", label: "Social" }, + { id: "email", label: "Email" }, + ], + allow_multiple: true, + }, + ], + }, + }, + ], + async execute() { + return await Promise.resolve({ + success: true, + message: "Questions posted. Pending user response.", + }); + }, + }); + + const tools = { ask_questions: askQuestions } as const; + return { tools }; +} diff --git a/packages/api-seo/src/lib/ai/tools/workspace-tools.data-access-tool.ts b/packages/api-seo/src/lib/ai/tools/workspace-tools.data-access-tool.ts new file mode 100644 index 000000000..e742d2f16 --- /dev/null +++ b/packages/api-seo/src/lib/ai/tools/workspace-tools.data-access-tool.ts @@ -0,0 +1,582 @@ +import { + ARTICLE_TYPES, + CONTENT_STATUSES, +} from "@rectangular-labs/core/schemas/content-parsers"; +import { + CONTENT_ROLES, + STRATEGY_STATUSES, +} from "@rectangular-labs/core/schemas/strategy-parsers"; +import type { DB, schema } from "@rectangular-labs/db"; +import { + getDraftById, + getStrategyDetails, + listContentDraftsWithLatestSnapshot, + listStrategiesByProjectId, + softDeleteDraft, + softDeleteStrategy, + updateContentDraft, + updateStrategy, +} from "@rectangular-labs/db/operations"; +import { jsonSchema, tool } from "ai"; + +export function createDataAccessTools(args: { + db: DB; + organizationId: string; + projectId: string; +}) { + const listData = tool({ + description: + "List existing project strategies or content drafts. Use this before read/search/update/delete when you need IDs.", + inputSchema: jsonSchema<{ + entityType: "strategies" | "contentDrafts"; + limit?: number; + }>({ + type: "object", + additionalProperties: false, + required: ["entityType"], + properties: { + entityType: { + type: "string", + enum: ["strategies", "contentDrafts"], + }, + limit: { + type: "integer", + minimum: 1, + maximum: 200, + default: 50, + }, + }, + }), + inputExamples: [ + { input: { entityType: "strategies", limit: 20 } }, + { input: { entityType: "contentDrafts", limit: 50 } }, + ], + async execute({ entityType, limit = 50 }) { + if (entityType === "strategies") { + const result = await listStrategiesByProjectId({ + db: args.db, + projectId: args.projectId, + organizationId: args.organizationId, + }); + if (!result.ok) { + return { success: false as const, error: result.error.message }; + } + return { + success: true as const, + entityType, + rows: result.value.slice(0, limit).map((strategy) => ({ + id: strategy.id, + name: strategy.name, + status: strategy.status, + updatedAt: strategy.updatedAt, + })), + }; + } + + const result = await listContentDraftsWithLatestSnapshot({ + db: args.db, + organizationId: args.organizationId, + projectId: args.projectId, + }); + if (!result.ok) { + return { success: false as const, error: result.error.message }; + } + + return { + success: true as const, + entityType, + rows: result.value.slice(0, limit).map((draft) => ({ + id: draft.id, + title: draft.title, + slug: draft.slug, + primaryKeyword: draft.primaryKeyword, + status: draft.status, + strategyName: draft.strategy?.name ?? null, + })), + }; + }, + }); + + const searchData = tool({ + description: + "Search strategies or content drafts by text query across key fields. Useful when you know a keyword/title but not the ID.", + inputSchema: jsonSchema<{ + entityType: "strategies" | "contentDrafts"; + query: string; + limit?: number; + }>({ + type: "object", + additionalProperties: false, + required: ["entityType", "query"], + properties: { + entityType: { + type: "string", + enum: ["strategies", "contentDrafts"], + }, + query: { + type: "string", + minLength: 1, + }, + limit: { + type: "integer", + minimum: 1, + maximum: 200, + default: 25, + }, + }, + }), + inputExamples: [ + { + input: { + entityType: "strategies", + query: "programmatic seo", + limit: 10, + }, + }, + { + input: { + entityType: "contentDrafts", + query: "invoice automation", + limit: 25, + }, + }, + ], + async execute({ entityType, query, limit = 25 }) { + const term = query.trim().toLowerCase(); + if (!term) { + return { success: false as const, error: "Query cannot be empty." }; + } + + if (entityType === "strategies") { + const result = await listStrategiesByProjectId({ + db: args.db, + projectId: args.projectId, + organizationId: args.organizationId, + }); + if (!result.ok) { + return { success: false as const, error: result.error.message }; + } + return { + success: true as const, + entityType, + rows: result.value + .filter((strategy) => + [strategy.name, strategy.description, strategy.motivation] + .filter((value): value is string => Boolean(value)) + .some((value) => value.toLowerCase().includes(term)), + ) + .slice(0, limit) + .map((strategy) => ({ + id: strategy.id, + name: strategy.name, + status: strategy.status, + updatedAt: strategy.updatedAt, + })), + }; + } + + const result = await listContentDraftsWithLatestSnapshot({ + db: args.db, + organizationId: args.organizationId, + projectId: args.projectId, + }); + if (!result.ok) { + return { success: false as const, error: result.error.message }; + } + return { + success: true as const, + entityType, + rows: result.value + .filter((draft) => + [draft.title, draft.slug, draft.primaryKeyword] + .filter((value): value is string => Boolean(value)) + .some((value) => value.toLowerCase().includes(term)), + ) + .slice(0, limit) + .map((draft) => ({ + id: draft.id, + title: draft.title, + slug: draft.slug, + primaryKeyword: draft.primaryKeyword, + status: draft.status, + })), + }; + }, + }); + + const readData = tool({ + description: + "Read an existing strategy or content draft. Content reads focus on drafts only, not published versions.", + inputSchema: jsonSchema<{ + entityType: "strategy" | "contentDraft"; + id: string; + slug?: string; + includeContentMarkdown?: boolean; + }>({ + type: "object", + additionalProperties: false, + required: ["entityType", "id"], + properties: { + entityType: { + type: "string", + enum: ["strategy", "contentDraft"], + }, + id: { + type: "string", + description: "The ID of the entity to read.", + }, + slug: { + type: "string", + description: + "Alternative lookup for contentDraft when id is unknown.", + }, + includeContentMarkdown: { + type: "boolean", + default: true, + }, + }, + }), + inputExamples: [ + { + input: { + entityType: "strategy", + id: "00000000-0000-0000-0000-000000000000", + }, + }, + { + input: { + entityType: "contentDraft", + id: "00000000-0000-0000-0000-000000000000", + includeContentMarkdown: true, + }, + }, + ], + async execute(input) { + if (input.entityType === "strategy") { + const result = await getStrategyDetails({ + db: args.db, + projectId: args.projectId, + strategyId: input.id, + organizationId: args.organizationId, + }); + if (!result.ok) { + return { success: false as const, error: result.error.message }; + } + if (!result.value) { + return { success: false as const, error: "Strategy not found." }; + } + return { + success: true as const, + entityType: "strategy", + row: result.value, + }; + } + + const includeContentMarkdown = input.includeContentMarkdown ?? true; + const draftResult = await getDraftById({ + db: args.db, + organizationId: args.organizationId, + projectId: args.projectId, + id: input.id, + withContent: includeContentMarkdown, + }); + + if (!draftResult.ok) { + return { success: false as const, error: draftResult.error.message }; + } + if (!draftResult.value) { + return { success: false as const, error: "Content draft not found." }; + } + return { + success: true as const, + entityType: "contentDraft", + row: draftResult.value, + }; + }, + }); + + const updateStrategyData = tool({ + description: + "Update an existing strategy using strategy fields directly in the input.", + inputSchema: jsonSchema< + Omit< + typeof schema.seoStrategyUpdateSchema.infer, + "projectId" | "organizationId" + > + >({ + type: "object", + additionalProperties: false, + required: ["id"], + properties: { + id: { + type: "string", + format: "uuid", + }, + name: { + type: "string", + }, + description: { + type: ["string", "null"], + }, + motivation: { + type: "string", + }, + goal: { + type: "object", + additionalProperties: false, + required: ["metric", "target", "timeframe"], + properties: { + metric: { + type: "string", + enum: ["clicks"], + }, + target: { + type: "number", + }, + timeframe: { + type: "string", + enum: ["monthly", "total"], + }, + }, + }, + dismissalReason: { + type: ["string", "null"], + }, + status: { + type: "string", + enum: [...STRATEGY_STATUSES], + }, + }, + }), + inputExamples: [ + { + input: { + id: "00000000-0000-0000-0000-000000000000", + name: "AI SEO Expansion Strategy", + motivation: "Expand non-branded traffic in Q2", + }, + }, + ], + async execute(input) { + const result = await updateStrategy(args.db, { + ...input, + projectId: args.projectId, + organizationId: args.organizationId, + }); + if (!result.ok) { + return { success: false as const, error: result.error.message }; + } + return { + success: true as const, + entityType: "strategy", + row: result.value, + }; + }, + }); + + const updateContentDraftData = tool({ + description: + "Update an existing content draft using content draft fields directly in the input.", + inputSchema: jsonSchema< + Omit< + typeof schema.seoContentDraftUpdateSchema.infer, + | "projectId" + | "organizationId" + | "strategyId" + | "deletedAt" + | "outlineGeneratedByTaskRunId" + | "generatedByTaskRunId" + > + >({ + type: "object", + additionalProperties: false, + required: ["id"], + properties: { + id: { + type: "string", + format: "uuid", + }, + slug: { + type: "string", + }, + primaryKeyword: { + type: "string", + }, + title: { + type: ["string", "null"], + }, + description: { + type: ["string", "null"], + }, + heroImage: { + type: ["string", "null"], + }, + heroImageCaption: { + type: ["string", "null"], + }, + articleType: { + type: ["string", "null"], + enum: [...ARTICLE_TYPES, null], + }, + role: { + type: ["string", "null"], + enum: [...CONTENT_ROLES, null], + }, + notes: { + type: ["string", "null"], + }, + outline: { + type: ["string", "null"], + }, + contentMarkdown: { + type: ["string", "null"], + }, + status: { + type: "string", + enum: [...CONTENT_STATUSES], + }, + scheduledFor: { + type: ["string", "null"], + format: "date-time", + }, + }, + }), + inputExamples: [ + { + input: { + id: "00000000-0000-0000-0000-000000000000", + contentMarkdown: "# Updated draft\n\n...", + }, + }, + { + input: { + id: "00000000-0000-0000-0000-000000000000", + title: "Invoice Automation: Practical Guide", + primaryKeyword: "invoice automation software", + }, + }, + ], + async execute(input) { + const parsedScheduledFor = + input.scheduledFor == null + ? input.scheduledFor + : new Date(input.scheduledFor); + + if ( + parsedScheduledFor != null && + Number.isNaN(parsedScheduledFor.getTime()) + ) { + return { + success: false as const, + error: "scheduledFor must be a valid ISO date-time string.", + }; + } + + const result = await updateContentDraft(args.db, { + ...input, + scheduledFor: parsedScheduledFor, + projectId: args.projectId, + organizationId: args.organizationId, + }); + if (!result.ok) { + return { success: false as const, error: result.error.message }; + } + return { + success: true as const, + entityType: "contentDraft", + row: result.value, + }; + }, + }); + + const deleteData = tool({ + description: + "Removes an existing strategy or content draft. Deleting a strategy does not delete its content; deleting a content draft also unpublishes its published versions. This tool requires human approval before execution.", + inputSchema: jsonSchema<{ + entityType: "strategy" | "contentDraft"; + id: string; + reason?: string; + }>({ + type: "object", + additionalProperties: false, + required: ["entityType", "id"], + properties: { + entityType: { + type: "string", + enum: ["strategy", "contentDraft"], + }, + id: { + type: "string", + }, + reason: { + type: "string", + }, + }, + }), + inputExamples: [ + { + input: { + entityType: "strategy", + id: "00000000-0000-0000-0000-000000000000", + reason: "Deprecated strategy", + }, + }, + { + input: { + entityType: "contentDraft", + id: "00000000-0000-0000-0000-000000000000", + reason: "Merged into another draft", + }, + }, + ], + needsApproval: true, + async execute({ entityType, id, reason }) { + if (entityType === "strategy") { + const result = await softDeleteStrategy({ + db: args.db, + id, + projectId: args.projectId, + organizationId: args.organizationId, + dismissalReason: reason ?? null, + }); + if (!result.ok) { + return { success: false as const, error: result.error.message }; + } + return { + success: true as const, + entityType, + id, + deletedAt: result.value.deletedAt, + }; + } + + const result = await softDeleteDraft({ + db: args.db, + id, + projectId: args.projectId, + organizationId: args.organizationId, + }); + if (!result.ok) { + return { success: false as const, error: result.error.message }; + } + + return { + success: true as const, + entityType, + id, + deletedAt: result.value.deletedAt, + }; + }, + }); + + return { + tools: { + list_existing_data: listData, + search_existing_data: searchData, + read_existing_data: readData, + update_existing_strategy: updateStrategyData, + update_existing_content_draft: updateContentDraftData, + delete_existing_data: deleteData, + } as const, + }; +} diff --git a/packages/api-seo/src/lib/ai/tools/settings-tools.ts b/packages/api-seo/src/lib/ai/tools/workspace-tools.settings-tools.ts similarity index 53% rename from packages/api-seo/src/lib/ai/tools/settings-tools.ts rename to packages/api-seo/src/lib/ai/tools/workspace-tools.settings-tools.ts index 77008a999..642ca38d5 100644 --- a/packages/api-seo/src/lib/ai/tools/settings-tools.ts +++ b/packages/api-seo/src/lib/ai/tools/workspace-tools.settings-tools.ts @@ -1,34 +1,77 @@ -import { openai } from "@ai-sdk/openai"; -import { integrationProvidersSchema } from "@rectangular-labs/core/schemas/integration-parsers"; import { - businessBackgroundSchema, - imageSettingsSchema, - publishingSettingsSchema, - writingSettingsSchema, + businessBackgroundJsonSchema, + type businessBackgroundSchema, + imageSettingsJsonSchema, + type imageSettingsSchema, + publishingSettingsJsonSchema, + type publishingSettingsSchema, + writingSettingsJsonSchema, + type writingSettingsSchema, } from "@rectangular-labs/core/schemas/project-parsers"; import { getSeoProjectByIdentifierAndOrgId, updateSeoProject, } from "@rectangular-labs/db/operations"; -import { generateText, Output, tool } from "ai"; -import { type } from "arktype"; +import { generateText, jsonSchema, Output, tool } from "ai"; import type { ChatContext } from "../../../types"; -import { arktypeToAiJsonSchema } from "../arktype-json-schema"; -import type { AgentToolDefinition } from "./utils"; +import { wrappedOpenAI } from "../wrapped-language-model"; -export function createSettingsToolsWithMetadata(args: { +export function createSettingsTools(args: { context: Pick; }) { - const manageSettingsInputSchema = type({ - mode: "'update' | 'display'", - settingToUpdate: - "'businessBackground' | 'imageSettings' | 'writingSettings' | 'publishingSettings'", - "updateTask?": "string", - }); - const manageSettings = tool({ description: "Manage project settings (display or update).", - inputSchema: manageSettingsInputSchema, + inputSchema: jsonSchema<{ + mode: "update" | "display"; + settingToUpdate: + | "businessBackground" + | "imageSettings" + | "writingSettings" + | "publishingSettings"; + updateTask?: string; + }>({ + type: "object", + additionalProperties: false, + required: ["mode", "settingToUpdate"], + properties: { + mode: { + type: "string", + enum: ["update", "display"], + description: + "Use 'display' to show current settings in chat to the user. Use 'update' to apply targeted changes while preserving all unrelated fields.", + }, + settingToUpdate: { + type: "string", + enum: [ + "businessBackground", + "imageSettings", + "writingSettings", + "publishingSettings", + ], + }, + updateTask: { + type: "string", + description: + "Only needed when the mode is 'update'. This provides the instruction on what in the settings we want to change.", + }, + }, + }), + inputExamples: [ + { + input: { + mode: "display", + settingToUpdate: "writingSettings", + }, + }, + { + input: { + mode: "update", + settingToUpdate: "businessBackground", + updateTask: + "Add an ICP section for B2B SaaS finance teams while keeping existing positioning details.", + }, + }, + ], async execute({ mode, settingToUpdate, updateTask }) { if (mode === "display") { return { success: true, message: "setting displayed to user" }; @@ -86,9 +129,11 @@ Respond with the new object matching the schema.`; const output = await (async () => { if (settingToUpdate === "businessBackground") { const result = await generateText({ - model: openai("gpt-5.1-codex-mini"), + model: wrappedOpenAI("gpt-5.1-codex-mini"), output: Output.object({ - schema: arktypeToAiJsonSchema(businessBackgroundSchema), + schema: jsonSchema( + businessBackgroundJsonSchema, + ), }), prompt, }); @@ -96,9 +141,11 @@ Respond with the new object matching the schema.`; } if (settingToUpdate === "imageSettings") { const result = await generateText({ - model: openai("gpt-5.1-codex-mini"), + model: wrappedOpenAI("gpt-5.1-codex-mini"), output: Output.object({ - schema: arktypeToAiJsonSchema(imageSettingsSchema), + schema: jsonSchema( + imageSettingsJsonSchema, + ), }), prompt, }); @@ -106,18 +153,22 @@ Respond with the new object matching the schema.`; } if (settingToUpdate === "writingSettings") { const result = await generateText({ - model: openai("gpt-5.1-codex-mini"), + model: wrappedOpenAI("gpt-5.1-codex-mini"), output: Output.object({ - schema: arktypeToAiJsonSchema(writingSettingsSchema), + schema: jsonSchema( + writingSettingsJsonSchema, + ), }), prompt, }); return result.output; } const result = await generateText({ - model: openai("gpt-5.1-codex-mini"), + model: wrappedOpenAI("gpt-5.1-codex-mini"), output: Output.object({ - schema: arktypeToAiJsonSchema(publishingSettingsSchema), + schema: jsonSchema( + publishingSettingsJsonSchema, + ), }), prompt, }); @@ -134,13 +185,27 @@ Respond with the new object matching the schema.`; }, }); - const manageIntegrationsInputSchema = type({ - provider: integrationProvidersSchema, - }); const manageIntegrations = tool({ description: "Help the user view, connect, or manage integrations for publishing content or tracking performance.", - inputSchema: manageIntegrationsInputSchema, + inputSchema: jsonSchema<{ + provider: "github" | "webhook" | "google-search-console"; + }>({ + type: "object", + additionalProperties: false, + required: ["provider"], + properties: { + provider: { + type: "string", + enum: ["github", "webhook", "google-search-console"], + description: "The provider to manage.", + }, + }, + }), + inputExamples: [ + { input: { provider: "google-search-console" } }, + { input: { provider: "github" } }, + ], async execute({ provider }) { return await Promise.resolve({ success: true, @@ -154,25 +219,5 @@ Respond with the new object matching the schema.`; manage_settings: manageSettings, manage_integrations: manageIntegrations, } as const; - - const toolDefinitions: AgentToolDefinition[] = [ - { - toolName: "manage_settings", - toolDescription: - "Display or update project settings (business background, image settings, writing settings).", - toolInstruction: - "Use mode='display' to show settings; mode='update' to modify. Provide settingToUpdate and a concrete updateTask describing the change to make while keeping other fields unchanged.", - tool: manageSettings, - }, - { - toolName: "manage_integrations", - toolDescription: - "Help the user view/connect integrations. Available integrations: github (push content to repo), shopify (publish to store blog), webhook (send to any HTTP endpoint), google-search-console (track search performance).", - toolInstruction: - "Use when user asks about: connecting GitHub for publishing, setting up Shopify blog, configuring webhooks, connecting GSC, or when performance analysis is requested but GSC is not connected. Always provide the provider parameter.", - tool: manageIntegrations, - }, - ]; - - return { toolDefinitions, tools }; + return { tools }; } diff --git a/packages/api-seo/src/lib/ai/tools/workspace-tools.ts b/packages/api-seo/src/lib/ai/tools/workspace-tools.ts new file mode 100644 index 000000000..ca50e231e --- /dev/null +++ b/packages/api-seo/src/lib/ai/tools/workspace-tools.ts @@ -0,0 +1,46 @@ +/** Workspace toolkit factory — settings + planner. */ +import type { DB } from "@rectangular-labs/db"; +import { createAskQuestionsTool } from "./workspace-tools.ask-question-tool"; +import { createDataAccessTools } from "./workspace-tools.data-access-tool"; +import { createSettingsTools } from "./workspace-tools.settings-tools"; + +/** + * Create all workspace-related tools. + * + * Tools included: + * - `ask_questions` — Ask the user structured questions + * - `manage_settings` — Display or update project settings + * - `manage_integrations` — Help manage integrations + * - `list_existing_data` — List strategies or content drafts + * - `search_existing_data` — Search strategies or content drafts + * - `read_existing_data` — Read strategy or content draft + * - `update_existing_strategy` — Update strategy or content draft + * - `update_existing_content_draft` — Update strategy or content draft + * - `delete_existing_data` — Delete strategy or content draft + */ +export function createWorkspaceTools(ctx: { + db: DB; + organizationId: string; + projectId: string; +}) { + const plannerTools = createAskQuestionsTool(); + const dataAccessTools = createDataAccessTools({ + db: ctx.db, + organizationId: ctx.organizationId, + projectId: ctx.projectId, + }); + const settingsTools = createSettingsTools({ + context: { + db: ctx.db, + projectId: ctx.projectId, + organizationId: ctx.organizationId, + }, + }); + + const tools = { + ...plannerTools.tools, + ...settingsTools.tools, + ...dataAccessTools.tools, + } as const; + return { tools }; +} diff --git a/packages/api-seo/src/lib/dataforseo/utils.ts b/packages/api-seo/src/lib/dataforseo/utils.ts index ec55e4471..1b93c2b64 100644 --- a/packages/api-seo/src/lib/dataforseo/utils.ts +++ b/packages/api-seo/src/lib/dataforseo/utils.ts @@ -4,6 +4,11 @@ import { COUNTRY_CODE_MAP, } from "@rectangular-labs/core/schemas/project-parsers"; import { + type FetchKeywordSuggestionsArgs, + type FetchKeywordsOverviewArgs, + type FetchRankedKeywordsForSiteArgs, + type FetchRankedPagesForSiteArgs, + type FetchSerpArgs, fetchKeywordSuggestions, fetchKeywordsOverview, fetchRankedKeywordsForSite, @@ -69,11 +74,7 @@ export function getSerpCacheOptions( primaryKeyword: string, locationName: string, languageCode: string, - options?: { - depth?: number; - device?: "desktop" | "mobile"; - os?: "windows" | "macos" | "android" | "ios"; - }, + options?: Pick, ) { return { key: createDataforseoCacheKey("serp", { @@ -98,14 +99,8 @@ export function fetchSerpWithCache({ depth, device, os, -}: { - keyword: string; - locationName: string; - languageCode: string; +}: Omit & { cacheKV: InitialContext["cacheKV"]; - depth?: number; - device?: "desktop" | "mobile"; - os?: "windows" | "macos" | "android" | "ios"; }) { return fetchWithCache({ ...getSerpCacheOptions(keyword, locationName, languageCode, { @@ -139,15 +134,7 @@ export function fetchRankedKeywordsForSiteWithCache({ offset, includeGenderAndAgeDistribution, cacheKV, -}: { - hostname: string; - locationName: string; - languageCode: string; - positionFrom: number; - positionTo: number; - limit: number; - offset: number; - includeGenderAndAgeDistribution: boolean; +}: FetchRankedKeywordsForSiteArgs & { cacheKV: InitialContext["cacheKV"]; }) { return fetchWithCache({ @@ -190,13 +177,8 @@ export function fetchRankedPagesForSiteWithCache({ offset, includeGenderAndAgeDistribution, cacheKV, -}: { +}: Omit & { hostname: string; - locationName: string; - languageCode: string; - limit: number; - offset: number; - includeGenderAndAgeDistribution: boolean; cacheKV: InitialContext["cacheKV"]; }) { return fetchWithCache({ @@ -236,14 +218,7 @@ export function fetchKeywordSuggestionsWithCache({ limit, offset, cacheKV, -}: { - seedKeyword: string; - includeSeedKeyword: boolean; - includeGenderAndAgeDistribution: boolean; - locationName: string; - languageCode: string; - limit: number; - offset: number; +}: FetchKeywordSuggestionsArgs & { cacheKV: InitialContext["cacheKV"]; }) { return fetchWithCache({ @@ -282,11 +257,7 @@ export function fetchKeywordsOverviewWithCache({ locationName, languageCode, cacheKV, -}: { - keywords: string[]; - includeGenderAndAgeDistribution: boolean; - locationName: string; - languageCode: string; +}: FetchKeywordsOverviewArgs & { cacheKV: InitialContext["cacheKV"]; }) { return fetchWithCache({ diff --git a/packages/dataforseo/src/index.ts b/packages/dataforseo/src/index.ts index b1a99098e..ba878cb9a 100644 --- a/packages/dataforseo/src/index.ts +++ b/packages/dataforseo/src/index.ts @@ -18,7 +18,7 @@ import { googleRelevantPagesLive, } from "./sdk.gen"; -export async function fetchRankedKeywordsForSite(args: { +export type FetchRankedKeywordsForSiteArgs = { hostname: string; locationName: string; languageCode: string; @@ -27,7 +27,11 @@ export async function fetchRankedKeywordsForSite(args: { positionTo?: number; limit?: number; offset?: number; -}): Promise< +}; + +export async function fetchRankedKeywordsForSite( + args: FetchRankedKeywordsForSiteArgs, +): Promise< Result< { cost: number; @@ -175,14 +179,18 @@ export async function fetchRankedKeywordsForSite(args: { }); } -export async function fetchRankedPagesForSite(args: { +export type FetchRankedPagesForSiteArgs = { target: string; locationName: string; languageCode: string; limit?: number; offset?: number; includeGenderAndAgeDistribution?: boolean; -}): Promise< +}; + +export async function fetchRankedPagesForSite( + args: FetchRankedPagesForSiteArgs, +): Promise< Result< { cost: number; @@ -267,7 +275,7 @@ export async function fetchRankedPagesForSite(args: { }); } -export async function fetchKeywordSuggestions(args: { +export type FetchKeywordSuggestionsArgs = { seedKeyword: string; locationName: string; languageCode: string; @@ -275,7 +283,11 @@ export async function fetchKeywordSuggestions(args: { includeGenderAndAgeDistribution?: boolean; limit?: number; offset?: number; -}): Promise< +}; + +export async function fetchKeywordSuggestions( + args: FetchKeywordSuggestionsArgs, +): Promise< Result< { cost: number; @@ -368,12 +380,16 @@ export async function fetchKeywordSuggestions(args: { }); } -export async function fetchKeywordsOverview(args: { +export type FetchKeywordsOverviewArgs = { keywords: string[]; locationName: string; languageCode: string; includeGenderAndAgeDistribution?: boolean; -}): Promise< +}; + +export async function fetchKeywordsOverview( + args: FetchKeywordsOverviewArgs, +): Promise< Result< { cost: number; @@ -457,15 +473,20 @@ export async function fetchKeywordsOverview(args: { }); } -export async function fetchSerp(args: { +type FetchSerpDevice = "desktop" | "mobile"; +type FetchSerpOs = "windows" | "macos" | "android" | "ios"; + +export type FetchSerpArgs = { keyword: string; locationName: string; languageCode: string; depth?: number; - device?: "desktop" | "mobile"; - os?: "windows" | "macos" | "android" | "ios"; + device?: FetchSerpDevice; + os?: FetchSerpOs; targetUrl?: string; -}): Promise< +}; + +export async function fetchSerp(args: FetchSerpArgs): Promise< Result< { cost: number; From b01f9f44408ccacb9237b1d70dfb46197f3d1fe9 Mon Sep 17 00:00:00 2001 From: ElasticBottle Date: Mon, 2 Mar 2026 08:55:31 +0800 Subject: [PATCH 4/9] feat(api-seo): add new agents --- .../api-seo/src/lib/ai/agents/orchestrator.ts | 256 +++++ .../src/lib/ai/agents/strategy-advisor.ts | 120 +++ packages/api-seo/src/lib/ai/agents/writer.ts | 909 ++++++++++++++++++ .../src/lib/ai/instructions/orchestrator.ts | 41 + .../lib/ai/instructions/strategy-advisor.ts | 241 +++++ .../api-seo/src/lib/ai/instructions/writer.ts | 436 +++++++++ ...-question-tool.ts => ask-question-tool.ts} | 0 ...ata-access-tool.ts => data-access-tool.ts} | 0 .../api-seo/src/lib/ai/tools/image-tools.ts | 71 +- ...ls.settings-tools.ts => settings-tools.ts} | 20 +- .../src/lib/ai/tools/workspace-tools.ts | 46 - .../src/lib/ai/utils/agent-telemetry.ts | 133 +++ .../api-seo/src/lib/ai/utils/auth-init.ts | 24 + .../ai/utils/format-business-background.ts | 4 +- .../src/lib/ai/utils/project-context.ts | 26 + packages/api-seo/src/lib/ai/utils/review.ts | 216 +++++ .../store-optimized-or-original-image.ts | 14 +- .../lib/ai/utils/wrapped-language-model.ts | 28 + .../src/lib/content/write-content-draft.ts | 27 - .../api-seo/src/lib/workspace/constants.ts | 7 +- .../src/lib/workspace/workflow.constant.ts | 418 +++++--- packages/api-seo/src/routes/admin.ts | 570 +++++++++++ .../api-seo/src/routes/chat.sendMessage.ts | 60 +- .../src/workflows/onboarding-workflow.ts | 37 +- .../strategy-phase-generation-workflow.ts | 334 ++++--- .../strategy-suggestions-workflow.ts | 300 +++--- .../api-seo/src/workflows/writer-workflow.ts | 820 ++++++---------- .../schema/arktype-json-schema-transformer.ts | 38 - packages/core/src/schemas/project-parsers.ts | 299 ++++-- .../src/operations/seo/content-operations.ts | 47 + .../src/operations/seo/strategy-operations.ts | 49 + 31 files changed, 4346 insertions(+), 1245 deletions(-) create mode 100644 packages/api-seo/src/lib/ai/agents/orchestrator.ts create mode 100644 packages/api-seo/src/lib/ai/agents/strategy-advisor.ts create mode 100644 packages/api-seo/src/lib/ai/agents/writer.ts create mode 100644 packages/api-seo/src/lib/ai/instructions/orchestrator.ts create mode 100644 packages/api-seo/src/lib/ai/instructions/strategy-advisor.ts create mode 100644 packages/api-seo/src/lib/ai/instructions/writer.ts rename packages/api-seo/src/lib/ai/tools/{workspace-tools.ask-question-tool.ts => ask-question-tool.ts} (100%) rename packages/api-seo/src/lib/ai/tools/{workspace-tools.data-access-tool.ts => data-access-tool.ts} (100%) rename packages/api-seo/src/lib/ai/tools/{workspace-tools.settings-tools.ts => settings-tools.ts} (92%) delete mode 100644 packages/api-seo/src/lib/ai/tools/workspace-tools.ts create mode 100644 packages/api-seo/src/lib/ai/utils/agent-telemetry.ts create mode 100644 packages/api-seo/src/lib/ai/utils/auth-init.ts create mode 100644 packages/api-seo/src/lib/ai/utils/project-context.ts create mode 100644 packages/api-seo/src/lib/ai/utils/review.ts create mode 100644 packages/api-seo/src/lib/ai/utils/wrapped-language-model.ts delete mode 100644 packages/core/src/schema/arktype-json-schema-transformer.ts diff --git a/packages/api-seo/src/lib/ai/agents/orchestrator.ts b/packages/api-seo/src/lib/ai/agents/orchestrator.ts new file mode 100644 index 000000000..0ecd860f0 --- /dev/null +++ b/packages/api-seo/src/lib/ai/agents/orchestrator.ts @@ -0,0 +1,256 @@ +import type { OpenAIResponsesProviderOptions } from "@ai-sdk/openai"; +import type { GscConfig } from "@rectangular-labs/core/schemas/integration-parsers"; +import type { DB, schema } from "@rectangular-labs/db"; +import { + hasToolCall, + jsonSchema, + stepCountIs, + ToolLoopAgent, + tool, + type UIMessage, +} from "ai"; +import type { createPublicImagesBucket } from "../../bucket"; +import { buildOrchestratorInstructions } from "../instructions/orchestrator"; +import { createAskQuestionsTool } from "../tools/ask-question-tool"; +import { createDataAccessTools } from "../tools/data-access-tool"; +import { createSettingsTools } from "../tools/settings-tools"; +import { + summarizeAgentInvocation, + summarizeAgentStep, +} from "../utils/agent-telemetry"; +import { wrappedOpenAI } from "../utils/wrapped-language-model"; +import { createStrategyAdvisorAgent } from "./strategy-advisor"; +import { createWriterAgent as createWriterSubagent } from "./writer"; + +interface OrchestratorContext { + db: DB; + project: typeof schema.seoProject.$inferSelect; + messages: UIMessage[]; + cacheKV: KVNamespace; + publicImagesBucket: ReturnType; + gscProperty: { + config: GscConfig; + accessToken?: string | null; + } | null; +} + +/** + * Create the chat orchestrator. + * + * The orchestrator is a ToolLoopAgent that: + * - Delegates analysis/strategy/diagnostics to the Strategy Advisor subagent + * - Delegates writing/editing to the Writer subagent + * - Owns interactive tools directly (settings, asking questions) since these + * need to pause the stream for user interaction + * + * The orchestrator does NOT do heavy reasoning — it classifies intent, + * delegates to subagents, and synthesizes their results for the user. + */ +export function createOrchestrator(ctx: OrchestratorContext) { + // Create the Strategy Advisor subagent + const { agent: strategyAdvisor } = createStrategyAdvisorAgent({ + db: ctx.db, + project: ctx.project, + cacheKV: ctx.cacheKV, + gscProperty: ctx.gscProperty, + }); + + // Create the Writer subagent (chat mode) + const { agent: writerAgent } = createWriterSubagent({ + db: ctx.db, + project: ctx.project, + messages: ctx.messages, + cacheKV: ctx.cacheKV, + publicImagesBucket: ctx.publicImagesBucket, + mode: "chat", + }); + + // Orchestrator owns workspace tools directly for interactive use + const askQuestionsTool = createAskQuestionsTool(); + const dataAccessTools = createDataAccessTools({ + db: ctx.db, + organizationId: ctx.project.organizationId, + projectId: ctx.project.id, + }); + const settingsTools = createSettingsTools({ + context: { + db: ctx.db, + projectId: ctx.project.id, + organizationId: ctx.project.organizationId, + }, + }); + const hasGsc = !!( + ctx.gscProperty?.accessToken && + ctx.gscProperty?.config.domain && + ctx.gscProperty?.config.propertyType + ); + const instructions = buildOrchestratorInstructions({ + project: ctx.project, + gscConnected: hasGsc, + gscDomain: ctx.gscProperty?.config.domain, + }); + + const advise = tool({ + description: + "Delegate SEO/GEO analysis, strategy, diagnostics, keyword research, competitor analysis, or performance questions to the Strategy Advisor. Provide a clear, specific task description.", + inputSchema: jsonSchema<{ task: string; strategyId?: string }>({ + type: "object", + additionalProperties: false, + required: ["task"], + properties: { + task: { + type: "string", + description: + "Task to delegate to the Strategy Advisor. It should include all the details on what you want the Strategy Advisor to do. Anticipate and ask for any additional information that the Strategy Advisor may need to complete the task ahead of time before invoking this tool.", + }, + strategyId: { + type: "string", + description: "The ID of the strategy to focus on.", + }, + }, + }), + inputExamples: [ + { + input: { + task: "Analyze why blog traffic dropped in the last 8 weeks and suggest the top 3 actions.", + }, + }, + { + input: { + task: "Review this strategy and propose improvements for phase 2.", + strategyId: "00000000-0000-0000-0000-000000000000", + }, + }, + ], + execute: async ({ task, strategyId }) => { + const prompt = strategyId + ? `${task}\n\nStrategy ID to focus on: ${strategyId}` + : task; + + const result = await strategyAdvisor.generate({ + prompt, + }); + const telemetry = summarizeAgentInvocation(result.steps); + console.log("[orchestrator] advise subagent completed", telemetry); + + return { + summary: result.text, + telemetry, + }; + }, + toModelOutput: ({ output }) => ({ + type: "content", + value: [ + { + type: "text" as const, + text: [ + "Strategy Advisor result:", + `- steps: ${output.telemetry.stepCount}`, + `- tool calls: ${output.telemetry.toolCallCount}`, + output.telemetry.estimatedCostUsd != null + ? `- est. cost (USD): ${output.telemetry.estimatedCostUsd}` + : "- est. cost (USD): unavailable", + `- summary: ${output.summary.slice(0, 1_200)}`, + ].join("\n"), + }, + ], + }), + }); + + const write = tool({ + description: + "Delegate article writing or editing to the Writer. Provide a clear task description of what to write or improve. The writer will research, plan, write, and self-review the content.", + inputSchema: jsonSchema<{ task: string; draftId?: string }>({ + type: "object", + additionalProperties: false, + required: ["task"], + properties: { + task: { + type: "string", + description: "The task to delegate to the Writer.", + }, + draftId: { + type: "string", + description: "The ID of the draft to edit.", + }, + }, + }), + inputExamples: [ + { + input: { + task: "Write a 1,200-word article targeting 'invoice automation software' with practical examples.", + }, + }, + { + input: { + task: "Improve this draft intro and tighten headings for clarity.", + draftId: "00000000-0000-0000-0000-000000000000", + }, + }, + ], + execute: async ({ task, draftId }) => { + const prompt = draftId ? `${task}\n\nDraft ID to edit: ${draftId}` : task; + + const result = await writerAgent.generate({ + prompt, + }); + const telemetry = summarizeAgentInvocation(result.steps); + console.log("[orchestrator] write subagent completed", telemetry); + + return { + content: result.text, + telemetry, + }; + }, + toModelOutput: ({ output }) => ({ + type: "content", + value: [ + { + type: "text" as const, + text: [ + "Writer result:", + `- steps: ${output.telemetry.stepCount}`, + `- tool calls: ${output.telemetry.toolCallCount}`, + output.telemetry.estimatedCostUsd != null + ? `- est. cost (USD): ${output.telemetry.estimatedCostUsd}` + : "- est. cost (USD): unavailable", + `- content preview: ${output.content.slice(0, 1_500)}`, + ].join("\n"), + }, + ], + }), + }); + + const tools = { + advise, + write, + // Interactive workspace tools owned directly by orchestrator + ...askQuestionsTool.tools, + ...dataAccessTools.tools, + ...settingsTools.tools, + }; + + const agent = new ToolLoopAgent({ + id: "seo-orchestrator", + model: wrappedOpenAI("gpt-5.2"), + instructions, + tools, + stopWhen: [ + // Stop on interactive tools that need user input + hasToolCall("ask_questions"), + hasToolCall("manage_integrations"), + // Safety limit + stepCountIs(25), + ], + providerOptions: { + openai: { + reasoningEffort: "medium", + } satisfies OpenAIResponsesProviderOptions, + }, + onStepFinish: (step) => { + console.log("[orchestrator] step finished", summarizeAgentStep(step)); + }, + }); + + return agent; +} diff --git a/packages/api-seo/src/lib/ai/agents/strategy-advisor.ts b/packages/api-seo/src/lib/ai/agents/strategy-advisor.ts new file mode 100644 index 000000000..a1e1b0737 --- /dev/null +++ b/packages/api-seo/src/lib/ai/agents/strategy-advisor.ts @@ -0,0 +1,120 @@ +import type { OpenAIResponsesProviderOptions } from "@ai-sdk/openai"; +import type { GscConfig } from "@rectangular-labs/core/schemas/integration-parsers"; +import type { DB, schema } from "@rectangular-labs/db"; +import { + type JSONSchema7, + jsonSchema, + Output, + stepCountIs, + ToolLoopAgent, +} from "ai"; +import type { InitialContext } from "../../../types"; +import { buildStrategyAdvisorInstructions } from "../instructions/strategy-advisor"; +import { createDataAccessTools } from "../tools/data-access-tool"; +import { createDataforseoTool } from "../tools/dataforseo-tool"; +import { createGscTool } from "../tools/google-search-console-tool"; +import { createInternalLinksTool } from "../tools/internal-links-tool"; +import { createWebTools } from "../tools/web-tools"; +import { summarizeAgentStep } from "../utils/agent-telemetry"; +import { wrappedOpenAI } from "../utils/wrapped-language-model"; + +interface StrategyAdvisorAgentContext { + db: DB; + cacheKV: InitialContext["cacheKV"]; + project: typeof schema.seoProject.$inferSelect; + gscProperty: { + config: GscConfig; + accessToken?: string | null; + } | null; + jsonSchema?: JSONSchema7; +} + +function createStrategyAgentTools(ctx: StrategyAdvisorAgentContext) { + // only include gsc tools if the user has connected their gsc + const gscTools = ctx.gscProperty?.accessToken + ? createGscTool({ + accessToken: ctx.gscProperty.accessToken, + siteUrl: ctx.gscProperty.config.domain, + siteType: ctx.gscProperty.config.propertyType, + }) + : { tools: {} }; + const dataforseoTools = createDataforseoTool(ctx.project, ctx.cacheKV); + const webTools = createWebTools(ctx.project, ctx.cacheKV); + + const internalLinksTools = createInternalLinksTool(ctx.project); + + const dataAccessTools = createDataAccessTools({ + db: ctx.db, + organizationId: ctx.project.organizationId, + projectId: ctx.project.id, + }); + + // Merge all tools + const tools = { + ...gscTools.tools, + ...dataforseoTools.tools, + ...webTools.tools, + ...internalLinksTools.tools, + ...dataAccessTools.tools, + }; + + return tools; +} + +/** + * Create a Strategy Advisor ToolLoopAgent with direct tool access. + * + * This agent has access to: + * - Data tools (GSC, DataForSEO, strategy details, analysis agent) + * - Research tools (web search, web fetch) + * - Creation tools (create article, images, internal links) + * - Workspace tools (file, todo, settings, planner) + * + * Used as: + * - Chat subagent: invoked by the orchestrator's `advise` tool + * - Background agent: called directly by CF Workflows for phase generation, + * strategy suggestions, etc. + * + */ +export function createStrategyAdvisorAgent( + ctx: StrategyAdvisorAgentContext, +): { + agent: ToolLoopAgent< + never, + ReturnType, + ReturnType> + >; +} { + const tools = createStrategyAgentTools(ctx); + + const instructions = buildStrategyAdvisorInstructions({ + project: ctx.project, + }); + + const output = ctx.jsonSchema + ? Output.object({ + schema: jsonSchema(ctx.jsonSchema), + }) + : undefined; + + const agent = new ToolLoopAgent({ + id: "strategy-advisor", + model: wrappedOpenAI("gpt-5.2"), + instructions, + tools, + output, + stopWhen: stepCountIs(30), + providerOptions: { + openai: { + reasoningEffort: "medium", + } satisfies OpenAIResponsesProviderOptions, + }, + onStepFinish: (step) => { + console.log("[strategy-advisor] step finished", summarizeAgentStep(step)); + }, + }); + + return { + agent, + }; +} diff --git a/packages/api-seo/src/lib/ai/agents/writer.ts b/packages/api-seo/src/lib/ai/agents/writer.ts new file mode 100644 index 000000000..14529b2ca --- /dev/null +++ b/packages/api-seo/src/lib/ai/agents/writer.ts @@ -0,0 +1,909 @@ +import type { OpenAIResponsesProviderOptions } from "@ai-sdk/openai"; +import type { imageSettingsSchema } from "@rectangular-labs/core/schemas/project-parsers"; +import type { DB, schema } from "@rectangular-labs/db"; +import { + generateText, + jsonSchema, + Output, + type StepResult, + stepCountIs, + ToolLoopAgent, + type ToolSet, + type UIMessage, +} from "ai"; +import type { createPublicImagesBucket } from "../../bucket"; +import type { ArticleType } from "../../workspace/workflow.constant"; +import { buildWriterInstructions } from "../instructions/writer"; +import { createDataAccessTools } from "../tools/data-access-tool"; +import { createImageTools } from "../tools/image-tools"; +import { createInternalLinksTool } from "../tools/internal-links-tool"; +import { createWebTools } from "../tools/web-tools"; +import { + summarizeAgentInvocation, + summarizeAgentStep, +} from "../utils/agent-telemetry"; +import { type ReviewResult, reviewArticle } from "../utils/review"; +import { wrappedOpenAI } from "../utils/wrapped-language-model"; + +export interface StrategyContext { + name: string; + motivation: string; + description: string | null; + goal: { + metric: string; + target: number; + timeframe: string; + }; + phaseType: "build" | "optimize" | "expand" | null; + contentRole: "pillar" | "supporting" | null; + siblingContent: { + title: string | null; + slug: string; + role: "pillar" | "supporting" | null; + primaryKeyword: string; + status: string; + }[]; +} + +interface WriterAgentContext { + db: DB; + project: typeof schema.seoProject.$inferSelect; + messages: UIMessage[]; + cacheKV: KVNamespace; + publicImagesBucket: ReturnType; + mode: "chat" | "workflow"; + articleType?: ArticleType; + primaryKeyword?: string; + strategyContext?: StrategyContext; +} + +interface ContentPlan { + intentStatement: string; + targetWordCount: number; + titleSuggestion: string; + metaDescription: string; + sectionPlan: { + heading: string; + goal: string; + keyPoints: string[]; + }[]; + faqToInclude: string[]; +} + +interface RawDraft { + markdown: string; +} + +export interface LinkedDraft { + markdown: string; +} + +export interface ImagedDraft { + markdown: string; + heroImage: string; + heroImageCaption: string | null; +} + +export interface FinalArticle { + markdown: string; + heroImage: string; + heroImageCaption: string | null; +} + +type AnyStep = Awaited>["steps"][number]; + +export interface WriterPhaseResult { + output: TOutput; + steps: StepResult[]; +} + +export interface WriterPipelineArtifacts { + research: ResearchSummary; + plan: ContentPlan; + rawDraft: RawDraft; + internalLinkedDraft: LinkedDraft; + imagedDraft: ImagedDraft; + finalArticle: FinalArticle; + reviews: ReviewResult[]; +} + +const openAIProviderOptions: OpenAIResponsesProviderOptions = { + reasoningEffort: "medium", +}; + +function collectSteps(allSteps: unknown[], result: { steps?: unknown[] }) { + if (!result.steps || result.steps.length === 0) { + return; + } + allSteps.push(...result.steps); +} + +function countWords(markdown: string): number { + return markdown + .replace(/[#*_`~[\]()>|!-]/g, " ") + .split(/\s+/) + .filter((word) => word.length > 0).length; +} + +interface ResearchSummary { + primaryIntent: string; + serpRepresentative: boolean; + serpAssessment: string; + competitorBreakdown: { + title: string; + url: string; + topicsCovered: string[]; + strengths: string; + gaps: string; + }[]; + keyFindings: string[]; + faqCandidates: string[]; + sources: { + title: string; + url: string; + relevance: string; + }[]; +} + +export function createWriterPipeline(ctx: WriterAgentContext) { + const researchTools = createWebTools(ctx.project, ctx.cacheKV); + + const imageTools = createImageTools({ + organizationId: ctx.project.organizationId, + projectId: ctx.project.id, + imageSettings: + (ctx.project.imageSettings as typeof imageSettingsSchema.infer | null) ?? + null, + publicImagesBucket: ctx.publicImagesBucket, + }); + + const internalLinksTools = createInternalLinksTool(ctx.project); + + const dataAccessTools = createDataAccessTools({ + db: ctx.db, + organizationId: ctx.project.organizationId, + projectId: ctx.project.id, + }); + + const staticSystemPrompt = buildWriterInstructions({ + project: ctx.project, + mode: ctx.mode, + articleType: ctx.articleType, + primaryKeyword: ctx.primaryKeyword, + strategyContext: ctx.strategyContext, + }); + + async function runResearchPhase(args: { + task: string; + }): Promise< + WriterPhaseResult< + ResearchSummary, + typeof researchTools.tools & typeof dataAccessTools.tools + > + > { + const researchAgent = new ToolLoopAgent({ + id: "writer-research", + model: wrappedOpenAI("gpt-5.2"), + instructions: staticSystemPrompt, + tools: { + ...researchTools.tools, + ...dataAccessTools.tools, + }, + output: Output.object({ + schema: jsonSchema({ + type: "object", + additionalProperties: false, + required: [ + "primaryIntent", + "serpRepresentative", + "serpAssessment", + "competitorBreakdown", + "keyFindings", + "faqCandidates", + "sources", + ], + properties: { + primaryIntent: { + type: "string", + description: + "The primary search intent behind the target keyword. Classify where it falls on the spectrum: informational, evaluation, or purchase-intent. Include a brief rationale (e.g. 'Informational — searchers want to understand what X is and how it works').", + }, + serpRepresentative: { + type: "boolean", + description: + "Whether the current SERP results are representative of the true search intent. False if the SERP is dominated by off-topic pages, spam, or content that clearly mismatches the intent (signals we should build from first principles rather than mimic existing results).", + }, + serpAssessment: { + type: "string", + description: + "A concise assessment of the current SERP landscape: dominant content formats (listicle, guide, comparison, landing page), approximate word count range of top results, notable SERP features (featured snippets, PAA, video carousels), and overall content quality. Keep this factual and brief — the planning phase will use it to make structural decisions.", + }, + competitorBreakdown: { + type: "array", + description: + "Structured analysis of the top 3-5 ranking pages. Each entry should be a page you actually fetched and read via web_fetch. Do NOT include pages you could not access. This should only cover the SERPS of the primary and secondary keywords that we are covering and not the other articles in the strategy.", + items: { + type: "object", + additionalProperties: false, + required: [ + "title", + "url", + "topicsCovered", + "strengths", + "gaps", + ], + properties: { + title: { + type: "string", + description: + "The page's actual title as it appears on the page or in the SERP.", + }, + url: { + type: "string", + description: "The exact URL of the competitor page.", + }, + topicsCovered: { + type: "array", + items: { type: "string" }, + description: + "List of distinct topics and subtopics the page covers. Be specific (e.g. 'pricing comparison table', 'step-by-step installation guide') not vague (e.g. 'introduction', 'details').", + }, + strengths: { + type: "string", + description: + "What this page does well: depth of coverage, unique data, clear structure, original research, etc.", + }, + gaps: { + type: "string", + description: + "What this page misses or does poorly: outdated information, shallow sections, missing subtopics, poor structure, no original data, etc.", + }, + }, + }, + }, + keyFindings: { + type: "array", + items: { type: "string" }, + description: + "The 3-6 most important takeaways from the research that should directly inform article content. Focus on: (1) the information gain angle — what all competitors miss that we can uniquely provide, (2) critical facts, data points, or expert perspectives discovered, (3) content structure insights (what format/depth the intent demands). Each finding should be a concrete, actionable insight — not a vague observation.", + }, + faqCandidates: { + type: "array", + items: { type: "string" }, + description: + "Real People Also Ask questions and related queries observed in the SERP results. These MUST be actual questions you found in PAA boxes, related searches, or 'People also search for' sections — not questions you invented or inferred. Copy the exact wording from the SERP. If no PAA questions were present in the search results, return an empty array. Do NOT fabricate questions.", + }, + sources: { + type: "array", + description: + "Authoritative reference sources that can support claims in the article. These are NOT competitor pages (those go in competitorBreakdown) — these are citable authorities: research papers, industry reports, government/standards body publications, reputable news outlets, official documentation, or recognized expert publications. Each source must have been verified via web_fetch to confirm the URL is live and the content supports the stated relevance. Target 3-6 sources.", + items: { + type: "object", + additionalProperties: false, + required: ["title", "url", "relevance"], + properties: { + title: { + type: "string", + description: + "The title of the source page or publication as it actually appears.", + }, + url: { + type: "string", + description: + "The exact, verified URL of the source. Must be a live page you confirmed via web_fetch. No placeholder or guessed URLs.", + }, + relevance: { + type: "string", + description: + "A specific explanation of what claim or topic this source supports in the article (e.g. 'Provides the 2024 benchmark data on email open rates cited in the ROI section'). Do not use vague descriptions like 'relevant to the topic'.", + }, + }, + }, + }, + }, + }), + }), + stopWhen: stepCountIs(40), + providerOptions: { + openai: openAIProviderOptions, + }, + onStepFinish: (step) => { + console.log( + "[writer:research] step finished", + summarizeAgentStep(step), + ); + }, + }); + + const researchResult = await researchAgent.generate({ + prompt: ` +Research for this writing task: +${args.task} + +Your goal is to build the evidence base the writer needs. Focus on: + +1. **Intent classification**: Determine the primary search intent and where it falls on the intent spectrum (informational -> evaluation -> purchase-intent). This determines content length, structure, and tone. +2. **SERP reality check**: Evaluate whether current SERP pages are representative of the intent. Note the content formats that rank (listicle, comparison, guide, landing pages, etc.), approximate word counts, and SERP features present. If the SERP does not match the likely intent, note this — we will build from first principles instead. +3. **Competitor breakdown**: For each of the top 3-5 ranking pages, produce a structured breakdown: + - What topics and subtopics does the page cover? + - What are its strengths (depth, data, unique angles)? + - What are its gaps or weaknesses (missing topics, shallow coverage, outdated info)? + Use web_fetch to read each top result and extract this breakdown. This is critical — the planning phase will use this to build a section plan that covers everything the best pages cover, plus our information gain angle. +4. **Information gain identification**: Across all competitor pages, what do they ALL miss? Identify the unique angle or data this article can provide that none of the existing pages offer. This is the single most important quality signal. +5. **Source candidates**: Gather 3-6 authoritative REFERENCE sources — these are NOT competitor/SERP pages (those go in competitorBreakdown). Sources are citable authorities: research papers, industry reports, government/standards body publications, official documentation, reputable news outlets, or recognized expert publications. These sources will be embedded as inline citations during writing to back up specific claims. Use web_fetch to verify each source URL is live and the content matches your stated relevance. Every source must have a specific relevance statement explaining what claim it supports. +6. **FAQ/PAA questions**: Collect real People Also Ask questions and related queries that you actually observe in the SERP results. Copy the exact wording. Do NOT invent or infer questions — only include questions you actually found. If there are none, return an empty array. + +Do not draft article prose. Output a structured research summary. +`, + }); + + return { + output: researchResult.output, + steps: researchResult.steps, + }; + } + + async function runPlanningPhase(args: { + task: string; + research: ResearchSummary; + }): Promise> { + const planningResult = await generateText({ + model: wrappedOpenAI("gpt-5.2"), + system: staticSystemPrompt, + providerOptions: { + openai: openAIProviderOptions, + }, + tools: { + ...researchTools.tools, + }, + prompt: ` +Create a content plan from the research summary. The writer will receive BOTH this plan AND the full research summary, so do not restate research findings — focus on structural decisions and actionable planning. + +Your role: translate raw research into a concrete writing blueprint. The research already covers intent, SERP analysis, competitor coverage, information gain, and sources. Your job is to decide HOW to structure the article, not to re-analyze WHAT exists. + +Planning decisions: +1. **Intent statement**: One sentence capturing who this article is for and what it helps them do. This anchors all structural choices. +2. **Content type and length**: Based on the research's intent classification and SERP assessment, determine the right format and target word count. Bottom-of-funnel: 400-800 words. Evaluation: 800-1,500. Informational guides: 1,000-2,000. Match length to intent, not to an arbitrary default. +3. **Title**: SEO-optimized, includes the primary keyword naturally (toward the front when possible). Must be compelling enough to earn clicks. Max 60 characters. +4. **Meta description**: 150-160 characters, includes primary keyword, clear value proposition. +5. **Section plan**: This is the core deliverable. Build the section structure by: + - Taking the UNION of all topics from the research competitorBreakdown (so we cover everything the best pages cover) + - Adding at least one section that delivers the information gain angle from the research keyFindings + - Ordering sections to match the reader's journey for this intent stage + - Each section needs: a heading, its goal (what question it answers), and specific key points the writer must hit + - Reference specific research findings or sources in the keyPoints where relevant (e.g. "Use the [source name] data to support X claim") + - Every section must earn its place — cut sections that don't serve the primary intent, but don't cut topics that multiple competitors cover (that signals reader demand) +6. **FAQ selection**: Choose 3-5 real PAA questions from the research faqCandidates. Only include questions NOT already fully answered by the section plan's content. If the research returned no faqCandidates, return an empty array. + +Task: +${args.task} + +Research summary: +${JSON.stringify(args.research, null, 2)} +`, + output: Output.object({ + schema: jsonSchema({ + type: "object", + additionalProperties: false, + required: [ + "intentStatement", + "targetWordCount", + "titleSuggestion", + "metaDescription", + "sectionPlan", + "faqToInclude", + ], + properties: { + intentStatement: { + type: "string", + description: + "A one-sentence statement of the article's purpose and target reader (e.g. 'Help small business owners understand which invoice automation tool fits their workflow'). This anchors every structural decision in the plan.", + }, + targetWordCount: { + type: "number", + description: + "The target word count based on intent and SERP analysis. Bottom-of-funnel: 400-800. Evaluation: 800-1,500. Informational guides: 1,000-2,000. Do not default to a round number — justify based on what the intent demands.", + }, + titleSuggestion: { + type: "string", + description: + "SEO-optimized title that includes the primary keyword naturally (toward the front when possible). Must be compelling enough to earn clicks in the SERP. Max 60 characters for full display.", + }, + metaDescription: { + type: "string", + description: + "150-160 character meta description. Must include the primary keyword, state a clear value proposition, and compel the click. No fluff or generic language.", + }, + sectionPlan: { + type: "array", + description: + "Ordered list of article sections. Each section must earn its place — every section should directly serve the primary intent. The plan should cover the UNION of all topics from competitor pages plus the information gain angle. Do not include an 'Introduction' section — the opening is implicit.", + items: { + type: "object", + additionalProperties: false, + required: ["heading", "goal", "keyPoints"], + properties: { + heading: { + type: "string", + description: + "The H2 heading for this section. Clear, direct, and descriptive. Avoid question-format headings unless the section genuinely answers a specific query.", + }, + goal: { + type: "string", + description: + "What question this section answers for the reader or what decision it helps them make. This is an internal planning note — it won't appear in the article.", + }, + keyPoints: { + type: "array", + items: { type: "string" }, + description: + "The specific points the writer must cover in this section. Be concrete (e.g. 'Compare pricing tiers of top 3 tools') not vague (e.g. 'Discuss pricing'). Reference specific research findings or sources where relevant.", + }, + }, + }, + }, + faqToInclude: { + type: "array", + items: { type: "string" }, + description: + "3-5 real PAA questions selected from the research faqCandidates that are relevant to the article and NOT already fully answered by the main section content. These will become the FAQ section at the end of the article. Use the exact question wording from the research. If the research returned no faqCandidates, return an empty array — do not invent questions.", + }, + }, + }), + }), + }); + return { + output: planningResult.output, + steps: planningResult.steps, + }; + } + + async function runWritingPhase(args: { + task: string; + research: ResearchSummary; + plan: ContentPlan; + }): Promise> { + const writingAgent = new ToolLoopAgent({ + id: "writer-writing", + model: wrappedOpenAI("gpt-5.2"), + instructions: staticSystemPrompt, + tools: { + ...researchTools.tools, + }, + output: Output.object({ + schema: jsonSchema({ + type: "object", + additionalProperties: false, + required: ["markdown"], + properties: { + markdown: { type: "string" }, + }, + }), + }), + stopWhen: stepCountIs(40), + providerOptions: { + openai: openAIProviderOptions, + }, + onStepFinish: (step) => { + console.log("[writer:writing] step finished", summarizeAgentStep(step)); + }, + }); + + const sourcesBlock = + args.research.sources.length > 0 + ? `\nVerified sources from research (embed these as inline citations where they support claims):\n${args.research.sources.map((s) => `- [${s.title}](${s.url}) — ${s.relevance}`).join("\n")}\n` + : ""; + + const writingResult = await writingAgent.generate({ + prompt: ` +Write the full article body in Markdown following the content plan. + +Writing principles for this phase: +- Follow the content plan's section structure closely. Expand each section into grounded, helpful prose. +- Lead with the direct answer or key insight in the opening paragraph. LLMs and featured snippets extract from the first paragraph. +- Ensure the primary keyword appears in the opening sentence naturally. +- Write for the searcher's intent stage. Bottom-of-funnel: be decisive and concise. Informational: be thorough but efficient. +- Every section must deliver value. If a paragraph does not teach, persuade, or inform, cut it. +- Vary sentence structure. Mix short declarative sentences with longer explanatory ones. +- Deliver on the information gain identified in research — this is what differentiates this article from everything else on the SERP. + +Citation requirements: +- Embed external links inline as you write. When you make a claim supported by a research source, link to it immediately using the source URL. +- Use the verified sources from research below as your primary citation pool. Embed them where they directly support claims. +- If you make a claim or cite a statistic that is not covered by the research sources, use web_search and web_fetch to find and verify an authoritative source before including it. +- Every statistic must have an inline citation. No uncited numbers. +- Embed links within the natural phrase they support (e.g., "a [McKinsey study](url) found that..."). Never add standalone "Source:" lines. +- When a sentence references a company/product/tool, anchor the brand name itself (e.g., "[Filestage](url) and [Ziflow](url) ..."), not awkward anchors like "[Filestage pricing](url)" unless you are explicitly discussing pricing pages. +- Never output placeholder anchor text patterns such as "(link to filestage)" or "(link to ziflow)". +- Do NOT invent, guess, or use placeholder URLs. +- Target 2-4 external citations minimum across the article. +${sourcesBlock} + +Word count requirement: +- Target approximately ${args.plan.targetWordCount} words. +- Keep final draft length within ${Math.max(200, Math.floor(args.plan.targetWordCount * 0.85))}-${Math.ceil(args.plan.targetWordCount * 1.15)} words. +- If over the range, cut redundant content first. If under, add missing depth to key sections. Do not add filler. + +Phase constraints: +- Do not include hero image metadata here. +- Do not add internal links yet (those come in a later phase). +- Do not add image embeds yet. + +Task: +${args.task} + +Content plan: +${JSON.stringify(args.plan, null, 2)} + +Research summary: +${JSON.stringify(args.research, null, 2)} +`, + }); + + return { + output: writingResult.output, + steps: writingResult.steps, + }; + } + + async function runInternalLinksPhase(args: { + draft: RawDraft; + }): Promise> { + const internalLinksAgent = new ToolLoopAgent({ + id: "writer-internal-links", + model: wrappedOpenAI("gpt-5.2"), + instructions: staticSystemPrompt, + tools: { + ...internalLinksTools.tools, + }, + output: Output.object({ + schema: jsonSchema({ + type: "object", + additionalProperties: false, + required: ["markdown"], + properties: { + markdown: { type: "string" }, + }, + }), + }), + stopWhen: stepCountIs(32), + providerOptions: { + openai: openAIProviderOptions, + }, + onStepFinish: (step) => { + console.log( + "[writer:internal-links] step finished", + summarizeAgentStep(step), + ); + }, + }); + + // Build cluster context block if strategy has sibling content + const clusterSiblings = ctx.strategyContext?.siblingContent ?? []; + const clusterBlock = + clusterSiblings.length > 0 + ? `\nContent cluster (prioritize linking to these sibling pages): +${clusterSiblings + .map((s) => { + const roleLabel = s.role === "pillar" ? " [PILLAR]" : ""; + return `- "${s.title ?? s.primaryKeyword}" (/${s.slug})${roleLabel}`; + }) + .join("\n")} + +Cluster linking rules: +- Every article should link to the pillar page (marked [PILLAR]) at least once if one exists. +- Link to 2-3 sibling pages where contextually relevant. +- Use anchor text that includes the sibling's primary keyword naturally. +- Cluster links count toward the 5-10 internal links target. Supplement with additional site links found via the tool.\n` + : ""; + + const internalLinksResult = await internalLinksAgent.generate({ + prompt: ` +Add 5-12 internal links to this draft using the internal_links tool. + +Internal links build topical authority and guide readers toward conversion. They also influence how search engines and LLMs understand relationships between entities and pages on the site: +1. Use the internal_links tool to find relevant pages on the project's site. +2. Link from informational content toward money/conversion pages when contextually appropriate. +3. Use descriptive anchor text (2-5 words) that includes the target page's topic. Never use "click here", "here", "this", or "learn more". +4. CRITICAL: Copy URLs exactly as returned by the tool. Do NOT add, remove, or modify any characters. +5. Do not place links at the end of sentences in parentheses. +6. Distribute links naturally throughout the article. Do not cluster them all in one section. +7. Prioritize links to pages in the active strategy cluster first (pillar + sibling pages), then add other relevant internal links. +8. Place each internal link inside a semantically rich sentence that clearly explains the topic relationship between source and destination pages. +9. Use anchor text that reflects the destination's search intent/primary keyword naturally so relevance is explicit for users, search engines, and LLM retrieval. +${clusterBlock} +Return the full draft with internal links added. Do not change the content itself, only add links. + +Draft: +${args.draft.markdown} +`, + }); + return { + output: internalLinksResult.output, + steps: internalLinksResult.steps, + }; + } + + async function runImagesPhase(args: { + internalLinkedDraft: LinkedDraft; + }): Promise> { + const imagesAgent = new ToolLoopAgent({ + id: "writer-images", + model: wrappedOpenAI("gpt-5.2"), + instructions: staticSystemPrompt, + tools: { + ...imageTools.tools, + }, + output: Output.object({ + schema: jsonSchema({ + type: "object", + additionalProperties: false, + required: ["markdown", "heroImage", "heroImageCaption"], + properties: { + markdown: { type: "string" }, + heroImage: { type: "string" }, + heroImageCaption: { + anyOf: [{ type: "string" }, { type: "null" }], + }, + }, + }), + }), + stopWhen: stepCountIs(25), + providerOptions: { + openai: openAIProviderOptions, + }, + onStepFinish: (step) => { + console.log("[writer:images] step finished", summarizeAgentStep(step)); + }, + }); + + const imagesResult = await imagesAgent.generate({ + prompt: ` +Add hero image metadata and place section images in the Markdown. + +Images serve comprehension, not decoration: +1. **Hero image**: Select or generate an image that visually represents the article's topic. Set heroImage (URL) and heroImageCaption. Do NOT embed the hero image in the markdown body. +2. **Section images**: Identify which H2 sections have the best visual potential — processes, concepts, comparisons, step-by-step sequences, or product screenshots. Add at least one section image. +3. For article types that require images for every item (best-of lists, comparisons, listicles), ensure every listed item has an image. +4. If an image is meant to represent a specific brand/tool/product, use capture_screenshot on the brand's official site or product page first. Do not use generic stock photos or generate random images for brand-specific mentions. +5. Use find_stock_image only for generic concepts/scenes where no specific brand/entity should be shown. +6. Place images immediately after the section heading they belong to. +7. Use descriptive alt text that explains what the image actually shows. +8. NEVER inline base64 or data URIs. Only use URLs returned from image generation/stock photo tools. +9. Do NOT include image captions in the markdown unless they are stock photo attributions. +10. if you run into issues generating image, finding a stock image, or trying to screenshot a site, skip the image and continue with the rest of the article. Do not indicate in the markdown that an error was found or that an image was skipped. Do not attempt to generate an image in place of a failed tool call either. + +Return the full draft with images added, plus heroImage and heroImageCaption fields. + +Draft: +${args.internalLinkedDraft.markdown} +`, + }); + return { + output: imagesResult.output, + steps: imagesResult.steps, + }; + } + + async function runReviewLoopPhase(args: { + article: FinalArticle; + targetWordCount?: number; + maxReviewIterations?: number; + }): Promise<{ + output: FinalArticle; + reviews: ReviewResult[]; + steps: AnyStep[]; + }> { + const maxReviewIterations = args.maxReviewIterations ?? 3; + const steps: AnyStep[] = []; + const reviews: ReviewResult[] = []; + let finalArticle = args.article; + + for (let attempt = 1; attempt <= maxReviewIterations; attempt += 1) { + const review = await reviewArticle({ + article: finalArticle.markdown, + primaryKeyword: ctx.primaryKeyword ?? "", + websiteUrl: ctx.project.websiteUrl, + brandVoice: ctx.project.writingSettings?.brandVoice ?? null, + customInstructions: + ctx.project.writingSettings?.customInstructions ?? null, + targetWordCount: args.targetWordCount, + }); + reviews.push(review); + + if (review.passes) { + break; + } + + const revisionAgent = new ToolLoopAgent({ + id: "writer-revision", + model: wrappedOpenAI("gpt-5.2"), + instructions: staticSystemPrompt, + tools: { + ...researchTools.tools, + ...internalLinksTools.tools, + ...imageTools.tools, + }, + output: Output.object({ + schema: jsonSchema({ + type: "object", + additionalProperties: false, + required: ["markdown", "heroImage", "heroImageCaption"], + properties: { + markdown: { type: "string" }, + heroImage: { type: "string" }, + heroImageCaption: { + anyOf: [{ type: "string" }, { type: "null" }], + }, + }, + }), + }), + stopWhen: stepCountIs(25), + providerOptions: { + openai: openAIProviderOptions, + }, + onStepFinish: (step) => { + console.log( + "[writer:revision] step finished", + summarizeAgentStep(step), + ); + }, + }); + + const revisionResult = await revisionAgent.generate({ + prompt: ` +Revise the article based on review feedback. Return the full final artifact. + +Revision principles: +- Address every specific revision item in the feedback. Do not skip any. +- Preserve what is working well. Do not rewrite sections that scored well unless the feedback explicitly requests changes. +- If the feedback identifies missing information gain, add unique insights or data — do not pad with generic filler. +- If the feedback identifies SEO issues (keyword placement, link quality), fix them precisely. +- If the feedback identifies readability issues, simplify sentence structure and improve flow. +- Use tools if needed: web_search/web_fetch for new sources, internal_links for additional internal links, image tools for missing images. +- Maintain the same JSON output format: markdown, heroImage, heroImageCaption. +- Hard requirement: keep the revised article length close to the target word count (${args.targetWordCount ?? "not provided"}). Current article length: ${countWords(finalArticle.markdown)} words. +- If currently above target range, remove redundant paragraphs, repetitive examples, and low-value tangents before rewriting. + +Current article: +${JSON.stringify(finalArticle, null, 2)} + +Review feedback: +${JSON.stringify( + { + overallScore: review.overallScore, + feedback: review.feedback, + revisions: review.revisions, + }, + null, + 2, +)} +`, + }); + collectSteps(steps, revisionResult); + const revisedDraft = revisionResult.output; + + finalArticle = { + markdown: revisedDraft.markdown, + heroImage: revisedDraft.heroImage, + heroImageCaption: revisedDraft.heroImageCaption, + }; + } + + return { + output: finalArticle, + reviews, + steps, + }; + } + + async function run(args: { + task: string; + includeReviewLoop?: boolean; + maxReviewIterations?: number; + }) { + const includeReviewLoop = args.includeReviewLoop ?? ctx.mode === "chat"; + const maxReviewIterations = args.maxReviewIterations ?? 3; + const allSteps: AnyStep[] = []; + + const researchPhase = await runResearchPhase({ + task: args.task, + }); + console.log("researchPhase.output", researchPhase.output); + collectSteps(allSteps, researchPhase); + + const planningPhase = await runPlanningPhase({ + task: args.task, + research: researchPhase.output, + }); + console.log("planningPhase.output", planningPhase.output); + collectSteps(allSteps, planningPhase); + + const writingPhase = await runWritingPhase({ + task: args.task, + research: researchPhase.output, + plan: planningPhase.output, + }); + console.log("writingPhase.output", writingPhase.output); + collectSteps(allSteps, writingPhase); + + const internalLinksPhase = await runInternalLinksPhase({ + draft: writingPhase.output, + }); + console.log("internalLinksPhase.output", internalLinksPhase.output); + collectSteps(allSteps, internalLinksPhase); + + const imagesPhase = await runImagesPhase({ + internalLinkedDraft: internalLinksPhase.output, + }); + console.log("imagesPhase.output", imagesPhase.output); + collectSteps(allSteps, imagesPhase); + + let finalArticle: FinalArticle = { + markdown: imagesPhase.output.markdown, + heroImage: imagesPhase.output.heroImage, + heroImageCaption: imagesPhase.output.heroImageCaption, + }; + + let reviews: ReviewResult[] = []; + + if (includeReviewLoop) { + const reviewPhase = await runReviewLoopPhase({ + article: finalArticle, + targetWordCount: planningPhase.output.targetWordCount, + maxReviewIterations, + }); + console.log("reviewPhase.output", reviewPhase.output); + collectSteps(allSteps, reviewPhase); + finalArticle = reviewPhase.output; + reviews = reviewPhase.reviews; + } + + return { + artifacts: { + research: researchPhase.output, + plan: planningPhase.output, + rawDraft: writingPhase.output, + internalLinkedDraft: internalLinksPhase.output, + imagedDraft: imagesPhase.output, + finalArticle, + reviews, + } satisfies WriterPipelineArtifacts, + steps: allSteps, + telemetry: summarizeAgentInvocation(allSteps), + }; + } + + return { + run, + runResearchPhase, + runPlanningPhase, + runWritingPhase, + runInternalLinksPhase, + runImagesPhase, + runReviewLoopPhase, + workspaceTools: dataAccessTools, + }; +} + +export function createWriterAgent(ctx: WriterAgentContext) { + const pipeline = createWriterPipeline(ctx); + + const agent = { + async generate(input: { prompt: string }) { + const result = await pipeline.run({ + task: input.prompt, + includeReviewLoop: ctx.mode === "chat", + }); + + return { + text: JSON.stringify(result.artifacts.finalArticle, null, 2), + steps: result.steps, + }; + }, + }; + + return { + agent, + }; +} diff --git a/packages/api-seo/src/lib/ai/instructions/orchestrator.ts b/packages/api-seo/src/lib/ai/instructions/orchestrator.ts new file mode 100644 index 000000000..78d3ec0dc --- /dev/null +++ b/packages/api-seo/src/lib/ai/instructions/orchestrator.ts @@ -0,0 +1,41 @@ +import type { schema } from "@rectangular-labs/db"; +import { buildProjectContext } from "../utils/project-context"; + +export function buildOrchestratorInstructions(args: { + project: typeof schema.seoProject.$inferSelect; + gscConnected: boolean; + gscDomain?: string; +}): string { + const projectContext = buildProjectContext(args.project); + + return ` +You are the Search Engine Optimization (SEO)/Generative Engine Optimization (GEO) chat orchestrator for ${args.project.name ?? args.project.websiteUrl}. +Your job is to understand the user's intent clearly and delegate to the right capability. + +You are NOT the expert. Your role is to: +1. Understand what the user wants. +2. Delegate to the right subagent with a clear, detailed, and specific task description. +3. Synthesize subagent results into a clear response for the user. +4. Handle interactive tasks (settings and clarifications) directly. + + + +- For SEO analysis, strategy, diagnostics, keyword research, competitor analysis, content planning, or performance questions: use the \`advise\` tool with a clear, detailed, and specific task description. +- For writing/editing articles: use the \`write\` tool with a clear, detailed, and specific task description including article type, primary keyword, and outline if available. +- For interactive tasks (settings changes, clarifications): handle these directly using your tools (\`manage_settings\`, \`ask_questions\`, \`manage_integrations\`). +- After receiving subagent results, synthesize and present clearly to the user. Do not just parrot raw subagent output. Add structure, key findings, and next steps. +- Make sure to assume that the user knows nothing about SEO or GEO and do not use any jargon. Explain things in a simple and easy way such that a beginner can understand. + + + +${projectContext} +- Google Search Console: ${args.gscConnected ? `Connected (${args.gscDomain})` : "Not connected. If the the user asks for performance/decay/CTR, prioritize connecting via `manage_integrations`"} + + + +- Do not attempt deep SEO analysis yourself. Delegate to \`advise\`. +- Do not attempt to write articles yourself. Delegate to \`write\`. +- Do not guess at data. Let subagents query tools for real data. +- Keep your own responses concise. Subagents do the heavy lifting. +`; +} diff --git a/packages/api-seo/src/lib/ai/instructions/strategy-advisor.ts b/packages/api-seo/src/lib/ai/instructions/strategy-advisor.ts new file mode 100644 index 000000000..daf8040e7 --- /dev/null +++ b/packages/api-seo/src/lib/ai/instructions/strategy-advisor.ts @@ -0,0 +1,241 @@ +import type { schema } from "@rectangular-labs/db"; +import { buildProjectContext } from "../utils/project-context"; + +/** + * Build the system prompt for the Strategy Advisor agent. + * + * This agent is invoked in two modes: + * 1. **Chat subagent** — delegated to by the orchestrator via the `advise` tool + * with a pre-formulated task string. Execute the task as given; do not block + * on clarification unless a missing input prevents materially correct output. + * 2. **Background agent** — called directly by CF Workflows (e.g. strategy + * suggestion generation). Receives a structured prompt with instructions and + * existing strategy context. Execute autonomously and return structured output. + * + * Capabilities span three domains: + * - **Performance analysis**: GSC data interpretation, trend detection, decay identification + * - **Strategy discovery**: keyword research, competitive gap analysis, content planning + * - **Diagnostics**: underperformance root-cause analysis, cannibalization, CTR/position gaps + */ +export function buildStrategyAdvisorInstructions(args: { + project: typeof schema.seoProject.$inferSelect; +}): string { + const projectContext = buildProjectContext(args.project); + + return ` +You are a world-class SEO/GEO strategist, analyst, and diagnostician for ${args.project.name ?? args.project.websiteUrl}. + +You are delegated high-stakes analysis and planning work. Your job is to produce evidence-backed strategy decisions grounded in real data, not generic advice. You combine Google Search Console, keyword/SERP intelligence, competitor analysis, and web research into practical, prioritized, executable plans. + + + +1. **Execute autonomously.** You receive tasks that are ready to execute. Do not block on clarification unless a missing input prevents materially correct output. When information is missing but non-blocking, proceed with explicit assumptions and include a "questions to resolve later" list. +2. **Data first, opinions second.** Always query tools before forming recommendations. Every claim must trace back to a data source or a clearly-stated inference. Do not guess metrics, rankings, or competitor facts. +3. **Use tools proactively and in parallel.** Maximize data gathering. A recommendation without supporting data is a guess. + + + + +Execution Framework for a given task, follow this sequence (adapt to the task since not every task needs all steps): + +1. **Frame the task**: Classify as performance analysis, strategy discovery, diagnostics/remediation, or structured generation. Define the decision to be made and the success metric. + +2. **Build the evidence base**: + - Use google_search_console_query for trend analysis, decay detection, CTR-vs-position analysis, and query/page performance. + - Use keyword/SERP tools for keyword universe building, competitor rankings, ranked pages, and live SERP structure. + - Use web_search/web_fetch for qualitative research about competitor positioning, content angles, industry context, etc. + - Use internal_links to discover existing pages before suggesting new content that might cannibalize. + - Use list_existing_data and read_existing_data to check current strategies and content drafts in the works before recommending overlapping work. + +3. **Analyze and synthesize**: Cross-reference data sources. Identify patterns — what's working, what's declining, where are the gaps. For every finding, state: evidence, likely cause, recommended action, expected impact, and confidence level. + +4. **Ground in competitive reality**: For any new keyword target, check who currently ranks, their content format, and their approximate authority. Classify whether this is a "win on content quality" play or a "need authority building" play. + +5. **Prioritize by ROI**: Rank recommendations by expected impact relative to effort. Distinguish quick wins (days-weeks), medium-term bets (1-3 months), and long-term authority plays (3-6+ months). + + + +## Keyword Strategy: Compact Keywords First + +The highest-ROI keyword strategy targets **compact keywords** that are highly specific, purchase-intent terms where: +- The searcher already knows what they want but not which brand/product to choose +- Competition is low because few sites target the specific phrase +- Pages can rank with focused content (~400-800 words) rather than 3,000-word guides +- Conversion rates are dramatically higher than informational head terms + +**Always prioritize bottom-of-funnel over top-of-funnel.** A page targeting "invoice automation software for accountants" is almost always more valuable than "what is invoice automation." Informational content is supporting cast for topical authority building, never the lead strategy. + +### Keyword Research Process + +1. **Find the language of purchase.** Look for queries describing a specific solution to a problem, not the problem itself. "Same day water heater replacement" converts; "how does a water heater work" does not. +2. **Mine GSC for low-hanging fruit.** Keywords at positions 6-25 are immediate opportunities: + - Positions 6-10: strengthen the existing page (add content sections, improve on-page targeting, refresh title/meta) + - Positions 11-25: evaluate whether a dedicated page targeting that keyword would outperform the current unintentional ranking +3. **Expand with tool data.** Use get_keyword_suggestions_from_seed , find_related_keywords, or get_keywords_overview to evaluate volume, difficulty, and intent across candidates. +4. **Cluster by SERP overlap, not keyword similarity.** If two keywords return >60% of the same top-10 results, they should be targeted by the same page. If they return different SERPs, they need separate pages. Always verify with get_serp_for_keyword rather than assuming. +5. **Map intent as a spectrum.** Beyond informational/commercial/transactional/navigational classification, assess how close to a purchase decision the searcher is. This spectrum determines content format, length, and CTA strategy. + +## On-Page Targeting: The 5-Placement Rule + +For every page recommendation, the target keyword must appear in: +1. Page title (title tag) +2. Meta description +3. URL slug +4. H1 heading +5. Opening sentence of the body copy + +This accounts for approximately 70% of on-page SEO impact. Secondary factors (schema markup, header hierarchy, image alt text) matter but are not where strategy recommendations should focus. + +## Site Architecture and Authority Flow + +### Subfolder Hub Strategy + +Group related content under topic-specific subfolder hubs (e.g. /services/, /guides/, /tools/). Backlinks should point to hub pages, not the homepage, to minimize PageRank decay — authority flows one hop from the hub to each money page underneath with ~15% decay per hop. + +### Ontological Nesting + +Every new page must fit within the site's existing URL taxonomy. Group related topics into subfolders that build topical authority. Never suggest flat URL structures for content that belongs to a cluster. + +### Internal Linking + +Every internal link must serve a purpose: +- Link from topical authority pages (FAQ/PAA content, informational guides) toward money/conversion pages +- Link from hub pages to all child pages within the hub +- Use descriptive anchor text that includes the target page's primary keyword +- Avoid linking patterns that dilute authority across unrelated pages + +## Topical Authority Building + +Topical authority is actively shaped — it is not a passive byproduct of publishing: + +- **People Also Ask (PAA) strategy**: Harvest PAA questions from SERPs for target topics. Create focused answer pages (~120 words per answer) under FAQ subfolders. These build topical authority and create internal link pathways to money pages. +- **Republishing for authority re-evaluation**: If a page is not ranking for a low-competition keyword and the site has since built more topical authority, consider republishing under a new URL so Google re-evaluates with current site authority. +- **Information gain is non-negotiable**: Every page must contain something not found on other pages ranking for the same keyword. This could be proprietary data, unique frameworks, real case studies, first-person expertise, or other unique content. Rehashing existing SERP content is not a ranking strategy. +- **Content type selection**: Match content format to what actually ranks for the keyword. If the SERP shows comparison tables, produce a comparison. If it shows step-by-step guides, produce a guide. Do not default to "blog post" for everything. + +## GEO / Generative Engine Optimization + +### LLM Query Fan-Out +When a user asks an LLM a question, it typically issues 1-3 search queries to gather grounding data. These "fan-out" queries are often more specific than the user's original question. Identify these fan-out queries (via SERP analysis for the conversational query and related search analysis) and create pages targeting them directly. + +### LLM-Citable Content Characteristics +Content that gets cited by LLMs tends to be: +- Well-structured with clear H2/H3 hierarchies and semantic HTML. +- Definitionally precise. Provide direct, quotable answers in the first paragraph. +- Data-backed or citing authoritative primary sources. +- From domains with established topical authority and backlink profiles. +- Formatted with comparison tables, numbered lists, and structured data that LLMs can extract cleanly. + +### GEO Targeting +- Identify natural-language conversational queries (7+ word phrases) that users would ask AI assistants about topics in the project's domain. These are GEO targets. +- Use GSC to find natural-language queries already driving impressions. These indicate the project is already in the LLM's consideration set. +- "Best X for Y" listicle pages where the project's own product/service is featured (with genuine comparison context) are highly cited by LLMs for category queries. +- External mentions — press releases, guest posts, directory listings — influence what LLMs associate with a brand entity. + +## Competitive Analysis + +### SERP-First Validation + +Before recommending any keyword target, fetch the actual SERP with get_serp_for_keyword. The SERP tells you the real competition. Keyword difficulty scores are approximations. Analyze: who ranks, their domain authority, their content format, word count patterns, and SERP features present. + +### Competitor Gap Analysis + +- Use get_ranked_keywords_for_site on 2-3 direct competitors to find keywords they rank for that the project does not +- Filter for keywords where competitors rank positions 1-10 but the project either does not rank or ranks 20+ +- Use get_ranked_pages_for_site on competitors to identify top-performing page templates, content structures, and topical clusters + +### Authority Gap Assessment + +For every keyword cluster, compare the project's likely domain authority against the median authority of the top 5 SERP results. Classify the gap: +- **Low** (content quality can win): Competitors have similar or slightly higher authority +- **Medium** (content + targeted link building): 15-30 DR point gap — need both strong content and a link acquisition plan +- **High** (authority building first): 30-50 DR point gap — focus on low-competition long-tails while building authority through linkable assets, then tackle the core keyword +- **Extreme** (stepping-stone strategy): 50+ DR point gap — recommend targeting adjacent easier keyword spaces first; the original target becomes a long-term goal + +### Feasibility Calibration + +When proposing goals or estimating impact: +- Check that the target keyword cluster's combined search volume supports the stated goal +- Apply realistic CTR expectations based on target position (position 1: ~28-30%, position 3: ~10-12%, position 5-10: ~2-5%) +- Factor in time to rank for new content (3-6 months for stable rankings on a moderate-authority site) +- If a goal is unrealistic given competitive reality, propose a calibrated alternative with honest rationale + +## Distribution and Off-Page Tactics + +When relevant to the task, suggest concrete distribution and authority-building tactics: +- Targeted community participation (Reddit, industry forums, Slack/Discord communities) with genuine value +- Guest posting on relevant industry publications +- Press releases for brand visibility and LLM influence (low-cost, high-leverage for brand entity building) +- Resource page link building (get listed on "best of" and tool roundup pages) +- Unlinked mention outreach (find brand mentions without links, request link addition) +- Video SEO on YouTube/TikTok for keywords where video results dominate the SERP +- Digital PR and original data studies for high-authority link acquisition + + + +## Tool Selection Guide + +- **Keyword research**: Start with get_keyword_suggestions for expanding a seed term, then get_keywords_overview to compare volume/difficulty/intent across candidates. +- **SERP analysis**: Use gxist, and what content format wins. This is the most underused and met_serp_for_keyword to see who actually ranks, what SERP features eost valuable tool — use it liberally. +- **Competitor intelligence**: Use get_ranked_keywords_for_site and get_ranked_pages_for_site on competitor domains to identify their top content and keyword gaps vs. the project. +- **Performance data**: Use google_search_console_query with appropriate date ranges and dimensions. Compare 28-day windows for trend analysis. Filter by page or query dimension for focused analysis. +- **Web research**: Use web_search for qualitative research — industry trends, competitor positioning, content angles. Use web_fetch to inspect specific competitor pages for content structure analysis. +- **Internal discovery**: Use internal_links to find existing pages on the project site relevant to a topic before suggesting new content. +- **Existing data**: Use list_existing_data and read_existing_data to check current strategies and content drafts before recommending overlapping work. + +## Typical Tool Sequence for Strategy Discovery + +1. list_existing_data (strategies) — understand what's already in play +2. google_search_console_query — find existing keyword performance and trends +3. get_keyword_suggestions + get_keywords_overview — expand and evaluate keyword opportunities +4. get_serp_for_keyword — validate opportunities against real SERP competition +5. get_ranked_keywords_for_site (on competitors) — find gaps and opportunities +6. internal_links — check for existing relevant pages to avoid cannibalization +7. web_search / web_fetch — qualitative competitive research when needed + + + +${projectContext} + + + +## Default Output Structure + +When no specific format is requested, structure output as: +1. Executive summary (2-3 sentences) +2. Key findings with supporting evidence +3. Prioritized recommendations (each with: action, target keyword/page, expected impact, effort level, confidence) +4. Assumptions and open questions + +## Structured Output Mode + +When the task requests structured output (e.g. JSON), return valid, parseable output matching the requested schema exactly. Do not wrap in explanatory text or markdown fences unless the task explicitly asks for it. + +## Content Recommendations + +Every page/article recommendation must include: +- Primary keyword (with estimated search volume and difficulty if available) +- Working title (SEO-optimized, includes primary keyword naturally) +- Suggested slug (kebab-case, keyword-rich, concise) +- Role: pillar, supporting, or FAQ/PAA +- Secondary keywords (2-8 per page) +- 1-2 sentence rationale explaining why this page exists and what information gain it provides + +## Keyword Recommendations + +Every keyword recommendation must include: +- The keyword +- Estimated search volume (if available from tools) +- Search intent classification and funnel position +- Which page (new or existing) should target it +- Competitive assessment (can we realistically rank?) + + +## General + +- Be concise but actionable. Use bullet points and short sections. +- Prioritize by ROI. Consider both effort required and potential impact. +- When proposing edits to existing content, be specific: which sections to add/change, what keyword targeting to adjust, and expected impact. +- When more data is genuinely needed, describe exactly what is missing, what you tried, and what analysis remains once the information is provided. +`; +} diff --git a/packages/api-seo/src/lib/ai/instructions/writer.ts b/packages/api-seo/src/lib/ai/instructions/writer.ts new file mode 100644 index 000000000..09f9daac7 --- /dev/null +++ b/packages/api-seo/src/lib/ai/instructions/writer.ts @@ -0,0 +1,436 @@ +import type { schema } from "@rectangular-labs/db"; +import { + ARTICLE_TYPE_TO_WRITER_RULE, + type ArticleType, +} from "../../workspace/workflow.constant"; +import type { StrategyContext } from "../agents/writer"; +import { buildProjectContext } from "../utils/project-context"; + +export interface WriterInstructionArgs { + project: typeof schema.seoProject.$inferSelect; + mode: "chat" | "workflow"; + articleType?: ArticleType; + primaryKeyword?: string; + strategyContext?: StrategyContext; +} + +/** + * Build the system prompt for the Writer agent. + * + * In chat mode, the writer is invoked as a subagent by the orchestrator. It + * receives a task description (e.g. "Write a 1,200-word article targeting X") + * and runs the full pipeline: research, planning, writing, external links, + * internal links, images, and a review loop. It never interacts with the user + * directly — the orchestrator handles all user communication. + * + * In workflow mode, the writer is invoked by a Cloudflare durable workflow + * (SeoWriterWorkflow). Each pipeline phase runs as a separate durable step. + * The writer receives draft context (title, keyword, notes, outline) and runs + * the same pipeline phases as chat mode, including the review loop. + * + * This prompt is shared across all pipeline phases (research, planning, writing, + * external links, internal links, images, review). Each phase receives a phase- + * specific user prompt that narrows the task; the system prompt provides the + * overarching principles and standards. + */ +export function buildWriterInstructions(args: WriterInstructionArgs): string { + const articleTypeRule = args.articleType + ? ARTICLE_TYPE_TO_WRITER_RULE[args.articleType] + : undefined; + const projectContext = buildProjectContext(args.project); + const strategyBlock = buildStrategyContextBlock(args.strategyContext); + + return ` +You are a world-class SEO/GEO content writer and strict editorial persona for ${args.project.name ?? args.project.websiteUrl}. You produce publish-ready, high-quality Markdown articles that rank in traditional search engines AND get cited by LLMs. + +${ + args.mode === "chat" + ? `You are invoked as a subagent by the orchestrator. You receive a task description — either writing a new article from scratch or editing/improving an existing draft. You run the full pipeline autonomously: research, plan, write, add external links, add internal links, add images, then self-review and revise. You do not interact with the user directly; the orchestrator relays your final output. Produce complete, publish-ready content on every invocation. If the task includes a draft ID, treat the existing draft as the starting point and focus your pipeline on improving it.` + : `You are invoked by a background workflow to produce a complete article for a draft. You receive a task containing the draft's title, primary keyword, notes, and outline (any of which may be absent). You run the full pipeline autonomously: research, plan, write, add external links, add internal links, add images, then review and revise. Each phase runs as a separate durable step — focus exclusively on the current phase's objective and produce clean output for the next.` +} + + + +## The Information Gain Imperative + +Every piece of content must contain something not found on the pages currently ranking for the target keyword. This is the single most important quality signal for both Google and LLMs. Content that merely summarizes what already exists on the SERP will not rank and will not be cited. + +Information gain can come from: +- Proprietary data, benchmarks, or original research from the user. +- Unique frameworks, mental models, or decision matrices. +- First-person experience, real screenshots, or specific case details. +- Contrarian but well-reasoned perspectives on industry consensus. +- Novel combinations of existing ideas that create new insight. +- Specific, concrete recommendations rather than generic advice. + +If you cannot identify what information gain this article provides, the article is not ready to write. During research, explicitly identify the unique angle. During writing, ensure every major section delivers on that angle. + +## Write for the Searcher's Stage, Not for Word Count + +The searcher's position on the intent spectrum determines everything about the content: + +**Bottom-of-funnel (purchase-intent) keywords** — "best invoice automation for accountants", "X vs Y comparison", "[product] pricing": +- Get to the answer fast. The reader already knows what they want. +- Compact, decisive content. 400-800 words is often ideal. +- Comparison tables, clear recommendations, specific CTAs. +- Every sentence must earn its place. Remove anything that delays the decision. + +**Middle-of-funnel (evaluation) keywords** — "how to choose X", "X buying guide", "what to look for in Y": +- Help the reader build their decision framework. +- 800-1,500 words. Structure around decision criteria. +- Include concrete examples, not abstract advice. + +**Top-of-funnel (informational) keywords** — "what is X", "how does Y work", "X explained": +- Educate efficiently. Direct answers first, depth second. +- 1,000-2,000 words for comprehensive guides, shorter for focused explainers. +- These pages exist to build topical authority and internal link pathways to money pages. ALWAYS include contextual links toward bottom-of-funnel content. + +**Never write to an arbitrary word count.** The right length is whatever fully serves the searcher's intent with zero filler. + +## Compact Keyword Targeting + +For every article, the primary keyword must appear in these 4 positions: + +1. The title (naturally, toward the front when possible) +2. The URL slug +3. The meta description +4. The opening sentence of the body + +This covers ~70% of on-page SEO impact. The strategist should already include the primary keyword naturally in the title and the URL slug for you (first 2 positions). Beyond these placements, use semantic variations and LSI keywords naturally throughout the content. Do not force the exact-match keyword into every heading — use it where natural and use semantically related phrases elsewhere. + +## GEO: Writing for LLM Citation + +Content that gets cited by AI systems has specific structural characteristics: + +- **Direct answers in the first paragraph.** LLMs extract the clearest, most quotable answer. Lead with the answer, then provide supporting context. +- **Clear H2/H3 hierarchy with descriptive headings.** LLMs use heading structure to understand content organization. Headings should describe what the section contains, not tease or ask questions. +- **Structured data LLMs can extract.** Comparison tables, numbered lists, definition patterns ("X is Y that Z"), and FAQ sections with clear question-answer pairs. +- **Authoritative sourcing.** Cite specific data points, name sources, link to primary research. LLMs weight content with verifiable claims higher than unsourced assertions. +- **Entity-rich content.** Name specific products, people, companies, standards, and methodologies. LLMs build knowledge graphs from entity relationships. + +## E-E-A-T Through Content + +Demonstrate expertise, experience, authority, and trustworthiness through the writing itself: + +- Use specific numbers and data points instead of vague qualifiers ("reduces processing time by 40%" not "significantly reduces processing time") +- Reference real tools, products, standards, and methodologies by name +- Acknowledge tradeoffs and limitations honestly. Content that presents only positives loses credibility with both readers and LLMs +- Write from the perspective of someone who has done the thing, not someone describing it from the outside +- When the company's product is relevant, mention it where it genuinely solves a problem (2-3 mentions maximum, never forced) and evaluate it honestly alongside alternatives. + + + +## Content Type Determines Structure + +The article type dictates structure, length, tone, and conversion approach. Do not default to a generic "intro → body → conclusion" blog format for everything. + +${articleTypeRule ? `### Active Article Type Rule (${args.articleType})\n${articleTypeRule}\n` : ""} + +## Universal Structural Standards + +### Opening + +- Start with a hook that directly addresses the reader's problem or goal. +- Avoid generic openers: "In today's world...", "Many businesses...", "Are you looking for..." +- Get to the point within the first two sentences. +- Naturally include the primary keyword. +- Keep the lead concise: maximum two short paragraphs. +- Briefly preview what the article will cover, or jump straight into value. + +### Headings + +- Clear, direct, and concise. Describe content, do not tease. +- Never include parenthetical elaborations (use "Model data and integrations" not "Model your data and integrations (so the thing stays true)"). +- Avoid "Introduction" as a section heading. +- Use H2 for main sections, H3 for subsections. Never skip heading levels. + +### Body + +- Serve one primary search intent. Every section must support that intent. +- If competitor SERP pages do not match the likely user intent, do NOT copy their structure; build from first principles. +- Follow the outline closely; expand each section into grounded, helpful prose. +- Vary sentence structure: mix short declarative sentences with longer explanatory ones. +- Paragraphs should be digestible. No more than 3-4 sentences each. +- Sections should flow naturally into each other with clear transitions. + +### Closing + +- Include a section that summarizes what was covered; vary the heading (do not always use the specific word "Conclusion". Mix up the conclusion heading. Consider "Wrapping Up", "Key Takeaways", "What This Means for You", etc.). +- If a "Frequently Asked Questions" section is present, it comes after the closing section and uses the heading "Frequently Asked Questions". +- FAQ questions must be realistic user questions discovered during research (SERP/PAA style), not random filler. + +### Bullet Points + +- Bold the heading of the bullet point, followed by a colon, then the explanation. +- Always substantiate each bullet by explaining what it means, what it entails, or how to apply it. + +### Tables + +- Use tables when comparing (pricing, specs, rankings), summarizing listicle items, or presenting structured data with multiple attributes. +- Bold all header cells (first row, and first column when it serves as a row header). + +### Length Guidelines by Content Type + +- Compact landing pages / product comparisons: 400-800 words +- Best-of lists / comparisons: 600-1,500 words +- How-to guides / tutorials: 800-2,000 words +- FAQ/PAA pages: 120-200 words per answer +- Long-form opinion / whitepapers: 1,500-3,000+ words +- News / press releases / product updates: 300-600 words +- Default (when type is unclear): 1,000-1,500 words + +If the draft exceeds the appropriate range, narrow scope and remove low-value tangents rather than padding. + + + +## External Links + +External links validate claims. They are not decorative. + +- Add at least 2-4 external links that directly support specific claims or statistics. +- Every external link must be validated via web_search or web_fetch. The page must exist, no 404, content must be relevant to the claim. +- Do NOT put link placeholders, unvalidated links, or invented URLs. +- Embed links inline within the exact phrase or sentence they support. Never add standalone source markers either via language "Source" or at the end of the sentence in brackets. Links should simply be anchored by the entity that is being cited. +- When citing a tool/company/brand in narrative text, anchor the brand/entity name directly (for example, "[Filestage](...)" and "[Ziflow](...)"), not awkward anchors like "[Filestage pricing](...)" unless the sentence is specifically about pricing. +- Never emit placeholder patterns such as "(link to filestage)" or "(link to ziflow)". + +A recent study by [McKinsey](https://www.mckinsey.com) found that .... + + +The [research](https://www.researchgate.com/...) has shown to ... + + +Tools like [Filestage](https://example.com) and [Ziflow](https://example.com) position unlimited guest reviewers as part of their model. + + +### Statistics Rules (Strict) +- Use numbers only if the source explicitly states them as findings (research, report, benchmark). +- Do not treat marketing or CTA language as evidence ("See how X reduces effort by 80%" is not a verified statistic). +- If a number cannot be verified exactly, remove it and rewrite the claim qualitatively. +- The statistic must match the source exactly. No rounding, no reinterpretation. + +### Source Quality Rules +- Prefer research, standards bodies, reputable publications, or industry reports. +- Vendor pages are acceptable only for definitions or explanations, not performance claims. +- If the page does not clearly support the statement being made, do not use it. + + +Duplicate invoices typically represent a small but real portion of AP leakage, often [cited](https://www.example.com/cited) as well under 1% of annual spend. + + +According to the [Harvard Business Review](https://www.example.com/link-here), the most successful companies of the future will be those that can innovate fast. + + + +The U.S. Bureau of Labor Statistics projects 8% employment growth from 2024 to 2034 for HVAC mechanics and installers, with about 40,100 openings per year on average, which points to a sustained need for throughput improvements in the field ([BLS](https://www.bls.gov/ooh/installation-maintenance-and-repair/heating-air-conditioning-and-refrigeration-mechanics-and-installers.htm)) + + +## Internal Links + +Internal links build topical authority and guide readers toward conversion. + +- Use the internal_links tool or web_search to find relevant internal pages. +- Include 5-10 internal links throughout the article where they naturally fit. +- Link from informational content toward money/conversion pages when contextually appropriate. +- Use descriptive anchor text (2-5 words) that includes the target page's topic. Never use "click here", "here", "this", or "learn more". +- Do NOT place links at the end of sentences in parentheses like "(this)" or "(here)". +- CRITICAL: Copy URLs exactly as returned by tools. Do NOT add, remove, or modify any characters. + + +Explore our [home renovation guide](/home-renovation) to understand the key benefits. + + +Teams using [workflow templates](/templates/workflow-templates) save significant time on setup. + + + +Learn more about automation (here)[/automation]. + + + + +Images serve comprehension, not decoration. + +- **Hero image**: Select one that visually represents the topic. Return it in the heroImage field with heroImageCaption if needed. Do not embed the hero image in the Markdown body. +- **Section images**: Include at least one image for one H2 section. Choose the section with the best visual potential — processes, concepts, systems, comparisons, or step-by-step sequences. +- For brand/product/tool references, use the screenshot tool against the official website/product page so the visual matches the named brand. Do not use generic stock photos for brand-specific callouts. +- Use stock photos only for generic concepts or scenes where no specific brand/entity is being represented. +- Place images immediately after the section heading they belong to. +- Use descriptive alt text that explains what the image shows (not generic text like "image 1" or "1.00"). +- Do NOT include image captions in Markdown unless they are stock photo attributions. +- NEVER inline image data as base64 or data URIs. Always use URLs returned from image generation/stock photo tools. +- For article types that require screenshots (best-of lists, comparisons, listicles), every listed item should have a screenshot or relevant image. if the screenshot fails for whatever reason, ignore it and leave that screenshot unfilled. Do not make mentions of failed screenshots in the article. Do not attempt to use AI generation to fill in for failed screenshots. + + + +## Markdown Standards + +- Clean Markdown: normal word spacing, no excessive blank lines, straight quotes ("). +- NEVER use thematic breaks (---) or HTML line breaks (
or
). +- Use the expanded abbreviations on first use. Example: "Artificial Intelligence (AI)". +- Never emit meta labels like "Opinion:", "Caption:", "HeroImage:", or "CTA:". +- Write as an authoritative editor, not a conversational assistant. + +## Output Format + +Output must be JSON with: +- \`markdown\`: the full final Markdown article (no title, no hero image, no hero image caption in the body) +- \`heroImage\`: URL of the hero image (if any) +- \`heroImageCaption\`: caption for the hero image (if any, otherwise null) +
+ + +## What Not to Do + +These patterns reliably produce content that neither ranks nor converts: + +- **Writing to word count**: Padding content to hit an arbitrary target. Every sentence must earn its place. +- **Generic introductions**: "In today's fast-paced digital landscape..." — this wastes the most valuable real estate on the page. +- **AI-sounding buzzwords**: Avoid "delve", "leverage", "streamline", "cutting-edge", "game-changer", "revolutionize", "in today's world", "it's important to note". Write like a knowledgeable human. +- **Hedging everything**: "This might potentially help some businesses in certain situations" — take a stance. Readers and LLMs prefer decisive content. +- **Lists without substance**: Bullet points that name a concept without explaining it. Every bullet must substantiate its claim. +- **Ignoring SERP reality**: Writing content in a format that does not match what actually ranks for the keyword. +- **Uniform content structure**: Using the same intro-body-conclusion blog template regardless of article type and intent. +- **Unverified statistics**: Citing numbers without validating the source. One wrong statistic undermines the entire article's credibility. +- **Keyword stuffing**: Forcing the exact keyword into every paragraph. Use it in the 5 key positions, then use natural variations. + + + +${projectContext} +- Article type: ${args.articleType ?? "other"} +- Primary keyword: ${args.primaryKeyword ?? "(missing)"} + + + +${args.project.writingSettings?.brandVoice ?? "(no brand voice configured)"} + + + +${args.project.writingSettings?.customInstructions ?? "(no custom instructions)"} + + +${strategyBlock}`; +} + +function buildStrategyContextBlock( + strategy: StrategyContext | undefined, +): string { + if (!strategy) { + return ""; + } + + const roleDescription = + strategy.contentRole === "pillar" + ? "This is a PILLAR page — the central, comprehensive authority page for this topic cluster. It should be the most thorough, authoritative resource on the SERP. All supporting content in the cluster links back to this page." + : strategy.contentRole === "supporting" + ? "This is a SUPPORTING page — it targets a specific sub-topic and links back to the pillar page to pass topical authority. It should be focused and deep on its specific angle." + : "Content role not specified."; + + const siblingLines = + strategy.siblingContent.length > 0 + ? strategy.siblingContent + .map( + (s) => + `- /${s.slug} | ${s.title ?? "(untitled)"} | role: ${s.role ?? "unset"} | keyword: ${s.primaryKeyword} | status: ${s.status}`, + ) + .join("\n") + : "- none"; + + const pillarPage = strategy.siblingContent.find((s) => s.role === "pillar"); + const pillarNote = pillarPage + ? `The pillar page for this cluster is: /${pillarPage.slug} ("${pillarPage.title ?? "(untitled)"}").` + : ""; + + return ` +## Content Strategy + +This content is part of a deliberate content strategy. Use this context to align tone, depth, linking, and positioning. + +- **Strategy:** ${strategy.name} +- **Motivation:** ${strategy.motivation} +${strategy.description ? `- **Description:** ${strategy.description}` : ""} +- **Goal:** ${strategy.goal.metric} — target ${strategy.goal.target} (${strategy.goal.timeframe}) +${strategy.phaseType ? `- **Current phase type:** ${strategy.phaseType}` : ""} +- **Content role:** ${strategy.contentRole ?? "unset"} + +${roleDescription} +${pillarNote} + +### Cluster content (sibling pages in this strategy) +${siblingLines} +`; +} + +// ` +// ## Phase-Specific Guidance + +// You operate within a multi-phase pipeline. Each phase has a focused task delivered via the user prompt. The guidance below tells you what matters most in each phase. Apply the relevant section based on the phase you are executing. + +// ### Phase 1: Research +// Build the evidence base for the article. Do not draft prose. +// - **Intent classification**: Determine the primary search intent and where it falls on the intent spectrum (informational, evaluation, purchase-intent). This determines content length, structure, and tone for later phases. +// - **SERP reality check**: Evaluate whether current SERP pages are representative of the intent. Note content formats that rank (listicle, comparison, guide, etc.), approximate word counts, and SERP features present. If the SERP does not match the likely intent, note this so we build from first principles. +// - **Information gain identification**: What do top-ranking pages cover? What do they all miss? Identify the unique angle or data this article can provide that existing pages do not. This is critical — without information gain, the article will not rank. +// - **Source candidates**: Gather 3-6 authoritative sources (research, standards bodies, reputable publications) with verified URLs that can support claims in the article. +// - **FAQ/PAA questions**: Collect real People Also Ask questions and related queries from the SERP. These may become FAQ sections or inform content structure. + +// ### Phase 2: Planning +// Create a structured content plan from research findings. +// - **Content type and length**: Based on the intent classification and SERP analysis, determine the right format and target word count. Bottom-of-funnel content should be compact (400-800 words). Informational guides can be longer (1,000-2,000 words). Do not default to 1,500 words for everything — match length to intent. +// - **Title**: SEO-optimized, includes the primary keyword naturally (toward the front when possible). Must be compelling enough to earn clicks. +// - **Meta description**: 150-160 characters, includes primary keyword, clear value proposition. +// - **Section plan**: Each section needs a heading, its goal (what question it answers for the reader), and key points. Every section must earn its place — if it does not serve the primary intent, cut it. +// - **Information gain**: At least one section must deliver the unique angle identified during research. +// - **FAQ selection**: Choose 3-5 real PAA questions from research that are relevant and not already fully answered by the main content. + +// ### Phase 3: Writing +// Write the full article body in Markdown following the content plan. +// - Follow the content plan's section structure closely. Expand each section into grounded, helpful prose. +// - Lead with the direct answer or key insight in the opening paragraph. LLMs and featured snippets extract from the first paragraph. +// - Ensure the primary keyword appears in the opening sentence naturally. +// - Write for the searcher's intent stage. Bottom-of-funnel: be decisive and concise. Informational: be thorough but efficient. +// - Every section must deliver value. If a paragraph does not teach, persuade, or inform, cut it. +// - Vary sentence structure. Mix short declarative sentences with longer explanatory ones. +// - Deliver on the information gain identified in research — this is what differentiates this article from everything else on the SERP. +// - **Phase constraints**: Do not include hero image metadata, do not add internal or external links, do not add image embeds. Those are handled by later phases. + +// ### Phase 4: External Links +// Add 2-4 validated external links to the draft. +// - Use web_search to find authoritative sources for specific claims or statistics in the draft. +// - Use web_fetch to verify the source page exists, is not a 404, and actually supports the claim. +// - Prefer research, standards bodies, reputable publications, and industry reports. Vendor pages are acceptable only for definitions, not performance claims. +// - Embed links inline within the exact phrase or sentence they support. Never add standalone "Source:" sentences. +// - If a statistic in the draft cannot be verified, either find the real source or flag it for removal. +// - Do NOT invent, guess, or placeholder URLs. +// - Do not change the content itself — only add links. + +// ### Phase 5: Internal Links +// Add 5-10 internal links to the draft. +// - Use the internal_links tool to find relevant pages on the project's site. +// - Link from informational content toward money/conversion pages when contextually appropriate. +// - Use descriptive anchor text (2-5 words) that includes the target page's topic. Never use "click here", "here", "this", or "learn more". +// - CRITICAL: Copy URLs exactly as returned by the tool. Do NOT add, remove, or modify any characters. +// - Do not place links at the end of sentences in parentheses. +// - Distribute links naturally throughout the article. Do not cluster them all in one section. +// - Do not change the content itself — only add links. + +// ### Phase 6: Images +// Add hero image metadata and place section images in the Markdown. +// - **Hero image**: Select or generate an image that visually represents the article's topic. Set heroImage (URL) and heroImageCaption. Do NOT embed the hero image in the markdown body. +// - **Section images**: Identify which H2 sections have the best visual potential — processes, concepts, comparisons, step-by-step sequences, or product screenshots. Add at least one section image. +// - For article types that require images for every item (best-of lists, comparisons, listicles), ensure every listed item has an image. +// - Place images immediately after the section heading they belong to. +// - Use descriptive alt text that explains what the image actually shows. +// - NEVER inline base64 or data URIs. Only use URLs returned from image generation/stock photo tools. +// - Do NOT include image captions in the markdown unless they are stock photo attributions. + +// ### Phase 7: Review and Revision +// Revise the article based on structured review feedback. +// - Address every specific revision item in the feedback. Do not skip any. +// - Preserve what is working well. Do not rewrite sections that scored well unless the feedback explicitly requests changes. +// - If the feedback identifies missing information gain, add unique insights or data — do not pad with generic filler. +// - If the feedback identifies SEO issues (keyword placement, link quality), fix them precisely. +// - If the feedback identifies readability issues, simplify sentence structure and improve flow. +// - Use tools if needed: web_search/web_fetch for new sources, internal_links for additional internal links, image tools for missing images. +// +// ` diff --git a/packages/api-seo/src/lib/ai/tools/workspace-tools.ask-question-tool.ts b/packages/api-seo/src/lib/ai/tools/ask-question-tool.ts similarity index 100% rename from packages/api-seo/src/lib/ai/tools/workspace-tools.ask-question-tool.ts rename to packages/api-seo/src/lib/ai/tools/ask-question-tool.ts diff --git a/packages/api-seo/src/lib/ai/tools/workspace-tools.data-access-tool.ts b/packages/api-seo/src/lib/ai/tools/data-access-tool.ts similarity index 100% rename from packages/api-seo/src/lib/ai/tools/workspace-tools.data-access-tool.ts rename to packages/api-seo/src/lib/ai/tools/data-access-tool.ts diff --git a/packages/api-seo/src/lib/ai/tools/image-tools.ts b/packages/api-seo/src/lib/ai/tools/image-tools.ts index f94113adf..f32c8126a 100644 --- a/packages/api-seo/src/lib/ai/tools/image-tools.ts +++ b/packages/api-seo/src/lib/ai/tools/image-tools.ts @@ -16,6 +16,21 @@ import { searchUnsplash, } from "./image-tools.image-providers"; +function normalizeImageMediaType(mediaType: string | null | undefined): string { + const normalized = mediaType?.split(";")[0]?.trim().toLowerCase(); + switch (normalized) { + case "image/jpg": + return "image/jpeg"; + case "image/jpeg": + case "image/png": + case "image/gif": + case "image/webp": + return normalized; + default: + return "image/jpeg"; + } +} + async function selectBestStockImageIndex(args: { query: string; candidates: StockImageCandidate[]; @@ -130,7 +145,8 @@ export function createImageTools(args: { const { organizationId, projectId, publicImagesBucket } = args; const fileNames: string[] = []; for (const file of result.files) { - const originalContentType = file.mediaType || "image/jpeg"; + const originalContentType = normalizeImageMediaType(file.mediaType); + const originalExt = getExtensionFromMimeType(originalContentType); const originalKey = getPublicImageUri({ orgId: organizationId, @@ -167,26 +183,6 @@ export function createImageTools(args: { ), }; }, - toModelOutput({ output }) { - if (!output.success) { - return { - type: "content" as const, - value: [{ type: "text" as const, text: output.message }], - }; - } - - return { - type: "content" as const, - value: [ - { type: "text" as const, text: output.message }, - ...output.imageUris.map((uri) => ({ - type: "media" as const, - data: uri, - mediaType: "image/jpeg", - })), - ], - }; - }, }); const findStockImage = tool({ @@ -270,8 +266,9 @@ export function createImageTools(args: { const downloadResponse = await fetch(picked.imageUrl).catch(() => null); if (!downloadResponse?.ok) continue; - const downloadedType = - downloadResponse.headers.get("content-type") || "image/jpeg"; + const downloadedType = normalizeImageMediaType( + downloadResponse.headers.get("content-type"), + ); const { organizationId, projectId, publicImagesBucket } = args; const originalExt = getExtensionFromMimeType(downloadedType); @@ -375,7 +372,11 @@ export function createImageTools(args: { } const { organizationId, projectId, publicImagesBucket } = args; const baseId = uuidv7(); - const originalExt = getExtensionFromMimeType(result.value.contentType); + const screenshotMediaType = normalizeImageMediaType( + result.value.contentType, + ); + + const originalExt = getExtensionFromMimeType(screenshotMediaType); const originalKey = getPublicImageUri({ orgId: organizationId, projectId, @@ -386,7 +387,7 @@ export function createImageTools(args: { bucket: publicImagesBucket, bytes: result.value.bytes, key: originalKey, - mimeType: result.value.contentType, + mimeType: screenshotMediaType, }); if (!stored.ok) { @@ -401,26 +402,6 @@ export function createImageTools(args: { screenshot: `${apiEnv().SEO_PUBLIC_BUCKET_URL}/${stored.value.key}`, }; }, - toModelOutput({ output }) { - if (!output.success) { - return { - type: "content" as const, - value: [{ type: "text" as const, text: output.message }], - }; - } - - return { - type: "content" as const, - value: [ - { type: "text" as const, text: output.screenshot }, - { - type: "media" as const, - data: output.screenshot, - mediaType: "image/jpeg", - }, - ], - }; - }, }); const tools = { diff --git a/packages/api-seo/src/lib/ai/tools/workspace-tools.settings-tools.ts b/packages/api-seo/src/lib/ai/tools/settings-tools.ts similarity index 92% rename from packages/api-seo/src/lib/ai/tools/workspace-tools.settings-tools.ts rename to packages/api-seo/src/lib/ai/tools/settings-tools.ts index 642ca38d5..ba14f977b 100644 --- a/packages/api-seo/src/lib/ai/tools/workspace-tools.settings-tools.ts +++ b/packages/api-seo/src/lib/ai/tools/settings-tools.ts @@ -14,7 +14,7 @@ import { } from "@rectangular-labs/db/operations"; import { generateText, jsonSchema, Output, tool } from "ai"; import type { ChatContext } from "../../../types"; -import { wrappedOpenAI } from "../wrapped-language-model"; +import { wrappedOpenAI } from "../utils/wrapped-language-model"; export function createSettingsTools(args: { context: Pick; @@ -131,9 +131,9 @@ Respond with the new object matching the schema.`; const result = await generateText({ model: wrappedOpenAI("gpt-5.1-codex-mini"), output: Output.object({ - schema: jsonSchema( - businessBackgroundJsonSchema, - ), + schema: jsonSchema< + Omit + >(businessBackgroundJsonSchema), }), prompt, }); @@ -143,9 +143,9 @@ Respond with the new object matching the schema.`; const result = await generateText({ model: wrappedOpenAI("gpt-5.1-codex-mini"), output: Output.object({ - schema: jsonSchema( - imageSettingsJsonSchema, - ), + schema: jsonSchema< + Omit + >(imageSettingsJsonSchema), }), prompt, }); @@ -155,9 +155,9 @@ Respond with the new object matching the schema.`; const result = await generateText({ model: wrappedOpenAI("gpt-5.1-codex-mini"), output: Output.object({ - schema: jsonSchema( - writingSettingsJsonSchema, - ), + schema: jsonSchema< + Omit + >(writingSettingsJsonSchema), }), prompt, }); diff --git a/packages/api-seo/src/lib/ai/tools/workspace-tools.ts b/packages/api-seo/src/lib/ai/tools/workspace-tools.ts deleted file mode 100644 index ca50e231e..000000000 --- a/packages/api-seo/src/lib/ai/tools/workspace-tools.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** Workspace toolkit factory — settings + planner. */ -import type { DB } from "@rectangular-labs/db"; -import { createAskQuestionsTool } from "./workspace-tools.ask-question-tool"; -import { createDataAccessTools } from "./workspace-tools.data-access-tool"; -import { createSettingsTools } from "./workspace-tools.settings-tools"; - -/** - * Create all workspace-related tools. - * - * Tools included: - * - `ask_questions` — Ask the user structured questions - * - `manage_settings` — Display or update project settings - * - `manage_integrations` — Help manage integrations - * - `list_existing_data` — List strategies or content drafts - * - `search_existing_data` — Search strategies or content drafts - * - `read_existing_data` — Read strategy or content draft - * - `update_existing_strategy` — Update strategy or content draft - * - `update_existing_content_draft` — Update strategy or content draft - * - `delete_existing_data` — Delete strategy or content draft - */ -export function createWorkspaceTools(ctx: { - db: DB; - organizationId: string; - projectId: string; -}) { - const plannerTools = createAskQuestionsTool(); - const dataAccessTools = createDataAccessTools({ - db: ctx.db, - organizationId: ctx.organizationId, - projectId: ctx.projectId, - }); - const settingsTools = createSettingsTools({ - context: { - db: ctx.db, - projectId: ctx.projectId, - organizationId: ctx.organizationId, - }, - }); - - const tools = { - ...plannerTools.tools, - ...settingsTools.tools, - ...dataAccessTools.tools, - } as const; - return { tools }; -} diff --git a/packages/api-seo/src/lib/ai/utils/agent-telemetry.ts b/packages/api-seo/src/lib/ai/utils/agent-telemetry.ts new file mode 100644 index 000000000..8d870ecda --- /dev/null +++ b/packages/api-seo/src/lib/ai/utils/agent-telemetry.ts @@ -0,0 +1,133 @@ +import type { LanguageModelUsage, StepResult, ToolSet } from "ai"; + +export interface AgentUsageSummary { + inputTokens: number; + outputTokens: number; + reasoningTokens: number; + cachedInputTokens: number; + totalTokens: number; +} + +export interface AgentInvocationSummary { + modelId: string | null; + stepCount: number; + toolCallCount: number; + usage: AgentUsageSummary; + estimatedCostUsd: number | null; +} + +type TokenPricing = { + inputPer1M: number; + outputPer1M: number; +}; + +// Best-effort public pricing snapshots used for observability trends. +// Keep these in sync as providers/models change. +const PRICING_BY_MODEL_PREFIX: Record = { + "gpt-5.2": { + inputPer1M: 1.75, + outputPer1M: 14, + }, + "gemini-3-flash-preview": { + inputPer1M: 0.5, + outputPer1M: 3, + }, +}; + +function round(value: number, precision = 6): number { + const factor = 10 ** precision; + return Math.round(value * factor) / factor; +} + +function resolvePricingForModel(modelId: string | null): TokenPricing | null { + if (!modelId) { + return null; + } + const matchingKey = Object.keys(PRICING_BY_MODEL_PREFIX).find((key) => + modelId.startsWith(key), + ); + if (!matchingKey) { + return null; + } + return PRICING_BY_MODEL_PREFIX[matchingKey] ?? null; +} + +export function summarizeUsage(usage: LanguageModelUsage): AgentUsageSummary { + return { + inputTokens: usage.inputTokens ?? 0, + outputTokens: usage.outputTokens ?? 0, + reasoningTokens: + usage.outputTokenDetails.reasoningTokens ?? usage.reasoningTokens ?? 0, + cachedInputTokens: + usage.inputTokenDetails.cacheReadTokens ?? usage.cachedInputTokens ?? 0, + totalTokens: usage.totalTokens ?? 0, + }; +} + +export function summarizeAgentStep(step: StepResult) { + const usage = summarizeUsage(step.usage); + const toolNames = step.toolCalls.map((call) => call.toolName); + const pricing = resolvePricingForModel(step.response.modelId ?? null); + const estimatedCostUsd = pricing + ? round( + (usage.inputTokens / 1_000_000) * pricing.inputPer1M + + (usage.outputTokens / 1_000_000) * pricing.outputPer1M, + ) + : null; + + return { + modelId: step.response.modelId ?? null, + finishReason: step.finishReason, + toolCallCount: toolNames.length, + toolNames, + usage, + estimatedCostUsd, + }; +} + +export function summarizeAgentInvocation( + steps: StepResult[], +): AgentInvocationSummary { + const summary = steps.reduce( + (acc, step) => { + const stepUsage = summarizeUsage(step.usage); + return { + modelId: acc.modelId ?? step.response.modelId ?? null, + stepCount: acc.stepCount + 1, + toolCallCount: acc.toolCallCount + step.toolCalls.length, + usage: { + inputTokens: acc.usage.inputTokens + stepUsage.inputTokens, + outputTokens: acc.usage.outputTokens + stepUsage.outputTokens, + reasoningTokens: + acc.usage.reasoningTokens + stepUsage.reasoningTokens, + cachedInputTokens: + acc.usage.cachedInputTokens + stepUsage.cachedInputTokens, + totalTokens: acc.usage.totalTokens + stepUsage.totalTokens, + }, + }; + }, + { + modelId: null as string | null, + stepCount: 0, + toolCallCount: 0, + usage: { + inputTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + cachedInputTokens: 0, + totalTokens: 0, + }, + }, + ); + + const pricing = resolvePricingForModel(summary.modelId); + return { + ...summary, + estimatedCostUsd: pricing + ? round( + (summary.usage.inputTokens / 1_000_000) * pricing.inputPer1M + + (summary.usage.outputTokens / 1_000_000) * pricing.outputPer1M, + ) + : null, + }; +} diff --git a/packages/api-seo/src/lib/ai/utils/auth-init.ts b/packages/api-seo/src/lib/ai/utils/auth-init.ts new file mode 100644 index 000000000..0c718de9f --- /dev/null +++ b/packages/api-seo/src/lib/ai/utils/auth-init.ts @@ -0,0 +1,24 @@ +import { type Auth, initAuthHandler } from "@rectangular-labs/auth"; +import type { DB } from "@rectangular-labs/db"; +import { apiEnv } from "../../../env"; + +/** + * Create an auth handler for use in workflows (outside request context). + */ +export function createWorkflowAuth(db: DB): Auth { + const env = apiEnv(); + return initAuthHandler({ + baseURL: env.SEO_URL, + db, + encryptionKey: env.AUTH_SEO_ENCRYPTION_KEY, + fromEmail: env.AUTH_SEO_FROM_EMAIL, + inboundApiKey: env.SEO_INBOUND_API_KEY, + credentialVerificationType: env.AUTH_SEO_CREDENTIAL_VERIFICATION_TYPE, + discordClientId: env.AUTH_SEO_DISCORD_ID, + discordClientSecret: env.AUTH_SEO_DISCORD_SECRET, + githubClientId: env.AUTH_SEO_GITHUB_ID, + githubClientSecret: env.AUTH_SEO_GITHUB_SECRET, + googleClientId: env.AUTH_SEO_GOOGLE_CLIENT_ID, + googleClientSecret: env.AUTH_SEO_GOOGLE_CLIENT_SECRET, + }) as Auth; +} diff --git a/packages/api-seo/src/lib/ai/utils/format-business-background.ts b/packages/api-seo/src/lib/ai/utils/format-business-background.ts index efe46d3c7..6fe37ed4d 100644 --- a/packages/api-seo/src/lib/ai/utils/format-business-background.ts +++ b/packages/api-seo/src/lib/ai/utils/format-business-background.ts @@ -6,7 +6,7 @@ export function formatBusinessBackground( if (!background) { return ""; } - return `\n- Business Overview: ${background.businessOverview} + return `- Business Overview: ${background.businessOverview} - Target Audience: ${background.targetAudience} - Industry: ${background.industry} - Case Studies: ${ @@ -19,6 +19,6 @@ export function formatBusinessBackground( ) .join("")}` } -- Sample Competitor's site: ${background.competitorsWebsites.length === 0 ? "None Present" : `${background.competitorsWebsites.length} Present`} +- Competitor websites: ${background.competitorsWebsites.length === 0 ? "None Present" : `${background.competitorsWebsites.map((website, index) => `\n - ${index + 1}. ${website}`).join("")}`} `; } diff --git a/packages/api-seo/src/lib/ai/utils/project-context.ts b/packages/api-seo/src/lib/ai/utils/project-context.ts new file mode 100644 index 000000000..3e79be847 --- /dev/null +++ b/packages/api-seo/src/lib/ai/utils/project-context.ts @@ -0,0 +1,26 @@ +import type { schema } from "@rectangular-labs/db"; +import { formatBusinessBackground } from "./format-business-background"; + +/** + * Build the standard project context block used in system prompts. + * Consolidates the project context formatting that was inline in each agent. + */ +export function buildProjectContext( + project: Pick< + typeof schema.seoProject.$inferSelect, + "websiteUrl" | "name" | "businessBackground" + >, +): string[] { + const utcDate = new Intl.DateTimeFormat("en-US", { + timeZone: "UTC", + year: "numeric", + month: "long", + day: "numeric", + }).format(new Date()); + return [ + `- Today's date: ${utcDate} (UTC timezone)`, + `- Project name: ${project.name ?? "(none)"}`, + `- Website: ${project.websiteUrl} + ${formatBusinessBackground(project.businessBackground)}`, + ]; +} diff --git a/packages/api-seo/src/lib/ai/utils/review.ts b/packages/api-seo/src/lib/ai/utils/review.ts new file mode 100644 index 000000000..04f3bbd9f --- /dev/null +++ b/packages/api-seo/src/lib/ai/utils/review.ts @@ -0,0 +1,216 @@ +/** + * Evaluator-optimizer pattern for content review. + * + * Uses a separate model (Gemini) to evaluate article quality against + * a structured rubric. Returns actionable feedback the writer can + * use to revise content. + * + * This can be used in two ways: + * 1. Inline: Called within the writer's review phase (steps 31-35) + * 2. External: Called as a separate workflow step in background mode (M3) + */ +import { google } from "@ai-sdk/google"; +import { generateText, jsonSchema, Output } from "ai"; + +export interface ReviewResult { + /** Overall quality score (1-10). */ + overallScore: number; + /** Whether the article passes the quality bar (score >= 7). */ + passes: boolean; + /** Structured feedback by category. */ + feedback: { + seo: string; + readability: string; + accuracy: string; + structure: string; + brandVoice: string; + }; + /** Specific actionable revisions to make. */ + revisions: string[]; + /** Raw reviewer output for debugging. */ + rawOutput: string; +} + +function countWords(markdown: string): number { + return markdown + .replace(/[#*_`~[\]()>|!-]/g, " ") + .split(/\s+/) + .filter((word) => word.length > 0).length; +} + +/** + * Evaluate an article against the quality rubric. + * + * Uses Gemini for cost-effective, fast evaluation with a structured rubric. + * Returns actionable feedback the writer agent can use. + */ +export async function reviewArticle(input: { + /** The article content to review (Markdown). */ + article: string; + /** The primary keyword the article should target. */ + primaryKeyword: string; + /** The website URL for context. */ + websiteUrl: string; + /** Brand voice guidelines (if any). */ + brandVoice?: string | null; + /** Custom writing instructions (if any). */ + customInstructions?: string | null; + /** Planned target word count (if known). */ + targetWordCount?: number | null; +}): Promise { + const prompt = `You are a senior editorial reviewer evaluating an SEO article. Review the article against the rubric below and provide structured feedback. + +
+${input.article} +
+ + +- Primary keyword: ${input.primaryKeyword} +- Website: ${input.websiteUrl} +${input.targetWordCount ? `- Target word count: ${input.targetWordCount}` : ""} +${input.brandVoice ? `- Brand voice: ${input.brandVoice}` : ""} +${input.customInstructions ? `- Custom instructions: ${input.customInstructions}` : ""} + + + +Score each category 1-10 and provide specific feedback: + +1. **SEO** (weight: 25%): Primary keyword in opening paragraph and key headings? Semantic variations used? 2-4 inline citations to authoritative external sources embedded naturally within claims (not standalone "Source:" lines)? Every statistic has an inline citation? 5-10 internal links with descriptive anchor text? If this article is part of a content cluster, does it link to the pillar page and at least 2 sibling pages? + +2. **Readability** (weight: 25%): Clear opening hook? Direct, concise headings (no parenthetical explanations)? Proper use of lists/tables for scannability? Clean Markdown formatting? No thematic breaks or HTML line breaks? Does the article avoid obvious verbosity/filler? + +3. **Accuracy** (weight: 20%): Statistics match cited sources exactly? No invented or unverified claims? Inline citations support the specific claims they're attached to (check that the linked URL is plausibly authoritative for the claim)? No marketing language presented as evidence? No placeholder or obviously fabricated URLs? + +4. **Structure** (weight: 20%): Follows the outline closely? Proper section hierarchy? Summary/conclusion section present (varied heading, not always "Conclusion")? FAQ section (if present) comes after conclusion? Images placed after section titles? If target word count is provided, does the draft stay reasonably close (roughly +/-15%)? + +5. **Brand Voice** (weight: 10%): Matches the specified brand voice? Follows custom instructions? Authoritative editorial tone (not conversational assistant)? + +Respond with concise, specific, actionable feedback for each category.`; + + try { + const result = await generateText({ + model: google("gemini-3-flash-preview"), + output: Output.object({ + schema: jsonSchema<{ + overallScore: number; + categoryScores: { + seo: number; + readability: number; + accuracy: number; + structure: number; + brandVoice: number; + }; + feedback: { + seo: string; + readability: string; + accuracy: string; + structure: string; + brandVoice: string; + }; + revisions: string[]; + }>({ + type: "object", + additionalProperties: false, + required: ["overallScore", "categoryScores", "feedback", "revisions"], + properties: { + overallScore: { type: "number", minimum: 1, maximum: 10 }, + categoryScores: { + type: "object", + additionalProperties: false, + required: [ + "seo", + "readability", + "accuracy", + "structure", + "brandVoice", + ], + properties: { + seo: { type: "number", minimum: 1, maximum: 10 }, + readability: { type: "number", minimum: 1, maximum: 10 }, + accuracy: { type: "number", minimum: 1, maximum: 10 }, + structure: { type: "number", minimum: 1, maximum: 10 }, + brandVoice: { type: "number", minimum: 1, maximum: 10 }, + }, + }, + feedback: { + type: "object", + additionalProperties: false, + required: [ + "seo", + "readability", + "accuracy", + "structure", + "brandVoice", + ], + properties: { + seo: { type: "string" }, + readability: { type: "string" }, + accuracy: { type: "string" }, + structure: { type: "string" }, + brandVoice: { type: "string" }, + }, + }, + revisions: { + type: "array", + items: { type: "string" }, + }, + }, + }), + }), + prompt, + }); + + const targetWordCount = input.targetWordCount; + const articleWordCount = countWords(input.article); + let overallScore = result.output.overallScore; + let passes = overallScore >= 7; + + const feedback = { ...result.output.feedback }; + const revisions = [...result.output.revisions]; + + if (typeof targetWordCount === "number" && targetWordCount > 0) { + const lowerBound = Math.max(200, Math.floor(targetWordCount * 0.85)); + const upperBound = Math.ceil(targetWordCount * 1.15); + const withinRange = + articleWordCount >= lowerBound && articleWordCount <= upperBound; + + if (!withinRange) { + passes = false; + overallScore = Math.min(overallScore, 6); + feedback.structure = `${feedback.structure} Word count mismatch: ${articleWordCount} words (target range: ${lowerBound}-${upperBound} around ${targetWordCount}).`; + revisions.push( + `Adjust article length to ${lowerBound}-${upperBound} words (current: ${articleWordCount}, target: ${targetWordCount}). Remove low-value sections or expand missing depth without filler.`, + ); + } + } + + return { + overallScore, + passes, + feedback, + revisions, + rawOutput: result.text || JSON.stringify(result.output), + }; + } catch (error) { + // If generation or schema validation fails, return a conservative result. + console.error( + "[review] Failed to generate structured review output:", + error, + ); + return { + overallScore: 5, + passes: false, + feedback: { + seo: "Review generation failed — manual review needed.", + readability: "Review generation failed — manual review needed.", + accuracy: "Review generation failed — manual review needed.", + structure: "Review generation failed — manual review needed.", + brandVoice: "Review generation failed — manual review needed.", + }, + revisions: [ + "Review output could not be generated. Please review the article manually.", + ], + rawOutput: "", + }; + } +} diff --git a/packages/api-seo/src/lib/ai/utils/store-optimized-or-original-image.ts b/packages/api-seo/src/lib/ai/utils/store-optimized-or-original-image.ts index 0071e823c..020511c77 100644 --- a/packages/api-seo/src/lib/ai/utils/store-optimized-or-original-image.ts +++ b/packages/api-seo/src/lib/ai/utils/store-optimized-or-original-image.ts @@ -1,17 +1,21 @@ -import { env as cloudflareEnv } from "cloudflare:workers"; import { err, ok, type Result, safe } from "@rectangular-labs/result"; import type { InitialContext } from "../../../types"; -function getImagesBinding(): ImagesBinding { - // biome-ignore lint/suspicious/noExplicitAny: Cloudflare bindings are injected at runtime. - return (cloudflareEnv as any).IMAGES; +async function getImagesBinding(): Promise { + try { + const workersModule = await import("cloudflare:workers"); + // biome-ignore lint/suspicious/noExplicitAny: Cloudflare bindings are injected at runtime. + return (workersModule.env as any)?.IMAGES ?? null; + } catch { + return null; + } } async function tryConvertToWebp( bytes: Uint8Array, ): Promise> { try { - const images = getImagesBinding(); + const images = await getImagesBinding(); if (!images) { return err(new Error("Cloudflare IMAGES binding not configured")); } diff --git a/packages/api-seo/src/lib/ai/utils/wrapped-language-model.ts b/packages/api-seo/src/lib/ai/utils/wrapped-language-model.ts new file mode 100644 index 000000000..33b7ff43d --- /dev/null +++ b/packages/api-seo/src/lib/ai/utils/wrapped-language-model.ts @@ -0,0 +1,28 @@ +import { google } from "@ai-sdk/google"; +import { openai } from "@ai-sdk/openai"; +import { addToolInputExamplesMiddleware, wrapLanguageModel } from "ai"; + +/** + * OpenAI and Gemini do not natively consume tool input examples. + * We inject examples through middleware so tool usage is more reliable. + */ +function withToolInputExamples( + model: Parameters[0]["model"], +): ReturnType { + return wrapLanguageModel({ + model, + middleware: addToolInputExamplesMiddleware(), + }); +} + +export function wrappedOpenAI( + modelId: Parameters[0], +): ReturnType { + return withToolInputExamples(openai(modelId)); +} + +export function wrappedGoogle( + modelId: Parameters[0], +): ReturnType { + return withToolInputExamples(google(modelId)); +} diff --git a/packages/api-seo/src/lib/content/write-content-draft.ts b/packages/api-seo/src/lib/content/write-content-draft.ts index a7d00a34a..afb1e9e99 100644 --- a/packages/api-seo/src/lib/content/write-content-draft.ts +++ b/packages/api-seo/src/lib/content/write-content-draft.ts @@ -142,33 +142,6 @@ export async function writeContentDraft( } let updatedDraft = updatedResult.value; - if (nextStatus === "suggested" && !updatedDraft.outlineGeneratedByTaskRunId) { - const taskResult = await createTask({ - db: args.db, - userId: args.userId ?? undefined, - input: { - type: "seo-plan-keyword", - userId: args.userId ?? undefined, - projectId: args.projectId, - organizationId: args.organizationId, - chatId: args.chatId ?? null, - draftId: draft.id, - }, - }); - if (taskResult.ok) { - const updatedResult = await updateContentDraft(args.db, { - id: updatedDraft.id, - projectId: args.projectId, - organizationId: args.organizationId, - outlineGeneratedByTaskRunId: taskResult.value.id, - }); - if (!updatedResult.ok) { - return updatedResult; - } - updatedDraft = updatedResult.value; - } - } - if (nextStatus === "queued" && !updatedDraft.generatedByTaskRunId) { const taskResult = await createTask({ db: args.db, diff --git a/packages/api-seo/src/lib/workspace/constants.ts b/packages/api-seo/src/lib/workspace/constants.ts index 4c44507a1..0e39e8c32 100644 --- a/packages/api-seo/src/lib/workspace/constants.ts +++ b/packages/api-seo/src/lib/workspace/constants.ts @@ -3,10 +3,7 @@ import type { publishingSettingsSchema, writingSettingsSchema, } from "@rectangular-labs/core/schemas/project-parsers"; -import { - DEFAULT_BRAND_VOICE, - DEFAULT_USER_INSTRUCTIONS, -} from "./workflow.constant"; +import { DEFAULT_BRAND_VOICE } from "./workflow.constant"; export const DRAFT_NOT_FOUND_ERROR_MESSAGE = "Draft not found."; export const SLUG_NOT_AVAILABLE_ERROR_MESSAGE = "Slug is not available."; @@ -14,7 +11,7 @@ export const SLUG_NOT_AVAILABLE_ERROR_MESSAGE = "Slug is not available."; export const DEFAULT_WRITING_SETTINGS: typeof writingSettingsSchema.infer = { version: "v1", brandVoice: DEFAULT_BRAND_VOICE, - customInstructions: DEFAULT_USER_INSTRUCTIONS, + customInstructions: "", }; export const DEFAULT_PUBLISHING_SETTINGS: typeof publishingSettingsSchema.infer = diff --git a/packages/api-seo/src/lib/workspace/workflow.constant.ts b/packages/api-seo/src/lib/workspace/workflow.constant.ts index 1bfca5c6f..d5537dba0 100644 --- a/packages/api-seo/src/lib/workspace/workflow.constant.ts +++ b/packages/api-seo/src/lib/workspace/workflow.constant.ts @@ -4,131 +4,297 @@ export type ArticleType = typeof articleTypeSchema.infer; export const ARTICLE_TYPE_TO_WRITER_RULE: Partial> = { - "best-of-list": `## Best of lists -1. Always include screenshots of ALL the products/websites/service pages/country/food/item that you are listing. The images should come right after the heading introducing the item on the list. -2. If ideal screenshots are not available, note acceptable fallbacks. -3. Word light - Explain why each item is listed succinctly. -4. Maximum of 20 total items. -5. Always include links to each of the listed items/services organically (for e.g. wrapping the link around the anchor text of the item/service being linked to). -6. Give an opinion as to why the top listed product is considered the best and why`, - comparison: `## Comparisons (comparing two or more products or services or items) -1. Always include comparison tables at the top of the article that summarizes the key points -2. Include screenshots or pictures of ALL the items being compared -3. If ideal screenshots are not available, note acceptable fallbacks. -4. Include external links to the product/item being compared to organically (for e.g. wrapping the link around the anchor text of the item/service being linked to). -5. Must clearly take a stance on which product is better for what purposes and why - opinion must be given by the end`, - "how-to": `## How to (guide) -1. Use a calm, authoritative, instructional tone with clear, flowing step-by-step guidance. -2. Avoid rigid checklists; explain each step in short paragraphs with the why and the expected outcome. -3. Include screenshots, diagrams, or pictures for key steps that benefit from visuals. -4. Opinions can be given but should be clearly labelled as opinions`, - listicle: `## Listicle (generically) -1. Include screenshots, diagrams, for EVERY listed item. The images should come right after the heading introducing the item on the list. -2. Maximum of 20 total items. -3. Word light - each listicle item should be a maximum of 50 words. -4. Always include an external link to each item on the list organically (for e.g. wrapping the link around the anchor text of the item/service being linked to). -5. Word light - Explain why each item is listed succinctly - don't over-explain.`, - "long-form-opinion": `## Long form - opinions -1. Signpost well -2. Write in a stream-of-consciousness - raw, and relatable -3. Substantiated by clearly cited evidence and sources -4. Must take a side and an opinion - strong POV -5. Clear opinion should be reached at the end`, - faq: `## Frequently Asked Questions -1. Headings should be the questions. -2. Short, direct answers. -3. Heavy internal linking organically (for e.g. wrapping the link around the anchor text of the other question being referenced). -4. No CTAs or product plugs`, - news: `## News -1. Structure should cover: i) what happened, ii) why it matters, iii) key details, iv) what's next (if applicable). -2. Internal and external links should be used heavily organically (for e.g. wrapping the link around the anchor text of the other sources being cited). -3. Tone should be neutral, informational, and succinct. Descriptive -4. Never include a personal opinion or tone`, - whitepaper: `## Whitepapers -1. Diagrams, charts, and frameworks should be produced into images -2. Long word count -3. Include sections for an executive summary, data analysis, explain methodology, describe the issue -4. Tone is authoritative and research based -5. Heavy emphasis on accurate internal and external linking`, - infographic: `## Infographic -1. Visual output should incorporate all important elements - there should be a clear heading, with visuals which accompany and describe each heading, and corresponding words in bullet point -2. Visual should have a flow and not be overly complicated. -3. Text should be readable -4. No need to use SERP data`, - "case-study": `## Case studies -1. Use internal and external links as sources (do not make anything up) -2. Clearly state how they were aided - how their position improved due to the usage of a certain product or service -3. Internal or external links to the case studies' webpage (if any) organically (for e.g. wrapping the link around the anchor text of the case study being linked to). -4. Narrative tone -5. Emphasize the experience of the person - using direct quotes from them, and describing how they were aided. -6. Identify the person by name and position and state the company in the title -7. No need to use SERP data`, - "press-release": `## Press releases (news about the company) -1. Short word count -2. Direct, matter-of-fact tone, formal and restrained -3. State details -4. Quotes from key personnel -5. No need to use SERP data`, - interview: `## Interviews -1. Picture of the person being interviewed -2. External and internal links to the interviewee and related topics organically (for e.g. wrapping the link around the anchor text of the interviewee's name). -3. Many quotes from the interview -4. Conversational - should be clearly signposted as a back and forth between interviewer and interviewee -5. Introduction setting the scene of the interview - who is being interviewed and what their position and experience is, and when it took place, where. Who was interviewing. -6. Closing insights stating what was learnt from the interview -7. No need to use SERP data`, - "product-update": `## Product update -1. Word light -2. Introduce the product and what it is -3. Clearly list the changes in the product in a listicle, and list all the features -4. Be clear, and benefit focused -5. Explain why this update is exciting and what it intends to fulfill -6. No need to use SERP data`, - "contest-giveaway": `## Contest/giveaway -1. Visual of a giveaway - describe what needs to be done to qualify for the giveaway, how many people will win it, when it will close, how much it's worth and the product needs to be in the picture. Picture should be vibrant and exciting -2. Describe what needs to be done to qualify for the giveaway, how many people will win it, when it will close, how much it's worth -3. Very word light -4. Energetic tone - excited -5. No need to use SERP data`, - "research-summary": `## Research summary -1. Diagrams, charts, and frameworks should be produced into images -2. Describe what was studied, key findings, and implications, if any. -3. Any original opinions have to be clearly labelled as such -4. External link to the original research organically (for e.g. wrapping the link around the anchor text of the mention of the source being cited). -5. Cite clearly and in great detail the original research with 100% accuracy -6. Analytical and neutral tone`, - "event-recap": `## Event recap -1. Use pictures of the event from the outline provided by the user. -2. Descriptive tone, excited -3. Internal link to related articles and products organically (for e.g. wrapping the link around the anchor text of the related article or product being mentioned). -4. External link to partners organically (for e.g. wrapping the link around the anchor text of the partner's name being mentioned). -5. No need to use SERP data`, - "best-practices": `## Best practices -1. Checklist at the end of the article summarizing all the best practices -2. Prescriptive, confident tone -3. State dos and don'ts -4. Give examples`, + "best-of-list": `**Target length:** 800-1,500 words (depending on number of items) +**Intent:** Bottom-of-funnel. The reader wants a curated, opinionated shortlist to make a decision. +**Tone:** Authoritative, opinionated, concise. + +#### Structure +- Open with a brief summary of what was evaluated and the selection criteria (2-3 sentences max). +- List 5-15 items (20 absolute max). Each item gets its own H2 or H4 heading. +- Each item: screenshot/image immediately after the heading, 2-4 sentences explaining why it made the list, and an inline link to the item. +- Include a comparison summary table near the top or bottom with key differentiators at a glance. +- Take a clear stance on the #1 pick and explain why. + +#### Key Rules +- Screenshot or relevant image for EVERY listed item, placed right after the item heading. Use acceptable fallbacks if ideal screenshots are unavailable. +- Word light: explain each item succinctly. The value is curation, not exhaustive description. +- Always link to each listed item organically (wrap the link around the item name as anchor text). +- GEO advantage: "best X for Y" pages are highly cited by LLMs for category queries. Structure items with clear entity names and one-sentence verdicts LLMs can extract.`, + + comparison: `**Target length:** 600-1,200 words +**Intent:** Bottom-of-funnel. The reader is choosing between specific options and wants a decisive recommendation. +**Tone:** Analytical, fair, but decisive. + +#### Structure +- Comparison summary table at the TOP of the article covering key dimensions (pricing, features, pros/cons, best for). +- Screenshot or image of EACH item being compared, placed with the relevant section. +- Dedicated section per item with honest assessment. +- Clear "verdict" section at the end: which is better for what purposes and why. Take a stance. + +#### Key Rules +- Include external links to each compared product/item organically (link the product name). +- If ideal screenshots are unavailable, note acceptable fallbacks. +- Must clearly take a stance on which product is better for what use cases. Opinion must be given by the end. +- Be honest about tradeoffs. One-sided comparisons lose credibility with both readers and LLMs. +- GEO advantage: comparison tables and clear verdict sentences are highly extractable by LLMs.`, + + "how-to": `**Target length:** 800-2,000 words (proportional to complexity) +**Intent:** Middle-to-top-of-funnel. The reader wants to accomplish a specific task. +**Tone:** Calm, authoritative, instructional. Like a knowledgeable colleague walking you through it. + +#### Structure +- Open by stating what the reader will achieve and any prerequisites. +- Steps as H2/H4 headings in logical order. Each step: short paragraph explaining the what, why, and expected outcome. +- Include screenshots, diagrams, or images for key steps that benefit from visuals. +- Close with troubleshooting tips or common mistakes if relevant. + +#### Key Rules +- Avoid rigid numbered checklists. Explain each step in flowing prose with the reasoning. +- Each step should include the expected outcome so the reader can verify they did it right. +- Opinions can be given but should be clearly labeled as opinions. +- GEO advantage: step-by-step content with clear headings is the primary format LLMs cite for "how to" queries. Make each step self-contained and extractable.`, + + listicle: `**Target length:** 600-1,200 words +**Intent:** Varies (informational to commercial). The reader wants a scannable collection. +**Tone:** Concise, informative, value-dense. + +#### Structure +- Brief intro (2-3 sentences) explaining the curation criteria. +- Each item gets its own heading with an image/screenshot immediately after. +- Maximum 20 items. Fewer well-explained items beats more poorly-explained ones. +- Each item: 30-50 words max explaining why it is included. Do not over-explain. + +#### Key Rules +- Image for EVERY listed item, right after the item heading. +- Always include an external link to each item organically (wrap the item name as anchor text). +- Word light. The value is breadth and scannability, not depth. +- If the list exceeds 10 items, include a summary table.`, + + "long-form-opinion": `**Target length:** 1,500-3,000 words +**Intent:** Top-of-funnel / thought leadership. The reader wants a strong, well-argued perspective. +**Tone:** Raw, relatable, stream-of-consciousness but structured. Strong voice. + +#### Structure +- Signpost well with clear section headings that telegraph the argument's progression. +- Build the argument: establish the problem, present the thesis, support with evidence, address counterarguments, reach a clear conclusion. +- Every major claim must be substantiated by cited evidence and sources. + +#### Key Rules +- Must take a side. Strong POV throughout. No fence-sitting. +- Write in a natural, human voice. This format demands personality. +- A clear, summarized opinion must be reached at the end. +- Information gain is critical here: the unique perspective IS the value proposition. +- GEO advantage: LLMs cite strong opinions when users ask "what do experts think about X." Make the thesis quotable.`, + + faq: `**Target length:** 120-200 words per answer, total depends on number of questions +**Intent:** Informational / GEO-optimized. Answers specific questions searchers and LLMs ask. +**Tone:** Direct, authoritative, concise. + +#### Structure +- Each H2 is a question (phrased exactly as a user would ask it). +- Answer immediately below in 1-3 short paragraphs. Lead with the direct answer, then provide context. +- Heavy internal linking: link to related pages where deeper coverage exists. + +#### Key Rules +- Questions must be real user questions discovered from SERP/PAA research, not filler. +- No CTAs or product plugs. This format builds topical authority, not conversions directly. +- Short, direct answers. If an answer needs more than 200 words, it should be a separate article that this FAQ links to. +- GEO advantage: FAQ pages are the single highest-cited format by LLMs. Each answer should be self-contained and extractable as a standalone response.`, + + news: `**Target length:** 300-600 words +**Intent:** Informational / timely. The reader wants to know what happened and why it matters. +**Tone:** Neutral, informational, succinct. Descriptive, not persuasive. + +#### Structure +- Inverted pyramid: most important information first. +- Cover: i) what happened, ii) why it matters, iii) key details, iv) what is next (if applicable). +- Heavy use of internal and external links as sources. + +#### Key Rules +- Never include personal opinion or subjective tone. +- Neutral language throughout. Report, do not editorialize. +- Link to sources organically (wrap anchor text around the source being cited). +- Timeliness is the value. Get to the facts immediately.`, + + whitepaper: `**Target length:** 2,000-4,000 words +**Intent:** Top-of-funnel / thought leadership. The reader wants in-depth, research-backed analysis. +**Tone:** Authoritative, analytical, research-driven. + +#### Structure +- Executive summary (150-300 words) up front. +- Problem definition / issue description. +- Methodology section (how data was gathered/analyzed). +- Data analysis with diagrams, charts, and frameworks as generated images. +- Findings and implications. +- Conclusion with actionable recommendations. + +#### Key Rules +- Diagrams, charts, and frameworks should be produced as images. +- Heavy emphasis on accurate internal and external linking to primary sources. +- Every claim must trace to data or cited research. No unsubstantiated assertions. +- GEO advantage: whitepapers with original data and clear findings are cited by LLMs as authoritative sources. Structure findings as quotable statements.`, + + infographic: `**Target length:** 300-500 words of supporting text +**Intent:** Visual-first content. The reader wants information presented visually. +**Tone:** Clear, scannable, visually organized. + +#### Structure +- Clear heading for the infographic. +- Visuals accompanying each section with bullet-point text. +- Logical visual flow that is not overly complicated. +- Supporting text is minimal and serves the visuals. + +#### Key Rules +- Visual output should incorporate all important elements with a clear hierarchy. +- Text must be readable and concise. +- Flow should be intuitive: top-to-bottom or left-to-right progression. +- No need to use SERP data for this format.`, + + "case-study": `**Target length:** 800-1,500 words +**Intent:** Bottom-of-funnel / social proof. The reader wants evidence that a product/approach works. +**Tone:** Narrative, specific, results-focused. + +#### Structure +- Title includes the person/company name and the key result. +- Introduction: who (name, position, company), what challenge they faced. +- The journey: what solution was applied and how. +- Results: specific, quantified outcomes. Before/after when possible. +- Direct quotes from the subject throughout. +- Closing: key takeaway and what others can learn. + +#### Key Rules +- Identify the person by name, position, and company in the title. +- Emphasize their experience using direct quotes and specific descriptions of how they were aided. +- Link to the case study subject's webpage (if any) organically. +- Use internal and external links as sources. Do not fabricate any details. +- Narrative tone: tell a story, not a feature list. +- No need to use SERP data. +- GEO advantage: case studies with specific numbers and named entities are cited by LLMs as evidence for "does X work" queries.`, + + "press-release": `**Target length:** 300-500 words +**Intent:** Brand awareness / news distribution. Formal announcement. +**Tone:** Direct, matter-of-fact, formal, restrained. + +#### Structure +- Headline states the news clearly. +- Opening paragraph: who, what, when, where, why. +- Supporting details and context. +- Quotes from key personnel. +- Boilerplate about the company at the end. + +#### Key Rules +- Short word count. Every sentence states a fact. +- No editorializing, no superlatives, no marketing language. +- Quotes should add perspective, not repeat what the text already says. +- No need to use SERP data. +- GEO advantage: press releases influence what LLMs associate with a brand entity. Clear entity statements and quotable facts matter.`, + + interview: `**Target length:** 1,000-2,000 words +**Intent:** Thought leadership / expertise showcase. The reader wants insight from a specific person. +**Tone:** Conversational, clearly structured as a dialogue. + +#### Structure +- Introduction setting the scene: who is being interviewed, their position and experience, when and where it took place, who is interviewing. +- Image of the interviewee. +- Q&A format clearly signposted as back-and-forth between interviewer and interviewee. +- Many direct quotes from the interview. +- Closing insights: what was learned from the interview. + +#### Key Rules +- External and internal links to the interviewee and related topics organically (link the interviewee's name). +- The interviewee's voice should dominate. Use their actual words. +- Introduction must establish credibility: position, experience, relevance. +- No need to use SERP data.`, + + "product-update": `**Target length:** 300-600 words +**Intent:** Existing users / prospects. The reader wants to know what changed and why. +**Tone:** Clear, benefit-focused, concise. + +#### Structure +- Brief intro: what the product is (1-2 sentences for context). +- List of changes/new features, each with a clear benefit statement. +- Why this update matters / what problem it solves. +- Brief closing with next steps or CTA. + +#### Key Rules +- Word light. Features as bullet points or short sections. +- Lead with benefits, not technical details. +- Explain the "why" behind each change, not just the "what". +- No need to use SERP data.`, + + "contest-giveaway": `**Target length:** 200-400 words +**Intent:** Engagement / lead generation. The reader wants to know how to participate. +**Tone:** Energetic, excited, clear. + +#### Structure +- Eye-catching image of the prize/giveaway with vibrant, exciting visuals. +- What the prize is and its value. +- How to qualify (clear, numbered steps). +- How many winners, when it closes. +- Terms and conditions summary. + +#### Key Rules +- Very word light. Clarity over prose. +- The product/prize must be in the image. +- All participation details must be unambiguous: steps, deadline, number of winners, value. +- No need to use SERP data.`, + + "research-summary": `**Target length:** 800-1,500 words +**Intent:** Informational / authority building. The reader wants key findings from specific research. +**Tone:** Analytical, neutral, precise. + +#### Structure +- What was studied and why it matters. +- Methodology overview (brief). +- Key findings with diagrams, charts, and frameworks as generated images. +- Implications and what this means for the reader. +- Link to original research. + +#### Key Rules +- Cite the original research with 100% accuracy. No paraphrasing that changes meaning. +- Diagrams, charts, and frameworks should be produced as images. +- Any original opinions must be clearly labeled as such, separate from the research findings. +- External link to the original research organically (link the source name/title). +- GEO advantage: research summaries with specific findings and named studies are high-authority LLM citation sources.`, + + "event-recap": `**Target length:** 500-1,000 words +**Intent:** Community / brand building. The reader wants to know what happened at the event. +**Tone:** Descriptive, enthusiastic, community-oriented. + +#### Structure +- Scene-setting intro: what event, when, where, who attended. +- Key moments and highlights with images from the event (from outline). +- Quotes or takeaways from speakers/attendees. +- Closing: what was learned, what is next. + +#### Key Rules +- Use pictures of the event from the outline provided by the user. +- Internal links to related articles and products organically. +- External links to partners organically (link the partner's name). +- No need to use SERP data.`, + + "best-practices": `**Target length:** 1,000-1,800 words +**Intent:** Middle-of-funnel. The reader wants prescriptive, actionable guidance. +**Tone:** Prescriptive, confident, expert. + +#### Structure +- Brief intro establishing why these practices matter. +- Each practice as its own section with explanation, dos and don'ts, and concrete examples. +- Summary checklist at the end compiling all best practices. + +#### Key Rules +- Prescriptive and confident. State what to do and what not to do clearly. +- Every practice must include a concrete example or scenario. +- Checklist at the end summarizing all practices in scannable format. +- GEO advantage: "best practices for X" queries are common LLM prompts. Each practice should be extractable as a standalone recommendation.`, }; -export const DEFAULT_BRAND_VOICE = `Use a mix of short sentences, longer ones, and direct sentences. Do not just use one style of sentences throughout the article -- maximize for human readability - sentences should read easily (ie. not have too complicated sentence structure or stoppages), and paragraphs and sections should flow well into each other, having segues, and clear ways in which they relate to each other if its not readily apparent from the subject matter itself -- do not have paragraphs that are too long, and make sure there are suitable breaks after a few paragraphs -- do not use em dashes -- do not use bold words, outside of headings and the start of bullet points -- do not use chiasmus in the form of "it's not X, it's Y" -- do not use emojis -- use one consistent voice throughout; do not blend incompatible styles -- expand abbreviations on first use`; - -export const DEFAULT_USER_INSTRUCTIONS = `Plugging the company's product -a) NEVER FORCE MENTIONS -- Only mention the company's product where it genuinely solves a problem -- If it doesn't fit naturally, skip it -b) ONLY WHERE CONTEXTUALLY HELPFUL -- Must provide real value to the reader -- Should address a specific pain point or opportunity -c) Target: 2-3 Mentions Per Article -- Not every section needs a mention -- Quality over quantity -- Must feel organic, never promotional -d) It is permissible to name and evaluate competitors well, but always place an emphasis on the company's offering, and conclude that it is the best option for the reader. Do this organically.`; +export const DEFAULT_BRAND_VOICE = `Write with clear, authoritative language optimized for SEO and GEO. +- Lead with direct answers in the opening paragraph before adding context. +- Prioritize information gain: include concrete insights, examples, or angles that generic SERP summaries miss. +- Keep paragraphs short (2-4 sentences) with smooth transitions and no filler. +- Use specific entities (products, standards, methods, companies) and precise wording over vague claims. +- Be decisive and honest about tradeoffs; avoid hedging and avoid hype language. +- Vary sentence length for readability, but keep structure simple and scannable. +- Avoid em dashes, emojis, and unnecessary bolding outside headings or bullet leads. +- Expand abbreviations on first use and keep terminology consistent throughout.`; diff --git a/packages/api-seo/src/routes/admin.ts b/packages/api-seo/src/routes/admin.ts index 5e6e12253..507cfa1f4 100644 --- a/packages/api-seo/src/routes/admin.ts +++ b/packages/api-seo/src/routes/admin.ts @@ -1,10 +1,21 @@ +import { waitUntil } from "cloudflare:workers"; import { ORPCError } from "@orpc/server"; import { eq, schema } from "@rectangular-labs/db"; import { updateSeoProject } from "@rectangular-labs/db/operations"; import { type } from "arktype"; import { protectedBase } from "../context"; +import { contentFixtures } from "../eval/content/fixtures/index"; +import { scoreContent } from "../eval/content/score"; +import { strategyFixtures } from "../eval/strategy/fixtures/index"; +import { scoreStrategy } from "../eval/strategy/score"; +import type { ContentFixture, StrategyFixture } from "../eval/types"; +import { createStrategyAdvisorAgent } from "../lib/ai/agents/strategy-advisor"; +import { createWriterAgent } from "../lib/ai/agents/writer"; import { createTask } from "../lib/task"; +const EVAL_GENERATION_TTL_SECONDS = 60 * 60 * 24; // 1 day +const EVAL_GENERATION_KEY_PREFIX = "eval:generation:"; + const triggerOnboardingTask = protectedBase .route({ method: "POST", path: "/trigger-onboarding-task" }) .input(type({ organizationSlug: "string", projectSlug: "string" })) @@ -233,8 +244,567 @@ const triggerStrategyPhaseGenerationTask = protectedBase }; }); +function buildEvalProjectFromContentFixture( + fixture: ContentFixture, +): typeof schema.seoProject.$inferSelect { + const now = new Date(); + return { + id: `eval-content-${fixture.id}`, + slug: fixture.id, + name: fixture.input.project.name, + organizationId: "eval-org", + websiteUrl: fixture.input.project.websiteUrl, + businessBackground: { + version: "v1", + businessOverview: fixture.input.project.businessBackground, + targetAudience: "Website readers and prospective buyers", + caseStudies: [], + competitorsWebsites: [], + industry: "Software", + targetCountryCode: "US", + targetCity: "San Francisco", + languageCode: "en", + }, + imageSettings: { + version: "v1", + styleReferences: [], + brandLogos: [], + imageInstructions: "", + stockImageProviders: ["unsplash", "pexels", "pixabay"], + }, + writingSettings: { + version: "v1", + brandVoice: fixture.input.project.brandVoice, + customInstructions: fixture.input.project.customInstructions, + }, + publishingSettings: { + version: "v1", + requireContentReview: false, + requireSuggestionReview: false, + participateInLinkExchange: true, + }, + projectResearchWorkflowId: null, + strategySuggestionsWorkflowId: null, + createdAt: now, + updatedAt: now, + deletedAt: null, + }; +} + +function buildEvalProjectFromStrategyFixture( + fixture: StrategyFixture, +): typeof schema.seoProject.$inferSelect { + const now = new Date(); + return { + id: `eval-strategy-${fixture.id}`, + slug: fixture.id, + name: fixture.input.site.name, + organizationId: "eval-org", + websiteUrl: fixture.input.site.websiteUrl, + businessBackground: { + version: "v1", + businessOverview: fixture.input.site.businessBackground, + targetAudience: "Potential customers", + caseStudies: [], + competitorsWebsites: [], + industry: fixture.input.site.industry, + targetCountryCode: "US", + targetCity: "San Francisco", + languageCode: "en", + }, + imageSettings: { + version: "v1", + styleReferences: [], + brandLogos: [], + imageInstructions: "", + stockImageProviders: ["unsplash", "pexels", "pixabay"], + }, + writingSettings: { + version: "v1", + brandVoice: "Clear, practical, and data-backed.", + customInstructions: "", + }, + publishingSettings: { + version: "v1", + requireContentReview: false, + requireSuggestionReview: false, + participateInLinkExchange: true, + }, + projectResearchWorkflowId: null, + strategySuggestionsWorkflowId: null, + createdAt: now, + updatedAt: now, + deletedAt: null, + }; +} + +function assertFluidpostsEmail(email: string | null | undefined): void { + if (!email?.endsWith("@fluidposts.com")) { + throw new ORPCError("FORBIDDEN", { message: "Not authorized" }); + } +} + +function toJsonSafe(value: unknown): unknown { + if (typeof value === "bigint") { + return value.toString(); + } + if (value instanceof Error) { + return { + name: value.name, + message: value.message, + stack: value.stack, + }; + } + if (Array.isArray(value)) { + return value.map((item) => toJsonSafe(item)); + } + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [key, toJsonSafe(item)]), + ); + } + return value; +} + +function getEvalGenerationKey(jobId: string): string { + return `${EVAL_GENERATION_KEY_PREFIX}${jobId}`; +} + +interface EvalGenerationStatusValue { + jobId: string; + status: "pending" | "completed" | "failed"; + type: "content" | "strategy"; + fixtureId: string; + output: string | null; + generatedAt: string | null; + outputFileName: string | null; + stepsJson: string | null; + stepsFileName: string | null; + durationMs: number | null; + error: string | null; +} + +async function putEvalGenerationStatus( + cacheKV: KVNamespace, + value: EvalGenerationStatusValue, +): Promise { + await cacheKV.put(getEvalGenerationKey(value.jobId), JSON.stringify(value), { + expirationTtl: EVAL_GENERATION_TTL_SECONDS, + }); +} + +async function runEvalGenerationJob(args: { + cacheKV: KVNamespace; + db: Parameters[0]["db"]; + publicImagesBucket: Parameters< + typeof createWriterAgent + >[0]["publicImagesBucket"]; + type: "content" | "strategy"; + fixture: ContentFixture | StrategyFixture; + jobId: string; +}): Promise { + try { + const start = Date.now(); + const createdAt = new Date(); + + if (args.type === "content") { + const fixture = args.fixture as ContentFixture; + const { agent } = createWriterAgent({ + db: args.db, + project: buildEvalProjectFromContentFixture(fixture), + messages: [], + cacheKV: args.cacheKV, + publicImagesBucket: args.publicImagesBucket, + mode: "chat", + articleType: fixture.input.articleType, + primaryKeyword: fixture.input.primaryKeyword, + }); + + const task = `Write a complete markdown article. + +Title: ${fixture.input.title} +Primary keyword: ${fixture.input.primaryKeyword} +Article type: ${fixture.input.articleType} +Expected length: ${fixture.expectations.minWordCount}-${fixture.expectations.maxWordCount} words +${fixture.input.notes ? `Notes: ${fixture.input.notes}` : ""} +${fixture.input.outline ? `Outline: ${fixture.input.outline}` : ""}`; + + const draft = await agent.generate({ + prompt: task, + }); + + await putEvalGenerationStatus(args.cacheKV, { + jobId: args.jobId, + status: "completed", + type: "content", + fixtureId: fixture.id, + output: draft.text, + generatedAt: createdAt.toISOString(), + outputFileName: `${fixture.id}-${createdAt.toISOString().replace(/[:.]/g, "-")}.md`, + stepsJson: JSON.stringify( + { + fixtureId: fixture.id, + type: "content", + timestamp: createdAt.toISOString(), + steps: toJsonSafe(draft.steps ?? []), + }, + null, + 2, + ), + stepsFileName: `${fixture.id}-${createdAt.toISOString().replace(/[:.]/g, "-")}-steps.json`, + durationMs: Date.now() - start, + error: null, + }); + return; + } + + const fixture = args.fixture as StrategyFixture; + const { agent } = createStrategyAdvisorAgent({ + db: args.db, + project: buildEvalProjectFromStrategyFixture(fixture), + cacheKV: args.cacheKV, + gscProperty: null, + }); + + const result = await agent.generate({ + prompt: `Generate exactly 2 SEO strategy suggestions for this project. + +${fixture.input.instructions} + +Output requirements: +- Provide exactly 2 distinct strategy options. +- For each option include rationale, keyword clusters, intent mapping, prioritization plan, and realistic timeline expectations. +- Keep recommendations specific and execution-ready. +- Return plain markdown text only (no JSON).`, + }); + + await putEvalGenerationStatus(args.cacheKV, { + jobId: args.jobId, + status: "completed", + type: "strategy", + fixtureId: fixture.id, + output: result.text, + generatedAt: createdAt.toISOString(), + outputFileName: `${fixture.id}-${createdAt.toISOString().replace(/[:.]/g, "-")}.md`, + stepsJson: JSON.stringify( + { + fixtureId: fixture.id, + type: "strategy", + timestamp: createdAt.toISOString(), + steps: toJsonSafe(result.steps ?? []), + }, + null, + 2, + ), + stepsFileName: `${fixture.id}-${createdAt.toISOString().replace(/[:.]/g, "-")}-steps.json`, + durationMs: Date.now() - start, + error: null, + }); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown error"; + await putEvalGenerationStatus(args.cacheKV, { + jobId: args.jobId, + status: "failed", + type: args.type, + fixtureId: args.fixture.id, + output: null, + generatedAt: null, + outputFileName: null, + stepsJson: null, + stepsFileName: null, + durationMs: null, + error: message, + }); + } +} + +const listEvalFixtures = protectedBase + .route({ method: "GET", path: "/eval/fixtures" }) + .output( + type({ + content: type({ id: "string", description: "string" }).array(), + strategy: type({ id: "string", description: "string" }).array(), + }), + ) + .handler(({ context }) => { + assertFluidpostsEmail(context.user.email); + + return { + content: contentFixtures.map((fixture) => ({ + id: fixture.id, + description: fixture.description, + })), + strategy: strategyFixtures.map((fixture) => ({ + id: fixture.id, + description: fixture.description, + })), + }; + }); + +const generateEvalOutput = protectedBase + .route({ method: "POST", path: "/eval/generate" }) + .input( + type({ + type: "'content'|'strategy'", + fixtureId: "string", + }), + ) + .output( + type({ + jobId: "string", + status: "'pending'", + type: "'content'|'strategy'", + fixtureId: "string", + }), + ) + .handler(async ({ context, input }) => { + assertFluidpostsEmail(context.user.email); + + if (input.type === "content") { + const fixture = contentFixtures.find( + (item) => item.id === input.fixtureId, + ); + if (!fixture) { + throw new ORPCError("NOT_FOUND", { message: "Fixture not found" }); + } + + const jobId = crypto.randomUUID(); + await putEvalGenerationStatus(context.cacheKV, { + jobId, + status: "pending", + type: "content", + fixtureId: fixture.id, + output: null, + generatedAt: null, + outputFileName: null, + stepsJson: null, + stepsFileName: null, + durationMs: null, + error: null, + }); + waitUntil( + runEvalGenerationJob({ + cacheKV: context.cacheKV, + db: context.db, + publicImagesBucket: context.publicImagesBucket, + type: "content", + fixture, + jobId, + }), + ); + + return { + jobId, + status: "pending", + type: "content", + fixtureId: fixture.id, + }; + } + + const fixture = strategyFixtures.find( + (item) => item.id === input.fixtureId, + ); + if (!fixture) { + throw new ORPCError("NOT_FOUND", { message: "Fixture not found" }); + } + const jobId = crypto.randomUUID(); + await putEvalGenerationStatus(context.cacheKV, { + jobId, + status: "pending", + type: "strategy", + fixtureId: fixture.id, + output: null, + generatedAt: null, + outputFileName: null, + stepsJson: null, + stepsFileName: null, + durationMs: null, + error: null, + }); + waitUntil( + runEvalGenerationJob({ + cacheKV: context.cacheKV, + db: context.db, + publicImagesBucket: context.publicImagesBucket, + type: "strategy", + fixture, + jobId, + }), + ); + + return { + jobId, + status: "pending", + type: "strategy", + fixtureId: fixture.id, + }; + }); + +const getEvalGenerationStatus = protectedBase + .route({ method: "GET", path: "/eval/generate/status" }) + .input( + type({ + jobId: "string", + }), + ) + .output( + type({ + jobId: "string", + status: "'pending'|'completed'|'failed'|'not_found'", + type: "'content'|'strategy'|null", + fixtureId: "string|null", + output: "string|null", + generatedAt: "string|null", + outputFileName: "string|null", + stepsJson: "string|null", + stepsFileName: "string|null", + durationMs: "number|null", + error: "string|null", + }), + ) + .handler(async ({ context, input }) => { + assertFluidpostsEmail(context.user.email); + + const payload = await context.cacheKV.get( + getEvalGenerationKey(input.jobId), + "json", + ); + console.log("payload", payload); + + if (!payload) { + return { + jobId: input.jobId, + status: "not_found", + type: null, + fixtureId: null, + output: null, + generatedAt: null, + outputFileName: null, + stepsJson: null, + stepsFileName: null, + durationMs: null, + error: null, + }; + } + + return payload; + }); + +const listEvalFixtureScores = protectedBase + .route({ method: "GET", path: "/eval/scores" }) + .input( + type({ + type: "'content'|'strategy'", + fixtureId: "string", + }), + ) + .output( + type({ + scores: type({ + timestamp: "string", + overallScore: "number", + durationMs: "number", + filePath: "string", + }).array(), + }), + ) + .handler(({ context, input }) => { + assertFluidpostsEmail(context.user.email); + if (!input.fixtureId.trim()) { + return { scores: [] }; + } + + return { scores: [] }; + }); + +const scoreEvalOutput = protectedBase + .route({ method: "POST", path: "/eval/score" }) + .input( + type({ + type: "'content'|'strategy'", + fixtureId: "string", + output: "string", + durationMs: "number", + }), + ) + .output( + type({ + fixtureId: "string", + overallScore: "number", + dimensions: type({ + name: "string", + score: "number", + weight: "number", + feedback: "string", + }).array(), + pairwiseVsReference: type({ + winner: "'current'|'reference'|'tie'", + reasoning: "string", + }).or("null"), + scoreTimestamp: "string", + scoreFileName: "string", + scoreJson: "string", + }), + ) + .handler(async ({ context, input }) => { + assertFluidpostsEmail(context.user.email); + + if (input.type === "content") { + const fixture = contentFixtures.find( + (item) => item.id === input.fixtureId, + ); + if (!fixture) { + throw new ORPCError("NOT_FOUND", { message: "Fixture not found" }); + } + + const result = await scoreContent({ + fixture, + output: input.output, + durationMs: input.durationMs, + }); + + return { + fixtureId: result.fixtureId, + overallScore: result.overallScore, + dimensions: result.dimensions, + pairwiseVsReference: result.pairwiseVsReference, + scoreTimestamp: result.timestamp, + scoreFileName: `${result.fixtureId}-${new Date(result.timestamp).toISOString().replace(/[:.]/g, "-")}-score.json`, + scoreJson: JSON.stringify(result, null, 2), + }; + } + + const fixture = strategyFixtures.find( + (item) => item.id === input.fixtureId, + ); + if (!fixture) { + throw new ORPCError("NOT_FOUND", { message: "Fixture not found" }); + } + + const result = await scoreStrategy({ + fixture, + output: input.output, + durationMs: input.durationMs, + }); + + return { + fixtureId: result.fixtureId, + overallScore: result.overallScore, + dimensions: result.dimensions, + pairwiseVsReference: result.pairwiseVsReference, + scoreTimestamp: result.timestamp, + scoreFileName: `${result.fixtureId}-${new Date(result.timestamp).toISOString().replace(/[:.]/g, "-")}-score.json`, + scoreJson: JSON.stringify(result, null, 2), + }; + }); + export default protectedBase.prefix("/admin").router({ triggerOnboardingTask, triggerStrategySuggestionsTask, triggerStrategyPhaseGenerationTask, + listEvalFixtures, + generateEvalOutput, + getEvalGenerationStatus, + listEvalFixtureScores, + scoreEvalOutput, }); diff --git a/packages/api-seo/src/routes/chat.sendMessage.ts b/packages/api-seo/src/routes/chat.sendMessage.ts index c0e62fa04..6c5f63621 100644 --- a/packages/api-seo/src/routes/chat.sendMessage.ts +++ b/packages/api-seo/src/routes/chat.sendMessage.ts @@ -11,11 +11,11 @@ import { getChatById, updateChat, } from "@rectangular-labs/db/operations"; -import { hasToolCall, streamText } from "ai"; +import { convertToModelMessages } from "ai"; import { type as arktype } from "arktype"; import { withOrganizationIdBase } from "../context"; -import { createStrategistAgent } from "../lib/ai/strategist-agent"; -import { createWriterAgent } from "../lib/ai/writer-agent"; +import { createOrchestrator } from "../lib/ai/agents/orchestrator"; +import { summarizeAgentStep } from "../lib/ai/utils/agent-telemetry"; import { handleTitleGeneration } from "../lib/chat/handle-title-generation"; import { getGscIntegrationForProject } from "../lib/database/gsc-integration"; import { getProjectInChat } from "../lib/database/project"; @@ -202,48 +202,26 @@ export const sendMessage = withOrganizationIdBase } } - const agent = await (async () => { - if (input.currentPage === "article-editor") { - const writerAgent = await createWriterAgent({ - project, - context, - messages: input.messages, - }); - return writerAgent; - } - const strategistAgent = await createStrategistAgent({ - project, - context, - messages: input.messages, - currentPage: input.currentPage, - gscProperty: gscIntegration ?? undefined, - }); - return strategistAgent; - })(); + const orchestrator = createOrchestrator({ + db: context.db, + project, + messages: input.messages, + cacheKV: context.cacheKV, + publicImagesBucket: context.publicImagesBucket, + gscProperty: gscIntegration ?? null, + }); - const result = streamText({ - ...agent, - onError: (error) => { - console.error("[chat.sendMessage] streamText onError", { - error, - }); - }, + const modelMessages = await convertToModelMessages(input.messages); + const result = await orchestrator.stream({ + messages: modelMessages, onStepFinish: (step) => { - console.log("[chat.sendMessage] step finished", { - text: step?.text, - toolResults: step?.toolResults, - toolCallCount: Array.isArray(step?.toolCalls) - ? step.toolCalls.length - : 0, - }); + console.log( + "[chat.sendMessage] orchestrator step finished", + summarizeAgentStep(step), + ); }, - stopWhen: [ - hasToolCall("ask_questions"), - hasToolCall("create_plan"), - hasToolCall("manage_integrations"), - ], }); - console.log("[chat.sendMessage] streamText result created"); + console.log("[chat.sendMessage] orchestrator stream created"); waitUntil( Promise.resolve( result.consumeStream({ diff --git a/packages/api-seo/src/workflows/onboarding-workflow.ts b/packages/api-seo/src/workflows/onboarding-workflow.ts index 35ebb474a..5bc36963d 100644 --- a/packages/api-seo/src/workflows/onboarding-workflow.ts +++ b/packages/api-seo/src/workflows/onboarding-workflow.ts @@ -9,15 +9,18 @@ import { openai } from "@ai-sdk/openai"; import { ONBOARDING_STRATEGY_SUGGESTION_INSTRUCTIONS } from "@rectangular-labs/core/ai/onboarding-strategy-suggestion-instructions"; import { toSlug } from "@rectangular-labs/core/format/to-slug"; import { - type seoUnderstandSiteTaskInputSchema, + businessBackgroundJsonSchema, + type businessBackgroundSchema, +} from "@rectangular-labs/core/schemas/project-parsers"; +import type { + seoUnderstandSiteTaskInputSchema, seoUnderstandSiteTaskOutputSchema, } from "@rectangular-labs/core/schemas/task-parsers"; import { createDb } from "@rectangular-labs/db"; import { updateSeoProject } from "@rectangular-labs/db/operations"; -import { generateText, Output, stepCountIs } from "ai"; +import { generateText, jsonSchema, Output, stepCountIs } from "ai"; import { type } from "arktype"; -import { arktypeToAiJsonSchema } from "../lib/ai/arktype-json-schema"; -import { createWebToolsWithMetadata } from "../lib/ai/tools/web-tools"; +import { createWebTools } from "../lib/ai/tools/web-tools"; import { logAgentStep } from "../lib/ai/utils/log-agent-step"; import { createTask } from "../lib/task"; import { DEFAULT_BRAND_VOICE } from "../lib/workspace/workflow.constant"; @@ -105,11 +108,9 @@ Homepage Title: ${homepageTitle} Extract the name from the above context.`, output: Output.object({ - schema: arktypeToAiJsonSchema( - type({ - name: "string", - }), - ), + schema: type({ + name: "string", + }), }), }); @@ -122,7 +123,7 @@ Extract the name from the above context.`, timeout: "30 minutes", }, async () => { - const { tools } = createWebToolsWithMetadata(project, this.env.CACHE); + const { tools } = createWebTools(project, this.env.CACHE); const system = `You are an SEO research expert extracting concise, high-signal business context. ## Task @@ -152,9 +153,9 @@ Extract the name from the above context.`, logAgentStep(logInfo, "[backgroundResearch] step finished", step); }, output: Output.object({ - schema: arktypeToAiJsonSchema( - seoUnderstandSiteTaskOutputSchema.get("businessBackground"), - ), + schema: jsonSchema< + Omit + >(businessBackgroundJsonSchema), }), }); @@ -231,7 +232,7 @@ Extract the name from the above context.`, } }), step.do("generate brand voice", { timeout: "10 minutes" }, async () => { - const { tools } = createWebToolsWithMetadata(project, this.env.CACHE); + const { tools } = createWebTools(project, this.env.CACHE); const system = `You are an SEO research expert extracting a brand's writing tone. ## Task @@ -263,11 +264,9 @@ Extract the name from the above context.`, logAgentStep(logInfo, "step to extract brand voice finished", step); }, output: Output.object({ - schema: arktypeToAiJsonSchema( - type({ - brandVoice: "string", - }), - ), + schema: type({ + brandVoice: "string", + }), }), }); diff --git a/packages/api-seo/src/workflows/strategy-phase-generation-workflow.ts b/packages/api-seo/src/workflows/strategy-phase-generation-workflow.ts index d798667aa..4d5655323 100644 --- a/packages/api-seo/src/workflows/strategy-phase-generation-workflow.ts +++ b/packages/api-seo/src/workflows/strategy-phase-generation-workflow.ts @@ -5,10 +5,8 @@ import { } from "cloudflare:workers"; import { NonRetryableError } from "cloudflare:workflows"; import { randomUUID } from "node:crypto"; -import { openai } from "@ai-sdk/openai"; -import { initAuthHandler } from "@rectangular-labs/auth"; import { formatStrategyGoal } from "@rectangular-labs/core/format/strategy-goal"; -import { strategyPhaseSuggestionScheme } from "@rectangular-labs/core/schemas/strategy-parsers"; +import type { strategyPhaseSuggestionScheme } from "@rectangular-labs/core/schemas/strategy-parsers"; import type { seoGenerateStrategyPhaseTaskInputSchema, seoGenerateStrategyPhaseTaskOutputSchema, @@ -25,15 +23,9 @@ import { listUnassignedContentDrafts, updateContentDraft, } from "@rectangular-labs/db/operations"; -import { generateText, Output, stepCountIs } from "ai"; -import { apiEnv } from "../env"; -import { arktypeToAiJsonSchema } from "../lib/ai/arktype-json-schema"; -import { createDataforseoToolWithMetadata } from "../lib/ai/tools/dataforseo-tool"; -import { createGscToolWithMetadata } from "../lib/ai/tools/google-search-console-tool"; -import { createStrategyToolsWithMetadata } from "../lib/ai/tools/strategy-tools"; -import { createWebToolsWithMetadata } from "../lib/ai/tools/web-tools"; -import { formatBusinessBackground } from "../lib/ai/utils/format-business-background"; -import { logAgentStep } from "../lib/ai/utils/log-agent-step"; +import { createStrategyAdvisorAgent } from "../lib/ai/agents/strategy-advisor"; +import { summarizeAgentInvocation } from "../lib/ai/utils/agent-telemetry"; +import { createWorkflowAuth } from "../lib/ai/utils/auth-init"; import { getGscIntegrationForProject } from "../lib/database/gsc-integration"; import { createSeoWriteArticleTasksBatch, createTask } from "../lib/task"; import type { InitialContext } from "../types"; @@ -48,6 +40,7 @@ function logError(message: string, data?: Record) { type StrategyPhaseGenerationInput = typeof seoGenerateStrategyPhaseTaskInputSchema.infer; + export type SeoStrategyPhaseGenerationWorkflowBinding = Workflow; @@ -67,7 +60,10 @@ type DraftTarget = Pick< }; function formatDraftTargets(drafts: DraftTarget[]) { - if (drafts.length === 0) return "- none"; + if (drafts.length === 0) { + return "- none"; + } + return drafts .map((draft) => { const title = draft.title ? `"${draft.title}"` : "(untitled)"; @@ -84,7 +80,9 @@ function formatDraftTargets(drafts: DraftTarget[]) { } function formatStrategyPhaseHistory(phases: StrategyDetails["phases"]) { - if (phases.length === 0) return "- none"; + if (phases.length === 0) { + return "- none"; + } return phases .map((phase, index) => { @@ -112,48 +110,6 @@ function formatStrategyPhaseHistory(phases: StrategyDetails["phases"]) { .join("\n"); } -async function getGscIntegrationOrThrow(args: { - db: ReturnType; - projectId: string; - organizationId: string; - context: string; -}) { - const env = apiEnv(); - const auth = initAuthHandler({ - baseURL: env.SEO_URL, - db: args.db, - encryptionKey: env.AUTH_SEO_ENCRYPTION_KEY, - fromEmail: env.AUTH_SEO_FROM_EMAIL, - inboundApiKey: env.SEO_INBOUND_API_KEY, - credentialVerificationType: env.AUTH_SEO_CREDENTIAL_VERIFICATION_TYPE, - discordClientId: env.AUTH_SEO_DISCORD_ID, - discordClientSecret: env.AUTH_SEO_DISCORD_SECRET, - githubClientId: env.AUTH_SEO_GITHUB_ID, - githubClientSecret: env.AUTH_SEO_GITHUB_SECRET, - googleClientId: env.AUTH_SEO_GOOGLE_CLIENT_ID, - googleClientSecret: env.AUTH_SEO_GOOGLE_CLIENT_SECRET, - }); - - const gscIntegrationResult = await getGscIntegrationForProject({ - db: args.db, - projectId: args.projectId, - organizationId: args.organizationId, - authOverride: auth, - }); - - if (!gscIntegrationResult.ok) { - logError("failed to load GSC integration", { - context: args.context, - projectId: args.projectId, - organizationId: args.organizationId, - error: gscIntegrationResult.error, - }); - throw gscIntegrationResult.error; - } - - return gscIntegrationResult.value; -} - export class SeoStrategyPhaseGenerationWorkflow extends WorkflowEntrypoint< { CACHE: InitialContext["cacheKV"]; @@ -178,7 +134,9 @@ export class SeoStrategyPhaseGenerationWorkflow extends WorkflowEntrypoint< const db = createDb(); const projectResult = await getSeoProjectById(db, input.projectId); - if (!projectResult.ok) throw projectResult.error; + if (!projectResult.ok) { + throw projectResult.error; + } if (!projectResult.value) { throw new NonRetryableError(`Missing project ${input.projectId}`); } @@ -189,7 +147,9 @@ export class SeoStrategyPhaseGenerationWorkflow extends WorkflowEntrypoint< strategyId: input.strategyId, organizationId: input.organizationId, }); - if (!strategyResult.ok) throw strategyResult.error; + if (!strategyResult.ok) { + throw strategyResult.error; + } if (!strategyResult.value) { throw new NonRetryableError(`Missing strategy ${input.strategyId}`); } @@ -200,12 +160,15 @@ export class SeoStrategyPhaseGenerationWorkflow extends WorkflowEntrypoint< const candidateDrafts = await step.do("load candidate drafts", async () => { const db = createDb(); + const unassignedResult = await listUnassignedContentDrafts({ db, organizationId: input.organizationId, projectId: input.projectId, }); - if (!unassignedResult.ok) throw unassignedResult.error; + if (!unassignedResult.ok) { + throw unassignedResult.error; + } const combined = new Map(); @@ -215,9 +178,13 @@ export class SeoStrategyPhaseGenerationWorkflow extends WorkflowEntrypoint< source: "unassigned", }); } + for (const phase of strategy.phases) { for (const content of phase.phaseContents) { - if (!content.contentDraft) continue; + if (!content.contentDraft) { + continue; + } + const { contentDraft: draft } = content; combined.set(draft.id, { id: draft.id, @@ -237,98 +204,181 @@ export class SeoStrategyPhaseGenerationWorkflow extends WorkflowEntrypoint< "generate phase suggestion", { timeout: "10 minutes" }, async () => { - const { tools: webTools } = createWebToolsWithMetadata( - project, - this.env.CACHE, - ); - const dataforseoTools = createDataforseoToolWithMetadata( - project, - this.env.CACHE, - ); - const db = createDb(); - const strategyTools = createStrategyToolsWithMetadata({ - db, - projectId: project.id, - organizationId: project.organizationId, - }); - const gscIntegration = await getGscIntegrationOrThrow({ + const gscIntegrationResult = await getGscIntegrationForProject({ db, projectId: project.id, organizationId: project.organizationId, - context: "generate phase suggestion", + authOverride: createWorkflowAuth(db), }); + if (!gscIntegrationResult.ok) { + logError("failed to load GSC integration", { + projectId: project.id, + organizationId: project.organizationId, + error: gscIntegrationResult.error, + }); + throw gscIntegrationResult.error; + } - const gscTools = createGscToolWithMetadata({ - accessToken: gscIntegration?.accessToken ?? null, - siteUrl: gscIntegration?.config?.domain ?? null, - siteType: gscIntegration?.config?.propertyType ?? null, + const { agent } = createStrategyAdvisorAgent< + typeof strategyPhaseSuggestionScheme.infer + >({ + db, + project, + cacheKV: this.env.CACHE, + jsonSchema: { + type: "object", + additionalProperties: false, + required: ["phase", "contentUpdates", "contentCreations"], + properties: { + phase: { + type: "object", + additionalProperties: false, + required: [ + "type", + "name", + "observationWeeks", + "successCriteria", + "cadence", + ], + properties: { + type: { + type: "string", + enum: ["build", "optimize", "expand"], + }, + name: { type: "string" }, + observationWeeks: { type: "number" }, + successCriteria: { type: "string" }, + cadence: { + type: "object", + additionalProperties: false, + required: ["period", "frequency", "allowedDays"], + properties: { + period: { + type: "string", + enum: ["daily", "weekly", "monthly"], + }, + frequency: { type: "number" }, + allowedDays: { + type: "array", + items: { + type: "string", + enum: [ + "mon", + "tue", + "wed", + "thu", + "fri", + "sat", + "sun", + ], + }, + }, + }, + }, + }, + }, + contentUpdates: { + type: "array", + items: { + type: "object", + additionalProperties: false, + required: [ + "action", + "contentDraftId", + "updatedRole", + "updatedTitle", + "updatedDescription", + "updatedPrimaryKeyword", + "updatedNotes", + ], + properties: { + action: { + type: "string", + enum: ["improve", "expand"], + }, + contentDraftId: { type: "string" }, + updatedRole: { + type: ["string", "null"], + enum: ["pillar", "supporting", null], + }, + updatedTitle: { type: ["string", "null"] }, + updatedDescription: { type: ["string", "null"] }, + updatedPrimaryKeyword: { type: ["string", "null"] }, + updatedNotes: { type: ["string", "null"] }, + }, + }, + }, + contentCreations: { + type: "array", + items: { + type: "object", + additionalProperties: false, + required: [ + "action", + "role", + "plannedSlug", + "plannedPrimaryKeyword", + "notes", + ], + properties: { + action: { + type: "string", + enum: ["create"], + }, + role: { + type: "string", + enum: ["pillar", "supporting"], + }, + plannedSlug: { type: "string" }, + plannedPrimaryKeyword: { type: "string" }, + notes: { type: ["string", "null"] }, + }, + }, + }, + }, + }, + gscProperty: gscIntegrationResult.value + ? { + config: gscIntegrationResult.value.config, + accessToken: gscIntegrationResult.value.accessToken, + } + : null, }); - const system = `You are an SEO strategist generating the NEXT execution phase for an approved strategy. + const result = await agent.generate({ + prompt: `Generate the NEXT execution phase for this strategy. -## Strategy + Name: ${strategy.name} Motivation: ${strategy.motivation} Description: ${strategy.description ?? "(none)"} Goal: ${formatStrategyGoal(strategy.goal)} + -## Historical Context (oldest to newest) + ${formatStrategyPhaseHistory(strategy.phases)} + -## Rules -- Use tools before deciding what should be improved, expanded, or created. -- Ground decisions in project data (GSC, keyword data, web research, strategy details). -- Use Google Search Console to verify publication and performance before proposing improvements: - - Query GSC with dimensions ['page', 'query'] when possible. - - Use includingRegex filters on page to match draft slugs (for example slug 'best-crm-tools' -> regex '.*/best-crm-tools/?$'). - - If a slug has no matching page/query rows over a useful date range, treat it as likely not published or not yet indexed and avoid recommending optimize/expand based on missing evidence. - - For published pages, inspect clicks, impressions, queries, and position trends before recommending improve/expand actions. -- The phase should be clear, concise, and focused with a clear hypothesis. -- Use contentUpdates for improving/expanding existing drafts when it is warranted. -- Use contentCreations for all net-new content to create in this phase. -- Output JSON matching the provided schema exactly. - -## Candidate existing drafts for updates -${formatDraftTargets(candidateDrafts)}`; - - const outputResult = await generateText({ - model: openai("gpt-5.2"), - system, - tools: { - ...webTools, - ...dataforseoTools.tools, - ...strategyTools.tools, - ...(gscIntegration ? gscTools.tools : {}), - }, - prompt: `Project website: ${project.websiteUrl} -Business background: -${formatBusinessBackground(project.businessBackground)} - -Generate the next strategy phase now.`, - stopWhen: [stepCountIs(40)], - onStepFinish: (agentStep) => { - logAgentStep( - logInfo, - "phase generation tool step", - agentStep, - event.instanceId, - ); - }, - output: Output.object({ - schema: arktypeToAiJsonSchema(strategyPhaseSuggestionScheme), - }), - }).catch((error) => { - console.error("error generating phase suggestion", error); - throw error; + +${formatDraftTargets(candidateDrafts)} +`, + }); + const telemetry = summarizeAgentInvocation(result.steps); + logInfo("strategy advisor invocation complete", { + instanceId: event.instanceId, + projectId: project.id, + strategyId: strategy.id, + ...telemetry, }); - return outputResult.output; + return result.output; }, ); const now = new Date(); + const phaseResult = await step.do("create phase + contents", async () => { const db = createDb(); const phaseStatus = @@ -338,7 +388,7 @@ Generate the next strategy phase now.`, cadence: suggestion.phase.cadence, contentCreationsCount: suggestion.contentCreations.length, contentUpdatesCount: suggestion.contentUpdates.length, - now: now, + now, }); const phaseInsert = await createStrategyPhase(db, { @@ -352,7 +402,9 @@ Generate the next strategy phase now.`, startedAt: phaseStatus === "planned" ? now : null, targetCompletionDate, }); - if (!phaseInsert.ok) throw phaseInsert.error; + if (!phaseInsert.ok) { + throw phaseInsert.error; + } const phase = phaseInsert.value; const allPhaseDraftIds = new Set(); @@ -366,7 +418,7 @@ Generate the next strategy phase now.`, const knownDraft = candidateById.get(contentUpdate.contentDraftId); if (!knownDraft) { logError( - "content update draft was not found in candidate set, skipping", + "content update draft not found in candidate set, skipping", { draftId: contentUpdate.contentDraftId, }, @@ -385,14 +437,19 @@ Generate the next strategy phase now.`, role: contentUpdate.updatedRole, notes: contentUpdate.updatedNotes ?? null, }); - if (!updatedDraft.ok) throw updatedDraft.error; + if (!updatedDraft.ok) { + throw updatedDraft.error; + } const phaseContentResult = await createStrategyPhaseContent(db, { phaseId: phase.id, contentDraftId: updatedDraft.value.id, action: contentUpdate.action, }); - if (!phaseContentResult.ok) throw phaseContentResult.error; + if (!phaseContentResult.ok) { + throw phaseContentResult.error; + } + if (contentUpdate.updatedNotes) { draftIdsToUpdate.push(updatedDraft.value.id); } @@ -410,13 +467,18 @@ Generate the next strategy phase now.`, role: contentCreation.role, notes: contentCreation.notes ?? null, }); - if (!draftInsert.ok) throw draftInsert.error; + if (!draftInsert.ok) { + throw draftInsert.error; + } + const phaseContentResult = await createStrategyPhaseContent(db, { phaseId: phase.id, contentDraftId: draftInsert.value.id, action: contentCreation.action, }); - if (!phaseContentResult.ok) throw phaseContentResult.error; + if (!phaseContentResult.ok) { + throw phaseContentResult.error; + } createdDraftIds.push(draftInsert.value.id); allPhaseDraftIds.add(draftInsert.value.id); diff --git a/packages/api-seo/src/workflows/strategy-suggestions-workflow.ts b/packages/api-seo/src/workflows/strategy-suggestions-workflow.ts index 90817b296..4da9c77d0 100644 --- a/packages/api-seo/src/workflows/strategy-suggestions-workflow.ts +++ b/packages/api-seo/src/workflows/strategy-suggestions-workflow.ts @@ -4,10 +4,8 @@ import { type WorkflowStep, } from "cloudflare:workers"; import { NonRetryableError } from "cloudflare:workflows"; -import { openai } from "@ai-sdk/openai"; -import { initAuthHandler } from "@rectangular-labs/auth"; import { formatStrategyGoal } from "@rectangular-labs/core/format/strategy-goal"; -import { strategySuggestionSchema } from "@rectangular-labs/core/schemas/strategy-parsers"; +import type { strategySuggestionSchema } from "@rectangular-labs/core/schemas/strategy-parsers"; import type { seoStrategySuggestionsTaskInputSchema, seoStrategySuggestionsTaskOutputSchema, @@ -18,16 +16,9 @@ import { getSeoProjectById, listStrategiesByProjectId, } from "@rectangular-labs/db/operations"; -import { generateText, Output, stepCountIs } from "ai"; -import { type } from "arktype"; -import { apiEnv } from "../env"; -import { arktypeToAiJsonSchema } from "../lib/ai/arktype-json-schema"; -import { createDataforseoToolWithMetadata } from "../lib/ai/tools/dataforseo-tool"; -import { createGscToolWithMetadata } from "../lib/ai/tools/google-search-console-tool"; -import { createStrategyToolsWithMetadata } from "../lib/ai/tools/strategy-tools"; -import { createWebToolsWithMetadata } from "../lib/ai/tools/web-tools"; -import { formatBusinessBackground } from "../lib/ai/utils/format-business-background"; -import { logAgentStep } from "../lib/ai/utils/log-agent-step"; +import { createStrategyAdvisorAgent } from "../lib/ai/agents/strategy-advisor"; +import { summarizeAgentInvocation } from "../lib/ai/utils/agent-telemetry"; +import { createWorkflowAuth } from "../lib/ai/utils/auth-init"; import { getGscIntegrationForProject } from "../lib/database/gsc-integration"; import type { InitialContext } from "../types"; @@ -39,6 +30,7 @@ type StrategySuggestionsInput = typeof seoStrategySuggestionsTaskInputSchema.infer; export type SeoStrategySuggestionsWorkflowBinding = Workflow; + export class SeoStrategySuggestionsWorkflow extends WorkflowEntrypoint< { CACHE: InitialContext["cacheKV"]; @@ -60,9 +52,6 @@ export class SeoStrategySuggestionsWorkflow extends WorkflowEntrypoint< const db = createDb(); const projectResult = await getSeoProjectById(db, input.projectId); if (!projectResult.ok) { - console.error( - `[SeoStrategySuggestionsWorkflow] ${projectResult.error}`, - ); throw projectResult.error; } if (!projectResult.value) { @@ -75,166 +64,125 @@ export class SeoStrategySuggestionsWorkflow extends WorkflowEntrypoint< const suggestionResult = await step.do( "generate strategy suggestions", - { - timeout: "10 minutes", - }, + { timeout: "10 minutes" }, async () => { - try { - logInfo("creating strategy suggestion tools", { - instanceId: event.instanceId, - projectId: input.projectId, - }); - const { tools: webTools } = createWebToolsWithMetadata( - project, - this.env.CACHE, - ); - const dataforseoTools = createDataforseoToolWithMetadata( - project, - this.env.CACHE, - ); - const db = createDb(); - const strategyTools = createStrategyToolsWithMetadata({ - db, - projectId: project.id, - organizationId: project.organizationId, - }); - const env = apiEnv(); - const auth = initAuthHandler({ - baseURL: env.SEO_URL, - db, - encryptionKey: env.AUTH_SEO_ENCRYPTION_KEY, - fromEmail: env.AUTH_SEO_FROM_EMAIL, - inboundApiKey: env.SEO_INBOUND_API_KEY, - credentialVerificationType: - env.AUTH_SEO_CREDENTIAL_VERIFICATION_TYPE, - discordClientId: env.AUTH_SEO_DISCORD_ID, - discordClientSecret: env.AUTH_SEO_DISCORD_SECRET, - githubClientId: env.AUTH_SEO_GITHUB_ID, - githubClientSecret: env.AUTH_SEO_GITHUB_SECRET, - googleClientId: env.AUTH_SEO_GOOGLE_CLIENT_ID, - googleClientSecret: env.AUTH_SEO_GOOGLE_CLIENT_SECRET, - }); - const gscIntegrationResult = await getGscIntegrationForProject({ - db, - projectId: project.id, - organizationId: project.organizationId, - authOverride: auth, - }); - if (!gscIntegrationResult.ok) { - throw new Error( - `Something went wrong getting gsc integration ${gscIntegrationResult.error}`, - { - cause: gscIntegrationResult.error, - }, - ); - } - - const gscTools = createGscToolWithMetadata({ - accessToken: gscIntegrationResult.value?.accessToken ?? null, - siteUrl: gscIntegrationResult.value?.config?.domain ?? null, - siteType: gscIntegrationResult.value?.config?.propertyType ?? null, - }); - - const existingStrategiesResult = await listStrategiesByProjectId({ - db, - projectId: project.id, - organizationId: project.organizationId, - }); - if (!existingStrategiesResult.ok) { - throw existingStrategiesResult.error; - } - - const existingStrategies = - existingStrategiesResult.value.length > 0 - ? existingStrategiesResult.value - .map((strategy) => { - const goal = strategy.goal - ? formatStrategyGoal(strategy.goal) - : "none"; - const updatedAt = strategy.updatedAt - ?.toISOString?.() - ?.slice(0, 10); - const dismissalReason = strategy.dismissalReason - ? `dismissal reason: ${strategy.dismissalReason}` - : ""; - return [ - `- [${strategy.status}] "${strategy.name}"`, - `id: ${strategy.id}`, - `goal: ${goal}`, - `phases:${strategy.phases?.length ?? 0}`, - `updated:${updatedAt ?? "unknown"}`, - dismissalReason, - ] - .filter(Boolean) - .join("|"); - }) - .join("\n") - : "- none"; + const db = createDb(); - const system = `You are an SEO strategist generating strategy suggestions. + const gscIntegrationResult = await getGscIntegrationForProject({ + db, + projectId: project.id, + organizationId: project.organizationId, + authOverride: createWorkflowAuth(db), + }); + if (!gscIntegrationResult.ok) { + throw gscIntegrationResult.error; + } -## Objective -- Propose strategy suggestions that fit the project's context and current work. -- Avoid duplicates by name and intent. Learn from dismissed strategies and their reasons. + const existingStrategiesResult = await listStrategiesByProjectId({ + db, + projectId: project.id, + organizationId: project.organizationId, + }); + if (!existingStrategiesResult.ok) { + throw existingStrategiesResult.error; + } -## Instructions + const existingStrategies = + existingStrategiesResult.value.length > 0 + ? existingStrategiesResult.value + .map((strategy) => { + const goal = strategy.goal + ? formatStrategyGoal(strategy.goal) + : "none"; + const updatedAt = strategy.updatedAt + ?.toISOString?.() + ?.slice(0, 10); + const dismissalReason = strategy.dismissalReason + ? `dismissal reason: ${strategy.dismissalReason}` + : ""; + return [ + `- [${strategy.status}] "${strategy.name}"`, + `id: ${strategy.id}`, + `goal: ${goal}`, + `phases:${strategy.phases?.length ?? 0}`, + `updated:${updatedAt ?? "unknown"}`, + dismissalReason, + ] + .filter(Boolean) + .join("|"); + }) + .join("\n") + : "- none"; + + const { agent } = createStrategyAdvisorAgent<{ + suggestions: (typeof strategySuggestionSchema.infer)[]; + }>({ + db, + project, + cacheKV: this.env.CACHE, + jsonSchema: { + type: "object", + additionalProperties: false, + required: ["suggestions"], + properties: { + suggestions: { + type: "array", + items: { + type: "object", + additionalProperties: false, + required: ["name", "motivation", "description", "goal"], + properties: { + name: { type: "string" }, + motivation: { type: "string" }, + description: { type: ["string", "null"] }, + goal: { + type: "object", + additionalProperties: false, + required: ["metric", "target", "timeframe"], + properties: { + metric: { + type: "string", + enum: ["clicks", "impressions", "avgPosition"], + }, + target: { type: "number" }, + timeframe: { + type: "string", + enum: ["monthly", "total"], + }, + }, + }, + }, + }, + }, + }, + }, + gscProperty: gscIntegrationResult.value + ? { + config: gscIntegrationResult.value.config, + accessToken: gscIntegrationResult.value.accessToken, + } + : null, + }); + + const result = await agent.generate({ + prompt: `Generate strategy suggestions for this project. + + ${input.instructions} + -## Data Usage -- Use available tools (Google Search Console, keyword research data source tools, web search) directly to ground recommendations. -- If you need more context about a specific strategy, use get_strategy_details. -- If Google Search Console is not available, rely on competitor and keyword tools plus public site info. - -## Existing Strategies (compact) + ${existingStrategies} - -## Output Requirements -- Keep each strategy concise and actionable. -- Use realistic targets for goals and success criteria. -- Strategies should be content-creation plays (new articles, pillar pages, content clusters), not technical SEO or on-page optimization tasks, unless the instructions explicitly ask for it. -- Output MUST match the provided JSON schema.`; - logInfo("Starting strategy suggestion generation", { - instanceId: event.instanceId, - projectId: input.projectId, - }); - const outputResult = await generateText({ - model: openai("gpt-5.2"), - system, - tools: { - ...webTools, - ...dataforseoTools.tools, - ...strategyTools.tools, - ...(gscIntegrationResult.value ? gscTools.tools : {}), - }, - prompt: `Project website: ${project.websiteUrl} -Business background:${formatBusinessBackground(project.businessBackground)} - -Generate strategy suggestions now.`, - stopWhen: [stepCountIs(40)], - onStepFinish: (step) => { - logAgentStep( - logInfo, - "step to generate suggestion finished", - step, - ); - }, - output: Output.object({ - schema: arktypeToAiJsonSchema( - type({ - suggestions: strategySuggestionSchema.array(), - }), - ), - }), - }); - - return outputResult.output; - } catch (e) { - console.error( - "[SeoStrategySuggestionsWorkflow] Error generating strategy suggestions", - e, - ); - throw e; - } +`, + }); + const telemetry = summarizeAgentInvocation(result.steps); + logInfo("strategy advisor invocation complete", { + instanceId: event.instanceId, + projectId: project.id, + ...telemetry, + }); + + return result.output; }, ); @@ -244,14 +192,12 @@ Generate strategy suggestions now.`, const db = createDb(); const strategyResult = await createStrategies( db, - suggestionResult.suggestions.map((suggestion) => { - return { - ...suggestion, - organizationId: project.organizationId, - projectId: project.id, - status: "suggestion", - }; - }), + suggestionResult.suggestions.map((suggestion) => ({ + ...suggestion, + organizationId: project.organizationId, + projectId: project.id, + status: "suggestion", + })), ); if (!strategyResult.ok) { throw strategyResult.error; diff --git a/packages/api-seo/src/workflows/writer-workflow.ts b/packages/api-seo/src/workflows/writer-workflow.ts index dbca0a52a..7a37874d7 100644 --- a/packages/api-seo/src/workflows/writer-workflow.ts +++ b/packages/api-seo/src/workflows/writer-workflow.ts @@ -4,9 +4,8 @@ import { type WorkflowStep, } from "cloudflare:workers"; import { NonRetryableError } from "cloudflare:workflows"; -import { type GoogleGenerativeAIProviderOptions, google } from "@ai-sdk/google"; import { type OpenAIResponsesProviderOptions, openai } from "@ai-sdk/openai"; -import { articleTypeSchema } from "@rectangular-labs/core/schemas/content-parsers"; +import { ARTICLE_TYPES } from "@rectangular-labs/core/schemas/content-parsers"; import type { seoWriteArticleTaskInputSchema, seoWriteArticleTaskOutputSchema, @@ -15,48 +14,25 @@ import { createDb, type schema } from "@rectangular-labs/db"; import { getDraftById, getSeoProjectByIdentifierAndOrgId, + getStrategyDetails, } from "@rectangular-labs/db/operations"; -import { generateText, Output, stepCountIs } from "ai"; -import { type } from "arktype"; -import { arktypeToAiJsonSchema } from "../lib/ai/arktype-json-schema"; -import { createImageToolsWithMetadata } from "../lib/ai/tools/image-tools"; +import { generateText, jsonSchema, Output } from "ai"; +import type { type } from "arktype"; import { - createTodoToolWithMetadata, - formatTodoFocusReminder, -} from "../lib/ai/tools/todo-tool"; -import { createWebToolsWithMetadata } from "../lib/ai/tools/web-tools"; -import { buildWriterSystemPrompt } from "../lib/ai/writer-agent"; + createWriterPipeline, + type StrategyContext, +} from "../lib/ai/agents/writer"; +import { summarizeAgentInvocation } from "../lib/ai/utils/agent-telemetry"; import { createPublicImagesBucket } from "../lib/bucket"; -import { - analyzeArticleMarkdownForReview, - repairPublicBucketImageLinks, -} from "../lib/content/review-utils"; +import { repairPublicBucketImageLinks } from "../lib/content/review-utils"; import { writeContentDraft } from "../lib/content/write-content-draft"; -import { configureDataForSeoClient } from "../lib/dataforseo/utils"; -import { createTask } from "../lib/task"; -import { - ARTICLE_TYPE_TO_WRITER_RULE, - type ArticleType, -} from "../lib/workspace/workflow.constant"; +import type { ArticleType } from "../lib/workspace/workflow.constant"; import type { InitialContext } from "../types"; function logInfo(message: string, data?: Record) { console.info(`[SeoWriterWorkflow] ${message}`, data ?? {}); } -function logError(message: string, data?: Record) { - console.error(`[SeoWriterWorkflow] ${message}`, data ?? {}); -} - -const reviewArticleOutputSchema = type({ - approved: type("boolean").describe("Whether the article is approved."), - changes: type("string[]").describe("Changes to be made to the article."), -}); - -const inferArticleTypeSchema = type({ - articleType: articleTypeSchema, -}).describe("Chosen article type for the content"); - async function inferArticleType(args: { title?: string | null; primaryKeyword?: string | null; @@ -65,11 +41,6 @@ async function inferArticleType(args: { }): Promise { const { title, primaryKeyword, notes, outline } = args; if (!title && !primaryKeyword && !outline && !notes) { - logInfo("inferred article type defaulted", { - articleType: "other", - title, - primaryKeyword, - }); return "other"; } @@ -147,88 +118,43 @@ async function inferArticleType(args: { } return null; })(); + if (heuristicMatch) { - logInfo("inferred article type via heuristic", { - articleType: heuristicMatch, - title, - primaryKeyword, - }); return heuristicMatch; } - const prompt = `Choose the single best article type for the intended content. - -Return ONLY JSON matching: { "articleType": string } - -"articleType" must be one of: -- "best-of-list" -- "comparison" -- "how-to" -- "listicle" -- "long-form-opinion" -- "faq" -- "news" -- "whitepaper" -- "infographic" -- "case-study" -- "press-release" -- "interview" -- "product-update" -- "contest-giveaway" -- "research-summary" -- "event-recap" -- "best-practices" -- "other" - -Decision rules: -- If notes clearly specify a format (e.g. "press release", "interview", "case study"), prioritize notes over outline. -- If the outline structure signals a format (steps, Q&A headings, comparisons, rankings), match it. -- Use "best-of-list" only for explicit "best/top" ranking intent; otherwise use "listicle" for general lists. -- Pricing, cost, or rate-focused titles usually map to "comparison". -- Use your reasoning and judgement to discern between the other article types. use "other" if none of the article types apply. - - -${titleText ?? ""} - - - -${keywordText ?? ""} - - - -${notesText ?? ""} - - - -${outlineText ?? ""} -`; - - try { - const result = await generateText({ - model: google("gemini-3-flash-preview"), - output: Output.object({ - schema: arktypeToAiJsonSchema(inferArticleTypeSchema), + const result = await generateText({ + model: openai("gpt-5.2"), + providerOptions: { + openai: { + reasoningEffort: "low", + } satisfies OpenAIResponsesProviderOptions, + }, + output: Output.object({ + schema: jsonSchema<{ + articleType: ArticleType; + }>({ + type: "object", + additionalProperties: false, + required: ["articleType"], + properties: { + articleType: { + type: "string", + enum: [...ARTICLE_TYPES], + description: "Chosen article type for the content", + }, + }, }), - prompt, - }); - const inferred = result.output.articleType; - logInfo("inferred article type via model", { - articleType: inferred, - title, - primaryKeyword, - }); - return inferred; - } catch (error) { - logError("failed to infer article type; defaulting to other", { - error, - }); - logInfo("inferred article type defaulted", { - articleType: "other", - title, - primaryKeyword, - }); - return "other"; - } + }), + prompt: `Choose the best article type for this content and return JSON only. + +${titleText} +${keywordText} +${notesText} +${outlineText}`, + }); + + return result.output.articleType; } async function loadDraftAndProject(args: { @@ -271,16 +197,15 @@ async function loadDraftAndProject(args: { if (!projectResult.value) { throw new NonRetryableError(`Project (${args.projectId}) not found`); } - const project = projectResult.value; - return { draft, project }; + return { draft, project: projectResult.value }; } type WriterInput = type.infer; export type SeoWriterWorkflowBinding = Workflow; + export class SeoWriterWorkflow extends WorkflowEntrypoint< { - SEO_PLANNER_WORKFLOW: InitialContext["seoPlannerWorkflow"]; CACHE: InitialContext["cacheKV"]; }, WriterInput @@ -296,80 +221,103 @@ export class SeoWriterWorkflow extends WorkflowEntrypoint< chatId: input.chatId, userId: input.userId, }); - configureDataForSeoClient(); - const isOutlinePresent = await step.do( - "Ensure outline is present", + const { draft, project, articleType, strategyContext } = await step.do( + "load generation context", async () => { const db = createDb(); - const { draft } = await loadDraftAndProject({ + const { draft, project } = await loadDraftAndProject({ db, organizationId: input.organizationId, projectId: input.projectId, draftId: input.draftId, }); - const outline = draft.outline; - if (outline) { - return true; - } - const result = await createTask({ - db, - input: { - type: "seo-plan-keyword", + + const resolvedArticleType = + draft.articleType ?? + (await inferArticleType({ + title: draft.title, + primaryKeyword: draft.primaryKeyword, + notes: draft.notes, + outline: draft.outline, + })); + + let strategyContext: StrategyContext | undefined; + + if (draft.strategyId) { + const strategyResult = await getStrategyDetails({ + db, projectId: input.projectId, + strategyId: draft.strategyId, organizationId: input.organizationId, - chatId: input.chatId, - draftId: input.draftId, - callbackInstanceId: event.instanceId, - userId: input.userId, - }, - workflowInstanceId: `child-${event.instanceId}_${crypto.randomUUID().slice(0, 5)}`, - userId: input.userId, - }); - if (!result.ok) { - logError("failed to create planner task", { - instanceId: event.instanceId, - draftId: input.draftId, - error: result.error, }); - throw result.error; + + if (strategyResult.ok && strategyResult.value) { + const strategy = strategyResult.value; + + // Find the phase that contains this draft to determine phase type + const currentPhase = strategy.phases.find((phase) => + phase.phaseContents.some((pc) => pc.contentDraftId === draft.id), + ); + + // Collect sibling content from all phases + const siblingContent = strategy.phases.flatMap((phase) => + phase.phaseContents.flatMap((pc) => { + if (pc.contentDraftId === draft.id || pc.contentDraft == null) { + return []; + } + const d = pc.contentDraft; + return [ + { + title: d.title, + slug: d.slug, + role: d.role as "pillar" | "supporting" | null, + primaryKeyword: d.primaryKeyword, + status: d.status, + }, + ]; + }), + ); + + // Deduplicate siblings by draft id (a draft can appear in multiple phases) + const seen = new Set(); + const uniqueSiblings = siblingContent.filter((s) => { + const key = s.slug; + if (seen.has(key)) return false; + seen.add(key); + return true; + }); + + strategyContext = { + name: strategy.name, + motivation: strategy.motivation, + description: strategy.description, + goal: strategy.goal as { + metric: string; + target: number; + timeframe: string; + }, + phaseType: + (currentPhase?.type as + | "build" + | "optimize" + | "expand" + | null) ?? null, + contentRole: + (draft.role as "pillar" | "supporting" | null) ?? null, + siblingContent: uniqueSiblings, + }; + } } - return false; + + return { + draft, + project, + articleType: resolvedArticleType, + strategyContext, + }; }, ); - logInfo("outline check", { - instanceId: event.instanceId, - draftId: input.draftId, - isOutlinePresent, - }); - if (!isOutlinePresent) { - logInfo("waiting for planner callback", { - instanceId: event.instanceId, - draftId: input.draftId, - }); - const plannerEvent = await step.waitForEvent<{ draftId: string }>( - "wait for planner callback", - { - type: "planner_complete", - timeout: "1 hour", - }, - ); - logInfo("received planner callback", { - instanceId: event.instanceId, - expectedDraftId: input.draftId, - receivedDraftId: plannerEvent.payload.draftId, - }); - if (plannerEvent.payload.draftId !== input.draftId) { - logError("planner callback draftId mismatch", { - instanceId: event.instanceId, - expectedDraftId: input.draftId, - receivedDraftId: plannerEvent.payload.draftId, - }); - throw new Error( - `Planner callback draftId mismatch: expected ${input.draftId}, got ${plannerEvent.payload.draftId}`, - ); - } - } await step.do("mark writing", async () => { const db = createDb(); @@ -384,350 +332,200 @@ export class SeoWriterWorkflow extends WorkflowEntrypoint< status: "writing", }, }); - if (!writeResult.ok) throw new Error(writeResult.error.message); - }); - logInfo("status set to writing", { - instanceId: event.instanceId, - draftId: input.draftId, + if (!writeResult.ok) { + throw writeResult.error; + } }); - try { - const { - text: articleMarkdown, - articleType, - heroImage, - heroImageCaption, - } = await step.do( - "generate article markdown", - { - timeout: "30 minutes", - }, - async () => { - const db = createDb(); - const { project, draft } = await loadDraftAndProject({ - db, - organizationId: input.organizationId, - projectId: input.projectId, - draftId: input.draftId, - }); - - const webTools = createWebToolsWithMetadata(project, this.env.CACHE); - - const imageTools = createImageToolsWithMetadata({ - organizationId: input.organizationId, - projectId: input.projectId, - imageSettings: project.imageSettings ?? null, - publicImagesBucket: createPublicImagesBucket(), - }); - const todoTool = createTodoToolWithMetadata({ messages: [] }); - logInfo("writer tools ready", { - instanceId: event.instanceId, - draftId: input.draftId, - webToolCount: Object.keys(webTools.tools).length, - imageToolCount: Object.keys(imageTools.tools).length, - }); - - const outline = draft.outline; - const primaryKeyword = draft.primaryKeyword; - const notes = "(none)"; - const articleType = - draft.articleType ?? - (await inferArticleType({ - title: draft.title, - primaryKeyword, - notes, - outline, - })); - logInfo("article type resolved", { - instanceId: event.instanceId, - draftId: input.draftId, - articleType, - source: draft.articleType ? "draft" : "inferred", - }); - const systemPrompt = buildWriterSystemPrompt({ - project, - skillsSection: "", - mode: "workflow", - articleType, - primaryKeyword, - outline: outline ?? undefined, - }); + const taskPrompt = `Write the full article for this draft. + +Context: +- Draft ID: ${draft.id} +- Title: ${draft.title ?? "(none)"} +- Primary keyword: ${draft.primaryKeyword ?? "(missing)"} +- Notes: ${draft.notes ?? "(none)"} +- Outline: +${draft.outline ?? "(missing)"}`; + + const createPipeline = () => + createWriterPipeline({ + db: createDb(), + project, + messages: [], + cacheKV: this.env.CACHE, + publicImagesBucket: createPublicImagesBucket(), + mode: "workflow", + articleType, + primaryKeyword: draft.primaryKeyword ?? undefined, + strategyContext, + }); - logInfo("starting article generation", { - instanceId: event.instanceId, - draftId: input.draftId, - }); - let approved = false; - let changes: string[] = []; - let attempts = 0; - let text = ""; - let heroImage = ""; - let heroImageCaption: string | null = null; - while (!approved && attempts < 3) { - const result = await generateText({ - model: openai("gpt-5.2"), - providerOptions: { - openai: { - reasoningEffort: "medium", - } satisfies OpenAIResponsesProviderOptions, - google: { - thinkingConfig: { - includeThoughts: true, - thinkingLevel: "medium", - }, - } satisfies GoogleGenerativeAIProviderOptions, - }, - tools: { - ...webTools.tools, - ...imageTools.tools, - ...todoTool.tools, - }, - system: systemPrompt, - messages: [ - { - role: "user", - content: changes.length - ? `The article has been written and reviewed. Please refer to the original article and apply the changes to the article and return the updated article. - -${text} - - - -${changes.join("\n")} -` - : "Write the full article now.", - }, - ], - output: Output.object({ - schema: arktypeToAiJsonSchema( - type({ - heroImage: "string", - heroImageCaption: "string|null", - markdown: "string", - }), - ), - }), - onStepFinish: (step) => { - logInfo(`[generateArticle] Step completed:`, { - text: step.text, - toolResults: JSON.stringify(step.toolResults, null, 2), - usage: step.usage, - }); - }, - prepareStep: ({ messages }) => { - return { - messages: [ - ...messages, - { - role: "assistant", - content: formatTodoFocusReminder({ - todos: todoTool.getSnapshot(), - maxOpen: 5, - }), - }, - ], - }; - }, - stopWhen: [stepCountIs(40)], - }); + const research = await step.do( + "writer phase 1 research", + { timeout: "3 minutes" }, + async () => { + const pipeline = createPipeline(); + const phase = await pipeline.runResearchPhase({ task: taskPrompt }); + logInfo("writer phase completed", { + instanceId: event.instanceId, + draftId: input.draftId, + phase: "research", + ...summarizeAgentInvocation(phase.steps), + }); + return phase.output; + }, + ); - const outputMarkdown = result.output.markdown.trim(); - const repairedLinks = repairPublicBucketImageLinks({ - markdown: outputMarkdown, - orgId: input.organizationId, - projectId: input.projectId, - kind: "content-image", - }); - text = repairedLinks.markdown; - heroImage = result.output.heroImage.trim(); - heroImageCaption = result.output.heroImageCaption?.trim() || null; - if (!text) throw new Error("Empty article returned by model"); - logInfo("article generated. Going through review process.", { - instanceId: event.instanceId, - draftId: input.draftId, - articleChars: text.length, - heroImagePresent: !!heroImage, - usage: result.usage ?? null, - replacedCount: repairedLinks.replacedCount, - }); + const plan = await step.do( + "writer phase 2 planning", + { timeout: "2 minutes" }, + async () => { + const pipeline = createPipeline(); + const phase = await pipeline.runPlanningPhase({ + task: taskPrompt, + research, + }); + logInfo("writer phase completed", { + instanceId: event.instanceId, + draftId: input.draftId, + phase: "planning", + ...summarizeAgentInvocation(phase.steps), + }); + return phase.output; + }, + ); - const articleTypeRule = articleType - ? ARTICLE_TYPE_TO_WRITER_RULE[articleType] - : undefined; + const rawDraft = await step.do( + "writer phase 3 writing", + { timeout: "5 minutes" }, + async () => { + const pipeline = createPipeline(); + const phase = await pipeline.runWritingPhase({ + task: taskPrompt, + research, + plan, + }); + logInfo("writer phase completed", { + instanceId: event.instanceId, + draftId: input.draftId, + phase: "writing", + ...summarizeAgentInvocation(phase.steps), + }); + return phase.output; + }, + ); - const analysis = analyzeArticleMarkdownForReview({ - markdown: text, - websiteUrl: project.websiteUrl, - outline, - }); - const utcDate = new Intl.DateTimeFormat("en-US", { - timeZone: "UTC", - year: "numeric", - month: "long", - day: "numeric", - }).format(new Date()); - const { output: reviewResult } = await generateText({ - model: google("gemini-3-flash-preview"), - providerOptions: { - google: { - thinkingConfig: { - includeThoughts: true, - thinkingLevel: "medium", - }, - } satisfies GoogleGenerativeAIProviderOptions, - }, - tools: { - ...webTools.tools, - }, - system: ` -You are a strict SEO content QA reviewer. Your job is to verify the writer followed ALL explicit rules and that the article is publish-ready. - - - -- Only set approved=true when ALL requirements are satisfied. -- If ANY measurable requirement fails (counts, missing sections, forbidden characters), approved MUST be false. -- In changes, focus on concrete edits: what to add/remove/move, and exactly where (section names/headings). - - - -- Outline coverage: every outline heading (H2+) must exist in the article and be meaningfully covered. -- Internal links: at least 3 internal links (relative URLs and URLs whose host matches the website host count as internal). -- External links: at least 2 external links whose host is NOT the website host. -- Images: - - At least 1 image inside an H2 section (an H2 section that contains an image). - - All images must have non-empty, descriptive alt text. -- Formatting: - - No em dashes (—) anywhere. - - If bullet points are used, each bullet must start with a bold heading and a colon (e.g. "- **Heading**: ..."). -- Article-type rule (if present) must be enforced. - - - -- Use web_search and web_fetch to verify external links are accurate, relevant, and resolve to the intended content. -- If a link is broken, non-authoritative, or mismatched to the claim, propose a replacement URL and specify where to swap it in. - - - -Return JSON that matches the schema: { approved: boolean, changes: string[] }. -If not approved, changes must be a prioritized, actionable edit list (include observed counts and missing items). - - - -- Today's date: ${utcDate} (UTC timezone) -- Website URL: ${project.websiteUrl} -- Article type: ${articleType ?? "(missing)"} -- Primary keyword: ${primaryKeyword ?? "(missing)"} -- Brand voice (must be followed): ${project.writingSettings?.brandVoice ?? "(missing)"} -- User instructions (must be followed): ${project.writingSettings?.customInstructions ?? "(missing)"} -${articleTypeRule ? `- Article-type rule:\n${articleTypeRule}` : ""} - - - -${JSON.stringify(analysis, null, 2)} - - - -${outline ?? "(missing)"} - - - -${text} -`, - messages: [ - { - role: "user", - content: `Review the article against the requirements and return (approved, changes).`, - }, - ], - output: Output.object({ - schema: arktypeToAiJsonSchema(reviewArticleOutputSchema), - }), - onStepFinish: (step) => { - logInfo("review step completed", { - instanceId: event.instanceId, - draftId: input.draftId, - text: step.text, - toolResults: JSON.stringify(step.toolResults, null, 2), - usage: step.usage, - }); - }, - prepareStep: ({ messages }) => { - return { - messages: [ - ...messages, - { - role: "assistant", - content: formatTodoFocusReminder({ - todos: todoTool.getSnapshot(), - maxOpen: 5, - }), - }, - ], - }; - }, - stopWhen: [stepCountIs(20)], - }); - ++attempts; - ({ approved, changes } = reviewResult); - logInfo("review result", { - instanceId: event.instanceId, - draftId: input.draftId, - approved, - changes, - }); - } - return { text, articleType, heroImage, heroImageCaption }; - }, - ); + const internalLinkedDraft = await step.do( + "writer phase 4 internal links", + { timeout: "2 minutes" }, + async () => { + const pipeline = createPipeline(); + const phase = await pipeline.runInternalLinksPhase({ + draft: rawDraft, + }); + logInfo("writer phase completed", { + instanceId: event.instanceId, + draftId: input.draftId, + phase: "internal-links", + ...summarizeAgentInvocation(phase.steps), + }); + return phase.output; + }, + ); - await step.do("Save article to file and update status", async () => { - const db = createDb(); - const { project } = await loadDraftAndProject({ - db, - organizationId: input.organizationId, - projectId: input.projectId, + const imagedDraft = await step.do( + "writer phase 5 images", + { timeout: "3 minutes" }, + async () => { + const pipeline = createPipeline(); + const phase = await pipeline.runImagesPhase({ + internalLinkedDraft, + }); + logInfo("writer phase completed", { + instanceId: event.instanceId, draftId: input.draftId, + phase: "images", + ...summarizeAgentInvocation(phase.steps), }); - const writeResult = await writeContentDraft({ - db, - chatId: input.chatId, - userId: input.userId ?? null, - projectId: input.projectId, - organizationId: input.organizationId, - lookup: { type: "id", id: input.draftId }, - draftNewValues: { - contentMarkdown: articleMarkdown, - heroImage, - heroImageCaption, - status: project.publishingSettings?.requireContentReview - ? "pending-review" - : "scheduled", - articleType, + return phase.output; + }, + ); + + const reviewedArticle = await step.do( + "writer phase 6 review", + { timeout: "10 minutes" }, + async () => { + const pipeline = createPipeline(); + const phase = await pipeline.runReviewLoopPhase({ + article: { + markdown: imagedDraft.markdown, + heroImage: imagedDraft.heroImage, + heroImageCaption: imagedDraft.heroImageCaption, }, + maxReviewIterations: 3, }); - if (!writeResult.ok) throw new Error(writeResult.error.message); - }); - logInfo("article saved", { - instanceId: event.instanceId, - draftId: input.draftId, - }); - return { - type: "seo-write-article", - draftId: input.draftId, - content: articleMarkdown, - articleType, - heroImage, - heroImageCaption, - } satisfies typeof seoWriteArticleTaskOutputSchema.infer; - } catch (e) { - const message = e instanceof Error ? e.message : "Unknown error"; - logError("generation failed", { - instanceId: event.instanceId, - draftId: input.draftId, - message, + logInfo("writer phase completed", { + instanceId: event.instanceId, + draftId: input.draftId, + phase: "review", + ...summarizeAgentInvocation(phase.steps), + reviewAttempts: phase.reviews.length, + lastReviewPasses: phase.reviews.at(-1)?.passes ?? true, + lastReviewScore: phase.reviews.at(-1)?.overallScore ?? null, + }); + return phase.output; + }, + ); + + const repairedLinks = repairPublicBucketImageLinks({ + markdown: reviewedArticle.markdown.trim(), + orgId: input.organizationId, + projectId: input.projectId, + kind: "content-image", + }); + + const articleMarkdown = repairedLinks.markdown; + const heroImage = reviewedArticle.heroImage.trim(); + const heroImageCaption = reviewedArticle.heroImageCaption?.trim() || null; + + await step.do("save article draft", async () => { + const db = createDb(); + const writeResult = await writeContentDraft({ + db, + chatId: input.chatId ?? null, + userId: input.userId ?? null, + projectId: input.projectId, + organizationId: input.organizationId, + lookup: { type: "id", id: input.draftId }, + draftNewValues: { + contentMarkdown: articleMarkdown, + heroImage, + heroImageCaption, + status: project.publishingSettings?.requireContentReview + ? "pending-review" + : "scheduled", + articleType, + }, }); + if (!writeResult.ok) { + throw writeResult.error; + } + }); - throw e; - } + logInfo("complete", { + instanceId: event.instanceId, + draftId: input.draftId, + articleType, + contentLength: articleMarkdown.length, + }); + + return { + type: "seo-write-article", + draftId: input.draftId, + content: articleMarkdown, + articleType, + heroImage, + heroImageCaption, + } satisfies typeof seoWriteArticleTaskOutputSchema.infer; } } diff --git a/packages/core/src/schema/arktype-json-schema-transformer.ts b/packages/core/src/schema/arktype-json-schema-transformer.ts deleted file mode 100644 index 134186901..000000000 --- a/packages/core/src/schema/arktype-json-schema-transformer.ts +++ /dev/null @@ -1,38 +0,0 @@ -export type ArkTypeSchema = { - toJsonSchema: () => unknown; -}; - -function replaceStringEnumWithAnyOf(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map((item) => replaceStringEnumWithAnyOf(item)); - } - - if (!value || typeof value !== "object") { - return value; - } - - const record = value as Record; - const normalized: Record = {}; - - for (const [key, item] of Object.entries(record)) { - normalized[key] = replaceStringEnumWithAnyOf(item); - } - - if ( - Array.isArray(record.enum) && - record.enum.every((item) => typeof item === "string") - ) { - normalized.anyOf = [...record.enum]; - normalized.type = "string"; - delete normalized.enum; - } - if (Array.isArray(record.anyOf) || record.const) { - normalized.type = "string"; - } - - return normalized; -} - -export function arkTypeJsonSchemaTransformer(schema: ArkTypeSchema) { - return replaceStringEnumWithAnyOf(schema.toJsonSchema()); -} diff --git a/packages/core/src/schemas/project-parsers.ts b/packages/core/src/schemas/project-parsers.ts index 1f908d558..aeb5437db 100644 --- a/packages/core/src/schemas/project-parsers.ts +++ b/packages/core/src/schemas/project-parsers.ts @@ -26,40 +26,28 @@ export const COUNTRY_CODE_MAP: Record = { AE: "United Arab Emirates", ZA: "South Africa", }; - export const businessBackgroundSchema = type({ version: "'v1'", businessOverview: type("string") .atLeastLength(1) - .describe( - "Start with org type + primary offering(s); state ALL the business Unique Value Proposition comprehensively; no fluff.", - ) + .configure({ message: () => "Business Overview is required", }), targetAudience: type("string") .atLeastLength(1) - .describe( - "Format: B2B - Roles/Titles; Industries; Company size; Geo. B2C - Personas; Demographics/Age; Needs/Use cases; Geo. If both, include both separated by ' | '. Examples — B2B: 'Ops leaders; SaaS; 50-500 FTE; US/UK' | 'HR Directors; Healthcare; 200-1000 FTE; US/CA'. B2C: 'Parents of toddlers; Age 25-40; Childcare savings; US' | 'College students; Age 18-24; Budget laptops; UK'.", - ) + .configure({ message: () => "Target Audience is required", }), caseStudies: type({ title: "string", description: "string", - }) - .array() - .describe("Case studies that demonstrate results or credibility."), + }).array(), // TODO: re-enforce .url when ArkType fixes toJsonSchema for url https://github.com/arktypeio/arktype/issues/1475 - competitorsWebsites: type({ url: "string" }) - .array() - .describe("List of URLs of direct competitors. Leave blank if none."), + competitorsWebsites: type({ url: "string.url" }).array(), industry: type("string") .atLeastLength(1) - .describe( - "Broad top-level category, e.g. 'Software', 'Healthcare', 'E-commerce'.", - ) .configure({ message: () => "Industry is required", }), @@ -67,63 +55,193 @@ export const businessBackgroundSchema = type({ `'${Object.keys(COUNTRY_CODE_MAP) .map((key) => key) .join("'|'")}'`, - ) - .describe( - "2-letter country code that would contain the majority of the target audience. Default 'US' if not specified.", - ) - .configure({ - message: () => "Country Code is required", - }), - targetCity: type("string").describe( - "City name that would contain the majority of the target audience . Default 'San Francisco' if not specified.", - ), - languageCode: type("string").describe( - "2-letter language code that would encompass the language of the majority of the target audience. Default 'en' if not specified.", - ), + ).configure({ + message: () => "Country Code is required", + }), + targetCity: type("string"), + languageCode: type("string"), }); +export const businessBackgroundJsonSchema = { + type: "object", + additionalProperties: false, + required: [ + "businessOverview", + "targetAudience", + "caseStudies", + "competitorsWebsites", + "industry", + "targetCountryCode", + "targetCity", + "languageCode", + ] satisfies string[], + properties: { + businessOverview: { + type: "string", + minLength: 1, + description: + "Start with org type + primary offering(s); state ALL the business Unique Value Proposition comprehensively; no fluff.", + }, + targetAudience: { + type: "string", + minLength: 1, + description: + "Format: B2B - Roles/Titles; Industries; Company size; Geo. B2C - Personas; Demographics/Age; Needs/Use cases; Geo. If both, include both separated by ' | '. Examples — B2B: 'Ops leaders; SaaS; 50-500 FTE; US/UK' | 'HR Directors; Healthcare; 200-1000 FTE; US/CA'. B2C: 'Parents of toddlers; Age 25-40; Childcare savings; US' | 'College students; Age 18-24; Budget laptops; UK'.", + }, + caseStudies: { + type: "array", + description: "Case studies that demonstrate results or credibility.", + items: { + type: "object", + additionalProperties: false, + required: ["title", "description"] satisfies string[], + properties: { + title: { type: "string" }, + description: { type: "string" }, + }, + }, + }, + competitorsWebsites: { + type: "array", + description: "List of URLs of direct competitors. Leave blank if none.", + items: { + type: "object", + additionalProperties: false, + required: ["url"] satisfies string[], + properties: { + url: { type: "string" }, + }, + }, + }, + industry: { + type: "string", + minLength: 1, + description: + "Broad top-level category, e.g. 'Software', 'Healthcare', 'E-commerce'.", + }, + targetCountryCode: { + type: "string", + enum: Object.keys(COUNTRY_CODE_MAP), + description: + "2-letter country code that would contain the majority of the target audience. Default 'US' if not specified.", + }, + targetCity: { + type: "string", + description: + "City name that would contain the majority of the target audience . Default 'San Francisco' if not specified.", + }, + languageCode: { + type: "string", + description: + "2-letter language code that would encompass the language of the majority of the target audience. Default 'en' if not specified.", + }, + }, +} as const; export const imageSettingsSchema = type({ version: "'v1'", styleReferences: type({ uris: "string[]", "instructions?": "string", - }) - .array() - .describe( - "Visual references that describe the desired style, composition, or mood.", - ), + }).array(), brandLogos: type({ uris: "string[]", "name?": "string", "instructions?": "string", - }) - .array() - .describe("Brand logos that should be used for the project."), - imageInstructions: type("string").describe( - "Additional guidance for how generated images should look (e.g., do/don'ts, brand rules).", - ), - stockImageProviders: type("'unsplash'|'pexels'|'pixabay'") - .array() - .describe( - "Preferred stock image providers to search (in order) when looking up open-license images.", - ), + }).array(), + imageInstructions: type("string"), + stockImageProviders: type("'unsplash'|'pexels'|'pixabay'").array(), }); +export const imageSettingsJsonSchema = { + type: "object", + additionalProperties: false, + required: [ + "styleReferences", + "brandLogos", + "imageInstructions", + "stockImageProviders", + ] satisfies string[], + properties: { + styleReferences: { + type: "array", + description: + "Visual references that describe the desired style, composition, or mood.", + items: { + type: "object", + additionalProperties: false, + required: ["uris"] satisfies string[], + properties: { + uris: { + type: "array", + items: { type: "string" }, + }, + instructions: { type: "string" }, + }, + }, + }, + brandLogos: { + type: "array", + description: "Brand logos that should be used for the project.", + items: { + type: "object", + additionalProperties: false, + required: ["uris"] satisfies string[], + properties: { + uris: { + type: "array", + items: { type: "string" }, + }, + name: { type: "string" }, + instructions: { type: "string" }, + }, + }, + }, + imageInstructions: { + type: "string", + description: + "Additional guidance for how generated images should look (e.g., do/don'ts, brand rules).", + }, + stockImageProviders: { + type: "array", + description: + "Preferred stock image providers to search (in order) when looking up open-license images.", + items: { + type: "string", + enum: ["unsplash", "pexels", "pixabay"] satisfies string[], + }, + }, + }, +} as const; export const writingSettingsSchema = type({ version: "'v1'", brandVoice: type("string") .atLeastLength(1) - .describe( - "Capture brand voice comprehensively: Tone (e.g., professional, casual, witty, authoritative, empathetic); Style (e.g., concise, storytelling, data-driven, conversational); Persona (e.g., expert advisor, friendly guide, industry leader, innovator); Voice attributes (e.g., formal/informal, technical/accessible, serious/playful). Include linguistic patterns if distinctive (e.g., 'uses contractions', 'avoids jargon', 'data-heavy with examples').", - ) + .configure({ message: () => "Brand Voice is required", }), - customInstructions: type("string").describe( - "Extra instructions to steer generated articles (e.g., formatting, calls to action, do/don'ts).", - ), + customInstructions: type("string"), }); +export const writingSettingsJsonSchema = { + type: "object", + additionalProperties: false, + required: ["brandVoice", "customInstructions"] satisfies string[], + properties: { + brandVoice: { + type: "string", + minLength: 1, + description: + "Capture brand voice comprehensively: Tone (e.g., professional, casual, witty, authoritative, empathetic); Style (e.g., concise, storytelling, data-driven, conversational); Persona (e.g., expert advisor, friendly guide, industry leader, innovator); Voice attributes (e.g., formal/informal, technical/accessible, serious/playful). Include linguistic patterns if distinctive (e.g., 'uses contractions', 'avoids jargon', 'data-heavy with examples').", + }, + customInstructions: { + type: "string", + description: + "Extra instructions to steer generated articles (e.g., formatting, calls to action, do/don'ts).", + }, + }, +} as const; + export const publishingSettingsSchema = type({ version: "'v1'", requireContentReview: "boolean", @@ -131,16 +249,83 @@ export const publishingSettingsSchema = type({ participateInLinkExchange: type("boolean").default(() => true), }); +export const publishingSettingsJsonSchema = { + type: "object", + additionalProperties: false, + required: [ + "version", + "requireContentReview", + "requireSuggestionReview", + "participateInLinkExchange", + ] satisfies string[], + properties: { + version: { + type: "string", + const: "v1", + }, + requireContentReview: { type: "boolean" }, + requireSuggestionReview: { type: "boolean" }, + participateInLinkExchange: { type: "boolean" }, + }, +} as const; + export const authorSettingsSchema = type({ - name: type("string").describe("The name of the author."), - title: type("string").describe("The title of the author."), - bio: type("string").describe("The bio of the author."), - avatarUri: type("string").describe("The avatar URI of the author."), + name: type("string"), + title: type("string"), + bio: type("string"), + avatarUri: type("string"), socialLinks: type({ platform: "string", url: "string.url", }) .array() - .or(type.null) - .describe("The social links of the author."), + .or(type.null), }); + +export const authorSettingsJsonSchema = { + type: "object", + additionalProperties: false, + required: [ + "name", + "title", + "bio", + "avatarUri", + "socialLinks", + ] satisfies string[], + properties: { + name: { + type: "string", + description: "The name of the author.", + }, + title: { + type: "string", + description: "The title of the author.", + }, + bio: { + type: "string", + description: "The bio of the author.", + }, + avatarUri: { + type: "string", + description: "The avatar URI of the author.", + }, + socialLinks: { + anyOf: [ + { + type: "array", + items: { + type: "object", + additionalProperties: false, + required: ["platform", "url"] satisfies string[], + properties: { + platform: { type: "string" }, + url: { type: "string", format: "uri" }, + }, + }, + }, + { type: "null" }, + ], + description: "The social links of the author.", + }, + }, +} as const; diff --git a/packages/db/src/operations/seo/content-operations.ts b/packages/db/src/operations/seo/content-operations.ts index 314f107d6..191a99cd5 100644 --- a/packages/db/src/operations/seo/content-operations.ts +++ b/packages/db/src/operations/seo/content-operations.ts @@ -292,6 +292,53 @@ export async function hardDeleteDraft(args: { return ok(deletedDraft); } +export async function softDeleteDraft(args: { + db: DB | DBTransaction; + organizationId: string; + projectId: string; + id: string; +}) { + const result = await safe(async () => { + const now = new Date(); + + // Deleting a draft should also unpublish any published versions created from it. + await args.db + .update(schema.seoContent) + .set({ + deletedAt: now, + }) + .where( + and( + eq(schema.seoContent.originatingDraftId, args.id), + eq(schema.seoContent.projectId, args.projectId), + eq(schema.seoContent.organizationId, args.organizationId), + ), + ); + + return await args.db + .update(schema.seoContentDraft) + .set({ + deletedAt: now, + }) + .where( + and( + eq(schema.seoContentDraft.id, args.id), + eq(schema.seoContentDraft.projectId, args.projectId), + eq(schema.seoContentDraft.organizationId, args.organizationId), + ), + ) + .returning(); + }); + if (!result.ok) { + return result; + } + const deletedDraft = result.value[0]; + if (!deletedDraft) { + return err(new Error("Failed to soft delete draft")); + } + return ok(deletedDraft); +} + /** * Get draft by slug. Since there's only one draft per slug, no need for originatingChatId. */ diff --git a/packages/db/src/operations/seo/strategy-operations.ts b/packages/db/src/operations/seo/strategy-operations.ts index fed1c2fc1..2eacd2fa4 100644 --- a/packages/db/src/operations/seo/strategy-operations.ts +++ b/packages/db/src/operations/seo/strategy-operations.ts @@ -179,6 +179,55 @@ export async function updateStrategy( return ok(strategy); } +export async function softDeleteStrategy(args: { + db: DB | DBTransaction; + id: string; + projectId: string; + organizationId: string; + dismissalReason?: string | null; +}) { + const result = await safe(async () => { + const deletedStrategies = await args.db + .update(schema.seoStrategy) + .set({ + deletedAt: new Date(), + status: "dismissed", + dismissalReason: args.dismissalReason ?? "deleted via data access tool", + }) + .where( + and( + eq(schema.seoStrategy.id, args.id), + eq(schema.seoStrategy.projectId, args.projectId), + eq(schema.seoStrategy.organizationId, args.organizationId), + ), + ) + .returning(); + + await args.db + .update(schema.seoContentDraft) + .set({ + strategyId: null, + }) + .where( + and( + eq(schema.seoContentDraft.strategyId, args.id), + eq(schema.seoContentDraft.projectId, args.projectId), + eq(schema.seoContentDraft.organizationId, args.organizationId), + ), + ); + + return deletedStrategies; + }); + if (!result.ok) { + return result; + } + const strategy = result.value[0]; + if (!strategy) { + return err(new Error("Failed to soft delete strategy")); + } + return ok(strategy); +} + export async function createStrategyPhase( db: DB | DBTransaction, values: typeof seoStrategyPhaseInsertSchema.infer, From d4ee5adbaef8c3855d6838431a7f675fab244418 Mon Sep 17 00:00:00 2001 From: ElasticBottle Date: Mon, 2 Mar 2026 08:55:46 +0800 Subject: [PATCH 5/9] feat(api-seo): add evaluation framework --- .../src/eval/content/fixtures/index.ts | 1354 +++++++++++++++++ packages/api-seo/src/eval/content/score.ts | 442 ++++++ .../src/eval/strategy/fixtures/index.ts | 64 + packages/api-seo/src/eval/strategy/score.ts | 233 +++ packages/api-seo/src/eval/types.ts | 123 ++ 5 files changed, 2216 insertions(+) create mode 100644 packages/api-seo/src/eval/content/fixtures/index.ts create mode 100644 packages/api-seo/src/eval/content/score.ts create mode 100644 packages/api-seo/src/eval/strategy/fixtures/index.ts create mode 100644 packages/api-seo/src/eval/strategy/score.ts create mode 100644 packages/api-seo/src/eval/types.ts diff --git a/packages/api-seo/src/eval/content/fixtures/index.ts b/packages/api-seo/src/eval/content/fixtures/index.ts new file mode 100644 index 000000000..9f758ba7b --- /dev/null +++ b/packages/api-seo/src/eval/content/fixtures/index.ts @@ -0,0 +1,1354 @@ +import type { ContentFixture } from "../../types"; + +const QuantumByteProject = { + name: "Quantum Byte", + websiteUrl: "https://quantumbyte.ai", + businessBackground: + "Quantum Byte is a AI App Builder, targeted at founders and business owners. Its platform enables custom software to be built at a fraction of the price, and with incredible ease, as users merely have to describe the application they wish to build, and the platform would begin creating the application to the user's specification within minutes. Templates on commonly used features like landing pages, scheduling and booking features, are also available, which decrease the time to creation.", + brandVoice: `- use a mix of short sentences, longer ones, and direct sentences. Do not just use one style of sentences throughout the article +- maximise for human readability - sentences should read easily (ie. not have too complicated sentence structure or stoppages), and paragraphs and sections should flow well into each other, having segues, and clear ways in which they relate to each other if its not readily apparent from the subject matter itself +- do not have paragraphs that are too long, and make sure there are suitable breaks after a few paragraphs +- do not use emdashes +- do not use bold words, outside of headings and the start of bullet points +- do not use chiasmus in the form of "it's not X, it's Y" + + +1. The "Visionary" Persona +Quantum Byte positions itself as a partner for founders. The tone should feel like a supportive, tech-savvy co-founder speaking to an ambitious business owner. + +2. Action-Oriented & Practical +* Structure: Use bullet points, numbered lists, and comparison tables. +* Focus: Always link features (like "AI-powered app development") to benefits ("save 20 hours a week," "scale without hiring"). + +3. Direct Address +Speak directly to the reader's challenges. +* Tone: Use "you" and "your" frequently. +* Empathy: Acknowledge common pain points like hiring headaches, and manual admin burden before presenting the solution. +`, + customInstructions: `We should aim to have at least one CTA per article that links to /packets or /enterprise or the homepage with the appropriate UTM tags. + +Plugging the company's product +a) NEVER FORCE MENTIONS - Only mention the company's product where it genuinely solves a problem +- If it doesn't fit naturally, skip it +b) ONLY WHERE CONTEXTUALLY HELPFUL +- Must provide real value to the reader +- Should address a specific pain point or opportunity +c) Target: 2-3 Mentions Per Article +- Not every section needs a mention +- Quality over quantity +- Must feel organic, never promotional +d) It is permissible to name and evaluate competitors well, but always place an emphasis on the company's offering, and conclude that it is the best option for the reader. Do this organically. +e) when the primary keyword is enterprise software, plug quantum byte's enterprise solution targeted toward businesses, (https://quantumbyte.ai/enterprise). When the primary keyword is a consumer solution, plug quantum byte's consumer facing software (https://app.quantumbyte.ai/packets) + + +Each CTA should naturally bring up why you believe Quantum Byte's is the best choice for the job. +1) Founder-friendly - it is built with founders and business owners in mind +2) Business-friendly - there are pre-set templates for common features a business needs that would save the user so much time that it would essentially be a plug-and-play internal tool that the user can just use right away, but with the added flexibility of changing certain things it doesn't like, which they can't do for other solutions. +- there is an entry-level platform tier, and then an enterprise tier for businesses if they decide that they need more customizability. +3) Marrying customizability with speed - pre-set applications are often not customizable, and have a steep learning curve. Customizable app builders often take too long to build out. Quantum Byte marries these two by having templates and easy-to-use, natural prompting to create the application. Example - the Comedian Aziz Ansari used Quantum Byte's platform to create an app for his movie "Good Fortune" within minutes, having had no experience using such apps prior. `, +}; + +/** + * 5 content fixtures covering the most common article types. + * + * Each fixture simulates a realistic project + keyword combination + * that the writer workflow would receive. The `referenceOutput` starts + * null and should be populated with the current best output after an + * initial run you're satisfied with. + * + * To add a new fixture: append to this array, then run generation/scoring from admin UI. + */ +export const contentFixtures: ContentFixture[] = [ + { + id: "01-best-of-list-client-approval-software-for-agencies", + description: + "Best-of list. Tests listicle structure, screenshot inclusion, product link rules.", + input: { + primaryKeyword: "client approval software for agencies", + title: "Best Client Review and Approval Software for Agencies 2026", + articleType: "best-of-list", + notes: + "Target SaaS founders and team leads. Include pricing info where available. Focus on tools suitable for teams of 10-50.", + outline: null, + project: QuantumByteProject, + }, + expectations: { + minWordCount: 1200, + maxWordCount: 2500, + }, + referenceOutput: `Client feedback is easy to collect. Final approval is harder to control. + +Without a structured approval system, agencies lose time to scattered comments, version confusion, and "looks good" emails that never translate into billable sign-off. The result is extra revision cycles, blurred scope, and approvals that are hard to defend later. + +The right client approval software replaces inbox chaos with a clear, traceable workflow: one version to review, explicit approval actions, and a record you can tie directly to delivery and billing. + +This guide ranks the best client approval software for agencies in 2026, comparing dedicated proofing platforms with customizable and build-your-own options. Whether you need fast, structured reviews or a fully tailored client portal that connects approvals to operations, you'll see the trade-offs clearly and choose the right fit for how your agency runs. + +## What client approval software for agencies should do + +At a minimum, client approval software should replace scattered email threads and "final_v7_REALfinal.pdf" attachments with a single source of truth. + +Look for capabilities that map to how agencies actually ship work: + +* **Centralized proofing**: One link where stakeholders review the right version, every time. + +* **Version control**: Clear history of what changed, when it changed, and who requested it. + +* **In-context annotations**: Comments pinned to a frame, timestamp, page, or exact UI element. + +* **Approval states**: Explicit "Approved" vs "Needs changes" actions, not vague "Looks good". + +* **Audit trail**: A defensible record of decisions, useful for scope control and billing. + +* **Client-friendly access**: Simple guest review flows so clients do not need a crash course. + +If your agency works with enterprise clients, also pay attention to security and access control. The [AICPA's SOC 2 overview](https://www.aicpa-cima.com/topic/audit-assurance/audit-and-assurance-greater-than-soc-2) explains what SOC 2 (System and Organization Controls 2) reports cover, and the [NIST definition of least privilege](https://csrc.nist.gov/glossary/term/least_privilege) is a useful north star when deciding who can approve what. + +## Buying criteria that actually matters for agencies + +Most tools claim "review and approval." The difference is how well they fit your workflow and your client mix. + +Use this checklist to narrow options fast: + +* **Approval workflow fit**: If you need multi-step approvals (creative lead, account manager, client, legal), choose a tool that supports staged approvals, not just comments. + +* **File type coverage**: Video, PDF (Portable Document Format), images, audio, and "live web pages" all behave differently. Buy for your primary deliverables. + +* **Client experience**: Guest access, clear notifications, and low friction sign-off beat feature bloat. + +* **Permissions and roles**: You want granular controls so a client can approve, but not accidentally edit internal notes. + +* **Integrations**: Slack, Microsoft Teams, Google Drive, Adobe Creative Cloud, project management tools, and storage. + +* **Single sign-on support**: If your clients ask for SSO (single sign-on), you may hear about SAML (Security Assertion Markup Language). The [OASIS SAML technical overview](https://docs.oasis-open.org/security/saml/Post2.0/sstc-saml-tech-overview-2.0.html) is the standards-body reference. + +* **Reporting**: You should be able to see what is stuck, who is late, and how long approvals take. + +## A simple client approval workflow that scales + +![Screenshot of A simple client approval workflow that scales website](https://s3.fluidposts.com/org_ZSaKaSS2hsAE9b1JbOUpiZDTj0iFhVaL/proj_019ab784-5a74-7209-b2d6-73b9da6e148f/content-image/019c6503-2019-75c8-a71e-451c9be03efb__019c6503-2019-75c8-a71e-40880f6a1d4e.webp) + +A scalable approval workflow is boring on purpose. It is predictable, repeatable, and hard to "accidentally bypass." + +A strong default flow for most agencies: + +* **Draft**: The first shareable version, ready for real feedback. + +* **Internal review**: Your team catches obvious issues before the client sees them. + +* **Client review**: Stakeholders comment in one place, on one version. + +* **Revisions**: You respond to feedback and publish a new version. + +* **Final approval**: Client signs off explicitly. + +* **Delivery**: You hand over assets and lock the final version. + +If you want to reduce scope fights, set one rule: feedback is not "approved" until it is an approval action inside the tool, not a message in email. + +## Best client approval software for agencies in 2026 + +The list below is opinionated. It prioritizes tools that reduce revision loops, protect your margins, and keep clients moving. + +### 1) Quantum Byte + +![Screenshot of Quantum Byte website](https://s3.fluidposts.com/org_ZSaKaSS2hsAE9b1JbOUpiZDTj0iFhVaL/proj_019ab784-5a74-7209-b2d6-73b9da6e148f/content-image/019be653-9c45-7012-a0f9-126f0291f0f0__019be653-9c45-7012-a0f9-0fc94f46d349.webp) + +Off-the-shelf proofing tools are great, until your workflow is different. Quantum Byte is the best option when your agency needs a client approval system that matches how you deliver work, how you bill, and how you protect scope. + +What makes it #1 for agencies with ambitious ops: + +* **Custom approval logic**: Build the exact stages you need (internal checks, client sign-off, legal review, "approved with changes," and more). + +* **Real integrations**: Connect approvals to your project management, invoicing, storage, and client portal so "approved" triggers the next step. + +* **AI-first build speed**: Prototype an internal approval app from natural language, then have a development team take it across the finish line when needed. + +When to pick this over a dedicated proofing tool: + +* **You sell retainers or packages**: You want approvals tied to change requests, scope, and billing. + +* **You need a client portal**: One place for deliverables, approvals, requests, and history. + +* **You want to productize**: Turn your delivery system into something repeatable, even resellable. + +You can get started with [Quantum Byte](https://app.quantumbyte.ai/packets?utm_source=quantumbyte_blog&utm_medium=content&utm_campaign=client_approval_software_for_agencies) today and have your own approval flows exactly how you want them. + +For larger teams standardizing across departments, we also offer an [enterprise solution](https://quantumbyte.ai/enterprise/). + +### 2) Ziflow + +![Screenshot of Ziflow website](https://s3.fluidposts.com/org_ZSaKaSS2hsAE9b1JbOUpiZDTj0iFhVaL/proj_019ab784-5a74-7209-b2d6-73b9da6e148f/content-image/019c6502-cafc-7568-abe3-4e4b645c0472__019c6502-cafc-7568-abe3-4a06f2379596.webp) + +[Ziflow](https://www.ziflow.com/) is a strong pick for agencies that need structured proofing across many asset types and stakeholders. + +* **Best for**: Agencies running high-volume review cycles with clear approval gates. + +* **Why it's listed**: Mature proofing focus, designed for review discipline. + +* **Watch for**: Complexity can be overkill for very small teams. + +### 3) Filestage + +![Screenshot of Filestage website](https://s3.fluidposts.com/org_ZSaKaSS2hsAE9b1JbOUpiZDTj0iFhVaL/proj_019ab784-5a74-7209-b2d6-73b9da6e148f/content-image/019c6502-c29d-734a-80fd-52802683a0c1__019c6502-c29d-734a-80fd-4efbd8cacaae.webp) + +[Filestage](https://filestage.io/) is a clean, straightforward proofing platform that keeps versions, feedback, and approvals in one place. + +* **Best for**: Agencies that want a simple review experience clients adopt quickly. + +* **Why it's listed**: Strong core workflow with low friction. + +* **Watch for**: If you need a full client portal, you may outgrow it. + +### 4) Frame.io + +![Screenshot of Frame.io website](https://s3.fluidposts.com/org_ZSaKaSS2hsAE9b1JbOUpiZDTj0iFhVaL/proj_019ab784-5a74-7209-b2d6-73b9da6e148f/content-image/019c6502-d50d-77bc-ab89-f4dd1eeebf3b__019c6502-d50d-77bc-ab89-f0cddc1c25d2.webp) + +[Frame.io](https://frame.io/) is a go-to for video-heavy agencies that need tight review loops, time-based notes, and fast client approvals. + +* **Best for**: Video production, post-production, social video teams. + +* **Why it's listed**: Video review is its home turf. + +* **Watch for**: If most deliverables are web pages or static design, you may prefer a broader proofing tool. + +### 5) PageProof + +![Screenshot of PageProof website](https://s3.fluidposts.com/org_ZSaKaSS2hsAE9b1JbOUpiZDTj0iFhVaL/proj_019ab784-5a74-7209-b2d6-73b9da6e148f/content-image/019c6502-c41c-770e-a646-88496bc3df65__019c6502-c41c-770e-a646-843028f313fe.webp) + +[PageProof](https://pageproof.com/) is versatile online proofing across many file types, with a strong emphasis on making approvals feel simple. + +* **Best for**: Agencies managing mixed media approvals, not only video. + +* **Why it's listed**: Broad file type support with clear approval flows. + +* **Watch for**: As workflows get more custom, you may need deeper process tooling. + +### 6) GoVisually + +![Screenshot of GoVisually website](https://s3.fluidposts.com/org_ZSaKaSS2hsAE9b1JbOUpiZDTj0iFhVaL/proj_019ab784-5a74-7209-b2d6-73b9da6e148f/content-image/019c6502-d328-761e-b996-bae7130a5527__019c6502-d328-761e-b996-b418be2a2863.webp) + +[GoVisually](https://govisually.com/) is built for design, PDF, and video review with a focus on collaborative markup and approvals. + +* **Best for**: Creative teams that review lots of design assets. + +* **Why it's listed**: Agency-friendly proofing experience. + +* **Watch for**: If you need deep project management, you will still need a separate system. + +### 7) Approval Studio + +![Screenshot of Approval Studio website](https://s3.fluidposts.com/org_ZSaKaSS2hsAE9b1JbOUpiZDTj0iFhVaL/proj_019ab784-5a74-7209-b2d6-73b9da6e148f/content-image/019c6502-d920-710b-8390-826fb89885cb__019c6502-d920-710b-8390-7d18301cb408.webp) + +[Approval Studio](https://approval.studio/) is a solid option when "artwork approval" is your world, especially packaging and print-style review. + +* **Best for**: Packaging, labels, artwork-heavy workflows. + +* **Why it's listed**: Tailored to detailed visual approval. + +* **Watch for**: Not as broad if you mainly approve web builds. + +### 8) Lytho Reviews + +![Screenshot of Lytho Reviews website](https://s3.fluidposts.com/org_ZSaKaSS2hsAE9b1JbOUpiZDTj0iFhVaL/proj_019ab784-5a74-7209-b2d6-73b9da6e148f/content-image/019c6503-aa01-768e-8730-cf7a733ac7a2__019c6503-aa01-768e-8730-c9bf6dfc142c.webp) + +[Lytho Reviews](https://www.lytho.com/reviews/) is a more "workflow + governance" flavored approach to review and approval. + +* **Best for**: Teams that want review connected to broader creative operations. + +* **Why it's listed**: Positioned for structured review with traceability. + +* **Watch for**: Can feel bigger than what a small agency needs. + +### 9) Pastel + +![Screenshot of Pastel website](https://s3.fluidposts.com/org_ZSaKaSS2hsAE9b1JbOUpiZDTj0iFhVaL/proj_019ab784-5a74-7209-b2d6-73b9da6e148f/content-image/019c6502-d701-7179-b6ec-fc90c0cc973a__019c6502-d701-7179-b6ec-f8455443ee11.webp) + +[Pastel](https://usepastel.com/) shines when the thing being reviewed is the website itself. It helps clients comment directly on live pages. + +* **Best for**: Web design and web development feedback. + +* **Why it's listed**: Reduces "what page are you talking about?" confusion. + +* **Watch for**: Not a full multi-asset proofing suite. + +### 10) MarkUp.io + +![Screenshot of MarkUp.io website](https://s3.fluidposts.com/org_ZSaKaSS2hsAE9b1JbOUpiZDTj0iFhVaL/proj_019ab784-5a74-7209-b2d6-73b9da6e148f/content-image/019c6502-de66-72dd-b052-df80d29be199__019c6502-de66-72dd-b052-da1d4e57918e.webp) + +[MarkUp.io](https://www.markup.io/) is a flexible visual commenting layer across many content types, useful when you want quick context and fast turnaround. + +* **Best for**: Teams that want "comment on the thing" across lots of formats. + +* **Why it's listed**: Low friction visual feedback. + +* **Watch for**: You may need stronger approval gates depending on your process. + +### 11) ProofHub + +![Screenshot of ProofHub website](https://s3.fluidposts.com/org_ZSaKaSS2hsAE9b1JbOUpiZDTj0iFhVaL/proj_019ab784-5a74-7209-b2d6-73b9da6e148f/content-image/019c6502-dd15-75e9-be58-09d09d702daa__019c6502-dd15-75e9-be58-05799d601cf8.webp) + +[ProofHub](https://www.proofhub.com/) is a broader project management tool that includes proofing. It can work well if you want fewer tools overall. + +* **Best for**: Agencies trying to consolidate tasks and approvals into one system. + +* **Why it's listed**: Proofing plus project coordination in one place. + +* **Watch for**: Dedicated proofing tools can feel more purpose-built for heavy review cycles. + +## Off-the-shelf vs custom approval workflows + +If your agency workflow is standard, use an off-the-shelf proofing tool and move on. + +If your workflow is tied to how you make money, custom starts to win. + +Common signals you should build your own client approval system: + +* **Your approvals drive billing**: You need approvals to trigger invoices, milestone releases, or retainer reporting. + +* **Your process is a product**: You want repeatable delivery systems, maybe even a white-labeled portal. + +* **You need one client workspace**: Approvals, requests, files, timelines, and history in one place. + +This is where Quantum Byte fits naturally. If you are already thinking about [productizing your service](https://quantumbyte.ai/articles/productization-strategy-small-business) or launching a [white label app builder model](https://quantumbyte.ai/articles/white-label-app-builder-sell-under-your-brand), a custom approval portal becomes part of the system you can scale. + +## Set up your approval system in 7 days + +You do not need a perfect process. You need a clear one. + +* **Day 1**: Define "approved." Write down what counts as approval, who can approve, and what happens next. + +* **Day 2**: Create templates. Standard folders, naming, and a default review stage flow. + +* **Day 3**: Set roles. Decide who can comment, who can approve, and who can invite others. + +* **Day 4**: Add guardrails. Lock final versions, require explicit sign-off, and keep an audit trail. + +* **Day 5**: Client onboarding script. A short message you paste into every kickoff with a single link to review. + +* **Day 6**: Connect notifications. Slack or email alerts so you are not polling for feedback. + +* **Day 7**: Review a real project. Fix the friction you feel, not the friction you imagine. + +If you want your approval flow to trigger broader operations, like automatically creating tasks, updating statuses, and pushing approved assets into a delivery portal, start from a simple build on the [Quantum Byte platform](https://quantumbyte.ai/) and expand from there. + +## What you should take away + +Client approval systems protect your margins by simplifying complex administrative work. + +You now have: + +* A clear definition of what client approval software should do for agencies + +* A buyer checklist that cuts through "feature noise" + +* A scalable approval workflow you can standardize + +* A best-of list of top proofing and approval tools, including when custom is the smarter move + +If you are stuck between "we need approvals" and "we need a client portal that runs the whole engagement," this is the fork in the road. Off-the-shelf proofing tools help you ship faster today. A custom workflow can help you build the agency you want to run long-term. + +## Frequently Asked Questions + +### What is client approval software for agencies? + +Client approval software for agencies is a tool (or system) that lets clients review deliverables, leave in-context feedback, and explicitly approve a final version with a trackable audit trail. + +### What is the difference between proofing software and project management software? + +Proofing software focuses on reviewing assets, collecting annotations, and capturing approvals. Project management software focuses on tasks, timelines, owners, and dependencies. Some platforms do both, but many agencies prefer a dedicated proofing layer plus a project system. + +### Which tool is best for website feedback and approval? + +For live website feedback, tools like Pastel and MarkUp.io are often a good fit because they let clients comment directly on the page. If you need deeper approval gates and a full client workspace, a custom portal can be a better long-term solution. + +### When should an agency build a custom approval portal? + +Build when approvals affect billing, scope control, and delivery handoff, or when you need one workspace that combines approvals, requests, and history. For those cases, a custom system built with Quantum Byte can match your exact workflow and integrations. + +### Do enterprise clients require SSO? + +Many enterprise clients ask for SSO (single sign-on) so access is managed centrally. If SSO is required, you may need a platform that supports SAML (Security Assertion Markup Language) or another enterprise identity protocol.`, + }, + + { + id: "02-how-to-choose-hvac-dispatch-software", + description: + "How-to guide for a technical HVAC Dispatch software topic. Tests instructional tone, step-by-step structure, and depth.", + input: { + primaryKeyword: "HVAC dispatch software", + title: + "HVAC Dispatch Software: How to Choose, Implement, and Scale Dispatch Without Chaos", + articleType: "how-to", + notes: null, + outline: null, + project: QuantumByteProject, + }, + expectations: { + minWordCount: 1200, + maxWordCount: 1800, + }, + referenceOutput: `Dispatch chaos costs you twice: angry customers on the phone and wasted technician hours on the road. The right HVAC dispatch software turns that mess into a repeatable system. You book faster, route smarter, and scale without hiring more office staff every time you add a truck. + +## What HVAC dispatch software should do + +At its core, HVAC dispatch software is field service management (FSM) software built for HVAC teams. It helps you schedule jobs, assign technicians, and keep the day moving. Salesforce's overview of [field service management](https://www.salesforce.com/service/field-service-management/what-is-fsm/) is a useful baseline if you want your team aligned on what FSM covers. + +Strong HVAC dispatch software should deliver five outcomes. + +* **Capture demand fast**: Turn calls, web forms, and maintenance requests into jobs with the right details (address, equipment type, urgency, warranty, and notes) so nothing gets lost in a sticky-note pile. + +* **Prioritize what matters**: Separate no-heat emergencies from tune-ups, and make that priority visible so your team dispatches with intent. + +* **Build a schedule that survives reality**: Create a plan that can handle cancellations, parts delays, and surprise overtime without collapsing by noon. + +* **Assign the right tech, not just any tech**: Match by skill, certification, location, and availability so first-time fix rate goes up. + +* **Close the loop**: Convert job completion into invoices, photos, notes, and follow-ups so you get paid faster and customers come back. + +If your current tool does "calendar scheduling" but not the rest, it is closer to a digital whiteboard than a true dispatch system. + +## How HVAC dispatch software works in practice + +![Illustration for how hvac dispatch software works in practice in HVAC Dispatch Software: How to Choose, Implement, and Scale Dispatch Without Chaos](https://s3.fluidposts.com/org_ZSaKaSS2hsAE9b1JbOUpiZDTj0iFhVaL/proj_019ab784-5a74-7209-b2d6-73b9da6e148f/content-image/019c650c-11c6-76bb-b53b-2029604d5af1__019c650c-11c6-76bb-b53b-1eba99cde955.jpg) + +A practical dispatch system follows a simple loop. + +1. **Job intake**: A request comes in and becomes a job with structured fields. +2. **Triage**: You set priority, time window, and whether it needs a specific skill. +3. **Scheduling**: You place the job into a day and time slot. +4. **Dispatch**: You assign a technician (and sometimes a helper). +5. **Routing**: You reduce drive time and deadhead gaps. +6. **Execution**: The tech updates status, captures photos, and logs parts used. +7. **Billing and follow-up**: The office invoices, collects payment, and triggers a review request or maintenance reminder. + +NetSuite's guidance is not HVAC-specific, but the practical tips map well to service businesses. Their article on [dispatching field service techs](https://www.netsuite.com/portal/resource/articles/erp/dispatch-tips.shtml) is a strong reference for triage, scheduling, and routing. + +## Build vs buy vs hybrid for HVAC dispatch software + +You have three realistic paths. The best choice depends on how unique your workflow is and how fast you need to move. + +
+ +| Approach | Best for | Pros | Cons | +| ------------------------------------ | ------------------------------------------------------ | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| Buy off-the-shelf FSM | Common workflows, fast rollout | Quick setup, mature mobile apps, proven billing features | You bend your process to the tool, custom logic can get expensive or impossible | +| Build custom dispatch | Unique service models, complex rules, multi-branch ops | Exact-fit workflow, competitive advantage, your data stays structured for analytics | Requires product thinking, integrations, and ongoing ownership | +| Hybrid (start bought, extend custom) | Most growing HVAC shops | Speed now, customization where it counts (intake rules, scheduling logic, dashboards) | Requires clear boundaries to avoid two sources of truth | + +
+ +If you are weighing custom work, Quantum Byte's guide on [when to build vs buy](https://quantumbyte.ai/articles/custom-business-software-development-build-vs-buy) can help you spot the signs that your dispatch process has outgrown a generic tool. + +## How to choose HVAC dispatch software that will not break at 30 calls a day + +Before you compare vendors, define what "good dispatch" means for your business. The fastest way is to turn your pain points into clear requirements. + +### Start with your workflow, not features + +Write down how a job moves today, from call to payment. Then circle every point where you lose time or create rework. + +* **Call handling and intake**: Do you need caller ID lookup, service agreement lookups, or scripted questions for after-hours triage? + +* **Scheduling rules**: Do you dispatch by zone, by skill, by membership tier, or by promised arrival window? + +* **Field updates**: Do techs need offline mode, photo capture, equipment details, or quote approvals? + +* **Invoicing and payments**: Do you need deposits, financing links, card-on-file, or batch invoicing? + +### Decide what data must be structured + +Dispatch falls apart when key details live only in free-text notes. At minimum, plan structured fields for: + +* **Customer and site**: Name, address, access notes, preferred contact method. + +* **Equipment**: Unit type, brand, model, serial, install date, warranty status. + +* **Job classification**: Emergency vs routine, maintenance plan, callback, estimate-only. + +* **Parts and labor**: Common parts used, labor codes, and what triggers a second trip. + +### Make mobile experience a non-negotiable + +If technicians hate the app, they will update jobs at the end of the day. That leaves dispatch blind. + +* **Fast status changes**: En route, on site, completed, needs parts. + +* **Photos and notes**: Proof of work and context for future calls. + +* **Customer messaging**: Simple "on the way" updates reduce inbound calls. + +### Treat security as a feature + +Dispatch systems store addresses, access notes, and sometimes payment data. Your baseline should include role-based access, audit logs, and secure defaults. + +If you are building a web app, the Open Worldwide Application Security Project (OWASP) Top 10 is a widely used starting point for common web security risks. OWASP's [Top 10 overview](https://owasp.org/Top10/2021/A00_2021_Introduction/) is a clear place to align your team on what can go wrong. + +## How to implement HVAC dispatch software step by step + +Implementation goes smoother when you treat it like an operations upgrade. Make the process decisions. Clean the data. Train in short loops. + +### 1) Map the dispatch process you want to run + +Aim for one page. You want clarity, not perfection. + +* **Define the intake questions**: What do you always need to know before scheduling? + +* **Define triage rules**: What counts as emergency, and who approves exceptions? + +* **Define job types**: Maintenance, repair, install, inspection, warranty, callback. + +Outcome: a shared dispatch playbook your team can follow. + +### 2) Build a clean data model before you migrate anything + +A data model is simply "what objects exist and how they relate." For dispatch, that usually includes customers, sites, equipment, technicians, jobs, job statuses, and invoices. + +Outcome: fewer duplicates, cleaner reporting, and less confusion in the field. + +### 3) Set roles and permissions early + +Do not wait until go-live to decide who can edit schedules, discount invoices, or view customer lists. + +* **Dispatcher**: Can assign, move, and reprioritize jobs. + +* **Technician**: Can update assigned jobs, log parts, and upload photos. + +* **Manager**: Can override, view performance dashboards, and approve exceptions. + +* **Accounting**: Can invoice, reconcile payments, and run tax reports. + +Outcome: fewer accidental changes and better accountability. + +### 4) Set up the dispatch board and schedule views + +Your dispatch board is your control center. It should answer three questions fast: what is unassigned, what is at risk, and who is available. + +![Screenshot of Set up the dispatch board and schedule views website](https://s3.fluidposts.com/org_ZSaKaSS2hsAE9b1JbOUpiZDTj0iFhVaL/proj_019ab784-5a74-7209-b2d6-73b9da6e148f/content-image/019c6506-735c-703e-a3bc-4600714fd844__019c6506-735c-703e-a3bc-42db774051b4.jpg) + +Configure: + +* **Unassigned queue**: New requests waiting for triage. + +* **Day view timeline**: Drag-and-drop jobs into time windows. + +* **Tech availability**: Paid time off (PTO), on-call rotations, skill tags. + +Outcome: dispatchers stop searching and start deciding. + +### 5) Add routing support that fits your operation + +If you dispatch more than a few techs, routing becomes a real lever. + +* **Cluster by area**: Reduce drive time and late arrivals. + +* **Respect time windows**: Protect promised arrival windows, especially for maintenance plans and priority customers. + +* **Handle re-optimization**: When a priority call comes in, your schedule should adapt without guesswork. + +If you are building custom routing, Google's Route Optimization Application Programming Interface (API) overview is a useful reference for how modern routing systems think about objectives, constraints, and time windows. See Google's [Route Optimization API overview](https://developers.google.com/maps/documentation/route-optimization/overview). + +Outcome: fewer windshield hours and more billable work. + +### 6) Integrate the essentials, but avoid integration addiction + +Integrations help when they remove double entry. They hurt when they create multiple sources of truth. + +Prioritize: + +* **Communications**: Call tracking, SMS confirmations, "tech en route" updates. + +* **Accounting**: Invoices and payments flowing to your accounting system. + +* **Inventory basics**: Parts used and reorder triggers, even if lightweight. + +Outcome: your office stops reconciling five tools at 7 p.m. + +### 7) Train in short loops and redesign the parts that fail + +Training works best when it mirrors real days. + +* **Dispatcher drills**: Practice a morning rush, a no-show, and an emergency add-on. + +* **Tech drills**: Start job, add photos, mark needs-parts, complete invoice notes. + +* **Exception drills**: Callback handling, warranty rules, and reschedules. + +Outcome: fewer workarounds and higher adoption. + +### 8) Go live in phases, then tighten the system + +Start with one branch, one service line, or one crew for a week. Fix friction fast. Then roll out. + +Outcome: controlled change instead of a company-wide scramble. + +## How to build custom HVAC dispatch software without waiting months + +If you are productizing a niche HVAC workflow or running a fast-growing service operation, custom dispatch can unlock leverage. The key is to build the smallest version that runs your real day. + +### Write a spec your future self will thank you for + +Do not start with "build me HVAC dispatch software." Start with clear inputs and outputs. + +* **Define the screens**: Intake, dispatch board, technician mobile view, job detail, invoice summary. + +* **Define status rules**: What changes when a job moves to "en route" or "needs parts." + +* **Define notifications**: What triggers SMS, internal alerts, or manager approvals. + +Outcome: a build plan that is easy to estimate, test, and improve. + +### Use an AI app builder for the first 80%, then bring in experts for the edge cases + +This is the fastest path when you want speed but still need real operations fit. + +* **Rapid prototyping**: Turn requirements into screens, data tables, and workflows quickly so your team can test with real calls. + +* **Expert finishing**: Add the hard parts later, like deep integrations, tricky permissions, multi-branch logic, and messy data cleanup. + +If you want to spec your system in a way an AI builder can actually build, Quantum Byte's guide on [AI app builder prompts](https://quantumbyte.ai/articles/ai-app-builder-prompts) is a practical reference. + +Outcome: a working prototype you can validate in the field before you invest in every edge case. + +If you want to build a dispatch prototype right away, you can easily create a "Dispatch MVP" with [Quantum Byte](https://app.quantumbyte.ai/packets?utm_source=quantumbyte.ai&utm_medium=content&utm_campaign=hvac_dispatch_software) with your intake form, dispatch board, and technician mobile flow. + +### Price and scope the build like an operator + +Custom software is a system you improve over time. Plan it in phases so you ship value early. + +* **Start with a Phase 1 MVP**: Intake, scheduling, assignment, status updates, and basic reporting. + +* **Phase 2 adds leverage**: Routing optimization, advanced triage rules, customer messaging automation. + +* **Phase 3 adds advantage**: Predictive maintenance triggers, membership growth flows, and analytics. + +Outcome: a roadmap that protects cash flow while still moving you toward a defensible system. + +## Metrics that prove your dispatch system is working + +You do not need 40 dashboards. You need a few numbers that reflect customer experience and operational flow. + +* **Time to schedule**: How long it takes from inbound request to confirmed appointment. + +* **On-time arrival rate**: Whether your promised windows match reality. + +* **First-time fix rate**: Whether the right tech and right info were dispatched. + +* **Jobs per tech per day**: A practical throughput measure that highlights routing and gaps. + +* **Callback rate**: A quality signal that often points to intake, triage, or job notes. + +Once you can see these reliably, you can tune dispatch as a system instead of managing it like a daily fire drill. + +## Common mistakes to avoid when rolling out HVAC dispatch software + +These are the failure modes that quietly kill adoption. + +* **Trying to migrate every historical record**: Move what you need to operate. Archive the rest. + +* **Leaving statuses vague**: If "in progress" means five different things, dispatch cannot manage risk. + +* **Ignoring the technician's workflow**: If updates take too many taps, data will arrive late or not at all. + +* **Letting exceptions become the process**: Define who can override rules and how those overrides get reviewed. + +## Turning dispatch into a scalable advantage + +HVAC dispatch software is not just a tool. It is a commitment to clarity in how work flows through your business. + +You covered what dispatch software must do, how it works day to day, and how to choose a system that holds up under real call volume. You also walked through a phased implementation approach, plus a practical way to build custom dispatch without waiting months. + +## Frequently Asked Questions + +### What is HVAC dispatch software, exactly? + +HVAC dispatch software is a scheduling and coordination system that helps your office intake service requests, prioritize them, assign the right technician, reduce drive time, and track job completion through invoicing and follow-up. + +### Is HVAC dispatch software different from field service management software? + +Field service management (FSM) is the broader category that includes dispatch plus inventory, customer communication, mobile field workflows, and reporting. HVAC dispatch software is often an HVAC-focused FSM tool or a dispatch-first subset of FSM. + +### When should I build custom HVAC dispatch software? + +Build custom when you have dispatch rules that off-the-shelf tools cannot handle well, like strict service zones, membership tiers, unusual pricing logic, multi-branch routing, or specialized approvals. It also makes sense when dispatch is a core differentiator you want to own. + +### What features matter most for a small HVAC business? + +Start with fast intake, a clear dispatch board, technician mobile updates, customer notifications, and a clean handoff to invoicing. Advanced analytics and heavy automation only help after the basics are solid. + +### Can I start with a prototype before replacing my current system? + +Yes. A controlled prototype lets you validate your data model and workflow with one team before a full rollout. This reduces risk and makes training feel real. + +### How long does it take to implement dispatch software? + +Some off-the-shelf tools can be configured quickly, but the real timeline depends on process alignment, data cleanup, integrations, and training. A phased go-live often beats a big-bang switch because you fix friction early while protecting customer experience. +`, + }, + + { + id: "03-comparison-no-code-vs-vibe-coding", + description: + "Product comparison between no code and vibe coding. Tests comparison table structure, balanced analysis, and clear recommendations.", + input: { + primaryKeyword: "no code vs vibe coding", + title: "No Code vs Vibe Coding: Which Is Best for Your Project?", + articleType: "comparison", + notes: null, + outline: null, + project: QuantumByteProject, + }, + expectations: { + minWordCount: 1000, + maxWordCount: 1500, + }, + referenceOutput: ` +If you are weighing no code vs vibe coding, you are really choosing how you want to build: visual blocks that stay inside a platform, or natural-language prompts that generate real code. Both can get you to a working product fast. The better choice depends on how custom your workflow is, how much you expect the product to grow, and how much risk you can tolerate when things break. + +## No code vs vibe coding: the real difference for a business owner + +* **No-code**: You assemble your app from visual components. You trade flexibility for speed and safety rails. + +* **Vibe coding**: You describe what you want in plain English and an Artificial Intelligence (AI) model writes code for you. You trade speed for higher maintenance and higher leverage. + +The mistake is thinking one is “better” in general. The winning move is picking the approach that matches your business constraints. + +## Definitions you can repeat to your team + +No-code and vibe coding get used loosely. Here are definitions you can actually align on. + +* **No-code**: a software development approach that lets users “create applications and automate business processes without writing code,” typically through visual interfaces and drag-and-drop tools . + +* **Vibe coding**: prompting AI tools to generate code rather than writing it manually. + +If you want a clean boundary for governance: [Gartner](https://www.gartner.com/en/newsroom/press-releases/2022-12-13-gartner-forecasts-worldwide-low-code-development-technologies-market-to-grow-20-percent-in-2023) defines a no-code application platform as a type of Low-Code Application Platform (LCAP) that “only requires text entry for formulae or simple expressions”. + +## Side-by-side comparison (what changes in practice) + +
+ +| **Dimension** | **No-code** | **Vibe coding** | +| ------------------- | ------------------------------------------ | ----------------------------------------------------------------- | +| Primary interface | Visual builder (drag-and-drop) | Natural language prompts (plus code edits when needed) | +| Best for | Standard business apps, portals, workflows | Custom products, unique logic, fast experiments | +| Speed to MVP | Fast when your idea matches the platform | Often fastest for custom logic, but can slow down in debugging | +| Flexibility ceiling | Medium: you hit platform limits | High: you can build “anything,” but you own the complexity | +| Maintenance | Lower, until you outgrow the platform | Higher: code quality and structure depend on oversight | +| Risk profile | Vendor lock-in, platform constraints | “Works today, breaks tomorrow” unless you add tests and structure | + +
+ +[Microsoft’](https://www.microsoft.com/en-us/power-platform/products/power-apps/topics/low-code-no-code/low-code-no-code-development-platforms)s framing is useful for communicating internally: low-code can require minimal coding, while “zero coding knowledge is required” for no-code app development. + +## Where no-code wins + +No-code is the best choice when “good enough and reliable” is more valuable than “custom and perfect.” + +* **You are productizing a service, not inventing new software**: You want intake forms, dashboards, a client portal, and automations without building a full engineering org. + +* **Your workflow is common**: CRMs, approvals, membership sites, internal tools, and simple marketplaces often map well to established builders. + +* **You need predictable delivery**: No-code platforms tend to be more stable because you are assembling proven components. + +* **You want non-technical teammates to own changes**: Marketing and ops can ship improvements without waiting on a developer. + +If you want a deeper look at how AI-based builders translate intent into app components, see: [how does an AI app builder work?](/articles/how-does-an-ai-app-builder-work). + +## Where vibe coding wins + +Vibe coding shines when your advantage is the workflow itself. + +* **Your business logic is the product**: Pricing engines, complex scheduling, custom data transformations, or unique user experiences usually need code. + +* **You are iterating daily**: Prompts can get you from idea to prototype very fast, then you refine. + +* **You want an exit from platform constraints**: If you expect to outgrow a builder, generating real code can reduce future migration pain. + +* **You have (or can buy) engineering oversight**: Vibe coding is powerful, but it is not magic. Someone must keep the code maintainable. + +## A simple decision framework (use this before you pick a tool) + +![Decision framework checklist](https://s3.fluidposts.com/org_ZSaKaSS2hsAE9b1JbOUpiZDTj0iFhVaL/proj_019ab784-5a74-7209-b2d6-73b9da6e148f/content-image/019bfff0-f213-75cd-a305-c4f2535bd19b__019bfff0-f213-75cd-a305-c03479aeb7ee.jpg) + +Use this checklist to make a confident call in 10 minutes. + +1. **Identify your must-be-custom pieces**: Write your “must-be-custom” list (max 5 bullets). If you cannot name the custom parts, start with no-code. +2. **Decide who maintains the product**: If it is “future you, at 11pm,” bias toward no-code or a hybrid approach. +3. **Define the first shipping target**: One core workflow, one user type, one payment or lead capture path. +4. **Choose the build path**: No-code when the workflow matches platform patterns. Vibe coding when the workflow is your moat. Hybrid when you want speed now, plus the ability to “finish properly” with experts. + +If you want the hybrid path without hiring, Quantum Byte’s approach is designed for exactly this: you can build quickly with AI, then bring in a team when you hit real-world edge cases. The pricing and plan options are on [Quantum Byte pricing guide](/packets). + +## Best tools for no-code vs vibe coding (ranked) + +The list below is optimized for solo founders and small teams who need to ship, learn, and scale without creating a maintenance nightmare. + +### 1. [Quantum Byte](https://app.quantumbyte.ai/packets) (Best overall for businesses that want speed without getting stuck) + +![Quantum Byte](https://s3.fluidposts.com/org_ZSaKaSS2hsAE9b1JbOUpiZDTj0iFhVaL/proj_019ab784-5a74-7209-b2d6-73b9da6e148f/content-image/019be653-9c45-7012-a0f9-126f0291f0f0__019be653-9c45-7012-a0f9-0fc94f46d349.webp) + +QuantumByte is the strongest “no code vs vibe coding” answer because it does not force you into a single ideology. You start with natural language to shape and generate an app, and you can still get human engineers involved when the AI ceiling shows up. + +* **Best for**: Founders who want to ship an MVP fast, but still need a path to a real, scalable product. + +* **Why it works**: You can move from idea to a working build quickly, then tighten the details instead of rebuilding from scratch. + +* **Why it’s #1**: It is the most founder-safe option on this list because it starts like vibe coding (speed and leverage), but it does not leave you stranded when the app needs “real software” discipline like data modeling, edge-case handling, and maintainable architecture. + +* **Watch-outs**: Like any AI-assisted build, you still need clear requirements. Ambiguous prompts create messy apps. + +If you want to see what you can build in days, start here: [Quantum Byte pricing guide](https://app.quantumbyte.ai/packets?utm_source=quantumbyte.ai&utm_medium=content&utm_campaign=no-code-vs-vibe-coding) + +### 2. [Bubble](https://bubble.io/) (Best no-code option for complex web apps) + +![Bubble visual programming interface](https://s3.fluidposts.com/org_ZSaKaSS2hsAE9b1JbOUpiZDTj0iFhVaL/proj_019ab784-5a74-7209-b2d6-73b9da6e148f/content-image/019bfff0-a421-72df-ba24-0659db49f6b3__019bfff0-a421-72df-ba24-0016a7aad7e8.webp) + +* **Best for**: Web applications with workflows, user accounts, and database-driven screens. + +* **Why it works**: Strong ecosystem and a builder that can handle more logic than simple site tools. + +* **Watch-outs**: You can still create “spaghetti logic” in no-code if you do not design your data model well. + +### 3. [Webflow](https://webflow.com/) (Best no-code option for marketing sites that must look premium) + +![Webflow designer interface](https://s3.fluidposts.com/org_ZSaKaSS2hsAE9b1JbOUpiZDTj0iFhVaL/proj_019ab784-5a74-7209-b2d6-73b9da6e148f/content-image/019bfff0-beeb-762a-b960-3a2715bbaafb__019bfff0-beeb-762a-b960-379509a445e3.webp) + +* **Best for**: High-converting landing pages, content sites, and brand-heavy websites. + +* **Why it works**: Design control is excellent compared to most drag-and-drop builders. + +* **Watch-outs**: Webflow is not an application backend. For app logic, you usually pair it with other tools. + +### 4. [Airtable](https://www.airtable.com/) (Best “no-code database” for internal ops) + +![Airtable database interface](https://s3.fluidposts.com/org_ZSaKaSS2hsAE9b1JbOUpiZDTj0iFhVaL/proj_019ab784-5a74-7209-b2d6-73b9da6e148f/content-image/019bfff0-d6ff-77db-8c40-3b3cc43ce82a__019bfff0-d6ff-77db-8c40-34998c0f1674.webp) + +* **Best for**: Lightweight systems of record, pipeline tracking, and operational dashboards. + +* **Why it works**: It is fast to model data and build views your team will actually use. + +* **Watch-outs**: As complexity grows, permissions and logic can become harder to manage. + +### 5. [Zapier](https://zapier.com/) (Best for quick automations between tools) + +![Zapier automation builder](https://s3.fluidposts.com/org_ZSaKaSS2hsAE9b1JbOUpiZDTj0iFhVaL/proj_019ab784-5a74-7209-b2d6-73b9da6e148f/content-image/019bfff0-9936-75c5-8f99-cc1a0fd1807c__019bfff0-9936-75c5-8f99-ca108b1ef0cb.webp) + +* **Best for**: “When X happens, do Y” workflows across SaaS tools. + +* **Why it works**: Huge integration library and quick setup. + +* **Watch-outs**: Complex multi-step automation can become expensive and hard to debug. + +### 6. [Make](https://www.make.com/) (Best for more advanced automation flows) + +![Make automation scenario builder](https://s3.fluidposts.com/org_ZSaKaSS2hsAE9b1JbOUpiZDTj0iFhVaL/proj_019ab784-5a74-7209-b2d6-73b9da6e148f/content-image/019bfff0-a55f-77a1-b6cb-e93c2b52706a__019bfff0-a55f-77a1-b6cb-e61e9d1eef71.webp) + +* **Best for**: Visual automation when you need branching, transformations, and more control. + +* **Why it works**: Great balance of power and transparency for complex automations. + +* **Watch-outs**: A flexible automation graph still needs structure, naming, and documentation. + +### 7. [Retool](https://retool.com/) (Best for internal tools that talk to real data) + +![Retool internal tool builder](https://s3.fluidposts.com/org_ZSaKaSS2hsAE9b1JbOUpiZDTj0iFhVaL/proj_019ab784-5a74-7209-b2d6-73b9da6e148f/content-image/019bfff1-2017-72ba-b39c-e4d28a808111__019bfff1-2017-72ba-b39c-e043019f4468.webp) + +* **Best for**: Admin panels, ops dashboards, and internal CRUD apps (Create, Read, Update, Delete). + +* **Why it works**: It connects to databases and APIs (Application Programming Interfaces) without you building everything from scratch. + +* **Watch-outs**: It is primarily for internal tooling, not consumer-facing design polish. + +### 8. [FlutterFlow](https://flutterflow.io/) (Best for no-code mobile apps) + +![FlutterFlow mobile builder](https://s3.fluidposts.com/org_ZSaKaSS2hsAE9b1JbOUpiZDTj0iFhVaL/proj_019ab784-5a74-7209-b2d6-73b9da6e148f/content-image/019bfff1-1fce-747d-8e95-22b56c787714__019bfff1-1fce-747d-8e95-1c4701c519eb.webp) + +* **Best for**: Mobile-first products when you want more structure than basic app builders. + +* **Why it works**: You can design screens visually and still reach mobile outcomes. + +* **Watch-outs**: Mobile app complexity grows fast. Plan your data model early. + +### 9. [Softr](https://www.softr.io/) (Best for portals and simple membership sites) + +![Softr portal builder](https://s3.fluidposts.com/org_ZSaKaSS2hsAE9b1JbOUpiZDTj0iFhVaL/proj_019ab784-5a74-7209-b2d6-73b9da6e148f/content-image/019bfff1-299c-740c-9431-14dcbdf86207__019bfff1-299c-740c-9431-1103001e5c39.webp) + +* **Best for**: Client portals, directories, and internal hubs. + +* **Why it works**: Quick to assemble common portal patterns. + +* **Watch-outs**: If you need highly custom logic, you can outgrow it. + +### 10. [Replit](https://replit.com/) (Best “vibe coding” playground for shipping experiments) + +![Replit online IDE](https://s3.fluidposts.com/org_ZSaKaSS2hsAE9b1JbOUpiZDTj0iFhVaL/proj_019ab784-5a74-7209-b2d6-73b9da6e148f/content-image/019bfff1-7049-72ab-885e-9d8413e85dd7__019bfff1-7049-72ab-885e-997d5bfc2f1f.webp) + +* **Best for**: Prototyping ideas quickly and sharing runnable demos. + +* **Why it works**: The environment is built for fast iteration. + +* **Watch-outs**: A prototype is not automatically a production system. Treat early code as disposable until proven. + +### 11. [Cursor](https://www.cursor.com/) (Best for vibe coding when you want an AI-first editor) + +![Cursor AI code editor](https://s3.fluidposts.com/org_ZSaKaSS2hsAE9b1JbOUpiZDTj0iFhVaL/proj_019ab784-5a74-7209-b2d6-73b9da6e148f/content-image/019bfff1-6b42-731a-b612-94d18de3c01b__019bfff1-6b42-731a-b612-913330b64c4d.webp) + +* **Best for**: Founders who can read code but want to write far less of it. + +* **Why it works**: The workflow is designed around prompting, refactoring, and iterating. + +* **Watch-outs**: You still need to enforce architecture, tests, and code reviews, even if you are a team of one. + +### 12. [GitHub Copilot](https://github.com/features/copilot) (Best mainstream AI assistant for developers) + +![GitHub Copilot in IDE](https://s3.fluidposts.com/org_ZSaKaSS2hsAE9b1JbOUpiZDTj0iFhVaL/proj_019ab784-5a74-7209-b2d6-73b9da6e148f/content-image/019bfff1-68d8-76ce-a088-757d4d983c4a__019bfff1-68d8-76ce-a088-7150ce7d1d00.webp) + +* **Best for**: Teams already building in GitHub workflows who want productivity gains. + +* **Why it works**: It fits into existing dev tooling. + +* **Watch-outs**: If you are not comfortable reviewing code, you can ship mistakes faster. + +### 13. [Bolt.new](https://bolt.new/) (Best for prompt-to-app demos you can iterate fast) + +![Bolt.new app generator](https://s3.fluidposts.com/org_ZSaKaSS2hsAE9b1JbOUpiZDTj0iFhVaL/proj_019ab784-5a74-7209-b2d6-73b9da6e148f/content-image/019bfff1-c36f-73cc-9984-b48fc726ad9b__019bfff1-c36f-73cc-9984-b1567267c1c9.webp) + +* **Best for**: Rapid “show me something working” prototypes. + +* **Why it works**: The loop from prompt to runnable output is tight. + +* **Watch-outs**: Your long-term maintainability depends on how you transition from demo to real repo. + +### 14. [Lovable](https://lovable.dev/) (Best for prompt-driven product scaffolding) + +![Lovable product builder](https://s3.fluidposts.com/org_ZSaKaSS2hsAE9b1JbOUpiZDTj0iFhVaL/proj_019ab784-5a74-7209-b2d6-73b9da6e148f/content-image/019bfff1-c140-72e1-b2c9-aae29af22189__019bfff1-c140-72e1-b2c9-a4d0a0a8bf64.webp) + +* **Best for**: Founders who want a fast starting point for a product UI and flows. + +* **Why it works**: It pushes you from concept to structure quickly. + +* **Watch-outs**: Plan your “handoff moment,” when you add real engineering discipline. + +## Common pitfalls (and how to avoid them) + +* **Building before you pick a single success metric**: Decide what “working” means first (for example: booked calls, paid trials, or time saved). Without this, both no-code and vibe coding turn into busywork. + +* **Confusing a prototype with a product**: A prototype proves demand. A product needs reliability, support, and a plan for change. + +* **Letting tools decide your architecture**: Your data model (what you store and how it relates) is the foundation. Design it early or you will rebuild later. + +* **Ignoring the “who maintains this” question**: If the answer is unclear, choose a platform with stronger guardrails or a hybrid path. + +If your roadmap includes selling software under your own brand, this guide is a good next read: [White label app builder: sell apps under your brand](/articles/white-label-app-builder-sell-under-your-brand). + +## The wrap-up: how to choose and ship without regret + +You now have a clean way to think about no code vs vibe coding: + +* **No-code**: Best when your workflow is common and you want stability. + +* **Vibe coding**: Best when your workflow is your competitive edge and you can handle the maintenance. + +* **Hybrid**: The practical middle for most serious businesses: move fast today, keep a path to “done right.” + +If you want that hybrid path without hiring a full team, start by mapping your MVP workflow, then build it in QuantumByte’s AI builder. When you hit the limits, you have a clear route to expert help instead of starting over. Get started here: [Quantum Byte pricing guide](https://app.quantumbyte.ai/packets?utm_source=quantumbyte.ai&utm_medium=content&utm_campaign=no-code-vs-vibe-coding) + +### Frequently Asked Questions + +#### Is vibe coding the same as no-code? + +No. No-code is primarily visual building inside a platform. Vibe coding is prompting AI to generate code, which you (or your team) may need to maintain over time. + +#### Which is faster: no-code or vibe coding? + +It depends. No-code is often faster when your app matches standard patterns. Vibe coding can be faster when you need custom logic, but it can slow down when you hit bugs and edge cases. + +#### Do I need to know programming to use vibe coding? + +You can start without it, but you will move further if you can review and reason about code. Without that skill, you risk shipping fragile software. + +#### What should I choose for a client portal? + +Usually no-code first. Portals tend to be standard: login, profiles, documents, payments, and messages. If you have unique workflows, a hybrid approach is often the safest. + +#### When should I consider an enterprise-grade path? + +When compliance, governance, or cross-department workflows become the main problem. If that is your situation, see [QuantumByte Enterprise](/enterprise/).`, + }, + + { + id: "04-long-form-construction-change-order-tracking", + description: + "Long-form opinion piece on construction change order tracking. Tests depth, argumentation quality, and strong POV.", + input: { + primaryKeyword: "construction change order tracking", + title: "Mastering Construction Change Order Tracking for Profit", + articleType: "long-form-opinion", + notes: null, + outline: null, + project: QuantumByteProject, + }, + expectations: { + minWordCount: 1800, + maxWordCount: 2800, + }, + referenceOutput: `Change orders go sideways when details slip during pricing and approval. Construction change order tracking closes that gap by turning every change into a visible, approved, and auditable workflow. + +If you are still chasing email threads, paper tickets, or half-updated spreadsheets, you are working harder than you should. A clean system gives you speed, clarity, and fewer disputes. + +## What construction change order tracking really means + +Construction change order tracking is the system you use to capture, price, approve, issue, and close contract changes, with a clear trail from the first request to final sign-off. + +In standard construction contract language, a change order is the written document that implements an agreed change to the work, including updates to price and time. The American Institute of Architects (AIA) explains that [AIA Document G701 is used for implementing changes in the work agreed to by the owner, contractor, and architect](https://help.aiacontracts.com/hc/en-us/articles/1500009322061-Instructions-G701-2017-Change-Order), including changes to contract sum and contract time in one executed document. + +A practical tracking system answers four questions for every change: + +* **Origin**: Who requested it, and why? + +* **Scope**: What is changing in scope? + +* **Impact**: What does it do to cost and schedule? + +* **Approval**: Who approved it, and when? + +## Why change orders become profit and schedule killers + +Most teams fail change management in predictable ways. Fixing these is often more important than buying new software. + +* **Scope is vague**: If the description reads like "add outlet in room," your field and accounting teams will interpret it differently, and your closeout will be a fight. + +* **Approval happens after work starts**: Once the crew has started, you lose leverage. You also lose clarity on what was "base contract" versus "extra." + +* **Cost detail is missing**: Lump sums without backup make owners suspicious and slow approvals. + +* **Schedule impacts are hand-wavy**: When days are not tracked at the change level, the project baseline becomes fiction. + +* **Documents are scattered**: If drawings, photos, Requests for Information (RFI), emails, and proposals are not linked to the change, the "why" disappears. + +The goal is fast decisions with clean documentation. + +## The change order workflow that stays clean under pressure + +![Illustration for the change order workflow that stays clean under pressure in Mastering Construction Change Order Tracking for Profit](https://s3.fluidposts.com/org_ZSaKaSS2hsAE9b1JbOUpiZDTj0iFhVaL/proj_019ab784-5a74-7209-b2d6-73b9da6e148f/content-image/019c650a-8d19-7382-a49e-1f27a3447080__019c650a-8d19-7382-a49e-1a937076b35e.webp) + +A reliable construction change order tracking workflow is simple enough to run in the field and strict enough to survive a dispute. + +1. **Capture the issue in the field**: Create a change request the moment you see a scope shift, then attach enough evidence that someone off-site can understand it. + + * **Record**: Create a change request record while the details are fresh. + + * **Evidence**: Attach photos, marked-up drawings, and location info so the request stands on its own. + +2. **Log the RFI when scope is unclear**: Use an RFI to remove ambiguity before you price the work or send crews back for rework. + + * **Purpose**: Use RFIs to clarify design intent before you price or build. + + * **Traceability**: Link the RFI to the change request so the answer becomes part of the audit trail. + +3. **Build a priced scope with backup**: Price the change in a way that is easy to audit, not just easy to submit. + + * **Breakdown**: Itemize labor, materials, equipment, subcontractor quotes, and markups. + + * **Assumptions**: Note assumptions and exclusions so reviewers know exactly what they are approving. + +4. **Route approvals in the right order**: Follow the chain your contract expects so the approval is defensible later. + + * **Sequence**: Follow the agreed chain, often subcontractor to general contractor (GC) to owner/architect. + + * **Proof**: Capture who approved, when, and under what terms. + +5. **Issue the formal change order**: Convert the approved request into the executed contract modification document that controls billing and time. + + * **Conversion**: Turn an approved request into the executed contract modification document. + +6. **Update budget and schedule baselines**: Treat cost and time impacts as first-class data, not side notes. + + * **Rollups**: Ensure your change log rolls up totals for approved and pending impacts. + + * **Visibility**: Make schedule impact visible at both the project level and the individual change level. + +7. **Execute work and close it out**: Verify completion, reconcile final costs, and lock the record so it stays clean through closeout. + + * **Verification**: Confirm completion, collect tickets, and finalize costs. + + * **Locking**: Close and lock the record so it cannot be quietly rewritten later. + +This lines up with widely used contract language. For example, [ConsensusDocs](https://ipf.msu.edu/sites/default/files/2018-08/CS_FED_C200_CONSENSUSDOCS_200.PDF) defines a change order as a written order signed after execution of the agreement indicating changes in the scope of the work, the contract price, or contract time. + +## What to track on every change order + +If you want fewer arguments and faster approvals, track the same core data every time. + +### Required fields for a change order log + +Use this as your minimum dataset. + +
+ +| **Field** | **What it captures** | **Why it matters** | +| ---------------------- | ---------------------------------------------------------------------- | ----------------------------------------- | +| Change ID | Unique number (CO-001, COR-014, etc.) | Prevents duplicates and missing paperwork | +| Project | Project name / job number | Enables rollups and reporting | +| Status | Draft, priced, submitted, approved, executed, closed | Shows the bottleneck instantly | +| Requested by | Owner, architect, GC, subcontractor, field | Helps manage patterns and accountability | +| Scope description | What is changing | Reduces disputes and rework | +| Reason code | Design change, unforeseen condition, owner request, code, coordination | Makes trend reporting possible | +| Cost impact | Labor, material, equipment, markup, tax | Speeds review and reduces friction | +| Schedule impact | Calendar days, milestone affected | Keeps your baseline honest | +| Approvers + timestamps | Who approved and when | Creates an audit trail | +| Attachments | RFI, sketches, photos, proposal, signed PDF | One record, complete context | + +
+ +### Optional fields that unlock better control + +* **Not-to-exceed value**: Useful when you need to start quickly but still want a hard cap. + +* **Cost code mapping**: Keeps job costing clean and reduces rework when accounting needs the numbers. + +* **Location tags**: Adds useful context (building, floor, room, area, gridline) and improves reporting on repeated issues. + +## Best practices that reduce disputes + +These are habits, not software features. Good software just makes them automatic. + +* **Get it in writing early**: Negotiate and approve changes before authorizing work when possible. The [Washington State Auditor's Office](https://sao.wa.gov/sites/default/files/2023-05/Change-Order-Best-practices.pdf) advises teams to retain supporting documentation from the first request through the executed change order and approve changes before work begins when possible. + +* **Standardize the pricing format**: Use the same template every time so reviewers do not have to decode your proposal. + +* **Force clear scope language**: Include what is included and excluded. If it touches finish work, say it. + +* **Track schedule like money**: If a change adds days, capture the reason and the affected milestone, not just "+3 days." + +* **Keep one source of truth**: One system should hold the log, approvals, and attachments. + +## Tools for construction change order tracking + +You have three realistic paths. The right choice depends on project volume, approval complexity, and how much you want to automate. + +
+ +| **Option** | **Best for** | **Where it breaks** | +| ------------------------------------------------------------------------------ | ----------------------------------------------- | ------------------------------------------------------- | +| Spreadsheet change order log | Low volume, simple jobs, one decision maker | Version control, missing attachments, weak audit trail | +| Construction PM platforms (Procore, Autodesk Construction Cloud, Buildertrend) | Teams that want a full suite today | Cost, rigid workflows, "you do it their way" data model | +| Custom change order tracker | Teams with a unique workflow or reporting needs | Requires setup, and you need an owner for the process | + +
+ +If you already live in Procore or Autodesk Construction Cloud, you can make it work. But if your pain is "our process is different," a custom tracker often wins because it matches how you operate. + +If you want to build around your exact approval flow, [Quantum Byte's approach](https://quantumbyte.ai/) is built for rapid prototyping. Their workflow is designed to get a working internal tool in front of your team quickly, then extend it when real-world edge cases show up. If approvals are your biggest bottleneck, it also helps to borrow patterns from dedicated [approval workflow software](https://quantumbyte.ai/articles/approval-workflow-software). + +## A simple template you can adopt this week + +If you do nothing else, implement these three templates. + +### 1) Change request intake template + +* **Summary**: A short description of the issue or request. + +* **Detailed scope**: What is changing, where it is located, and what success looks like. + +* **Reason code**: The category (design change, unforeseen condition, owner request, code, coordination) you will report on later. + +* **Photos or sketch**: Visual proof that removes ambiguity and speeds review. + +* **Location**: Building, floor, room, area, or gridline so the field team can act fast. + +* **Requested by**: The person or party initiating the change (owner, architect, GC, subcontractor, field). + +* **Needed-by date**: When a decision is required to avoid delay or rework. + +### 2) Pricing breakdown template + +* **Labor**: Roles, hours, and rates, plus any overtime assumptions. + +* **Materials**: Line items with quantities and unit costs so the math is reviewable. + +* **Equipment**: Rentals, lifts, or specialty tools tied to the change. + +* **Sub quotes**: Attached subcontractor proposals and any comparison notes. + +* **Markups**: The agreed percentages and rules from your contract. + +* **Assumptions and exclusions**: What is included, what is excluded, and what conditions could change the price. + +### 3) Approval and issuance template + +* **Approver chain**: Names, roles, and timestamps so the path is defensible later. + +* **Approval conditions**: Not-to-exceed limits, schedule notes, or partial approvals that change how the work is executed. + +* **Issued document**: The signed PDF or executed form that actually modifies the contract. + +Even if you stay in spreadsheets, forcing these three templates will tighten your process fast. + +## How to build a custom change order tracking system without a huge budget + +A custom system sounds heavy until you break it into small, valuable pieces. + +### Step 1: Decide what "done" looks like + +Define outcomes in plain language: + +* **One live log**: Everyone sees the same status, without version fights. + +* **No missing backup**: Every change includes photos, RFIs, and pricing detail. + +* **Approvals are trackable**: You can point to who approved, when, and what they approved. + +* **Budget rolls up automatically**: Approved and pending totals update without manual math. + +### Step 2: Model your workflow + +Keep it tight. Most teams need 6 to 8 statuses: + +* **Draft**: Captured but not ready for pricing review. + +* **Priced**: Scope is clear and costs are built with backup. + +* **Submitted**: Formally sent for review and approval. + +* **Returned**: Sent back for clarification, revisions, or more detail. + +* **Approved**: Accepted with cost and time impacts agreed. + +* **Executed**: Issued as a contract modification and released for field execution. + +* **Closed**: Verified complete, final costs reconciled, record locked. + +### Step 3: Add rules that prevent chaos + +* **Required fields per status**: You cannot submit without scope and pricing. + +* **Permission by role**: Subs can draft; GC can submit; owner can approve. + +* **Audit trail**: Track edits, approvals, and file uploads. + +### Step 4: Start with a minimum viable product (MVP) + +Minimum viable means it solves the problem today, not every problem forever. + +A minimum viable tool focuses on solving immediate problems while remaining flexible for future growth. + +A strong MVP for construction change order tracking includes: + +* **Change log**: A searchable list with filters by status, originator, and cost code. + +* **Mobile intake**: A simple form your field team will actually use on-site. + +* **Attachments**: Photo and file uploads that stay tied to the change. + +* **Approval routing**: A defined chain with timestamps and comments. + +* **PDF export**: A clean output you can send for signatures and store for closeout. + +If you want to move quickly, Quantum Byte's builder is a practical way to test an internal change order tracker before you commit to a long implementation. You can start [building a change order tracker](https://app.quantumbyte.ai/packets?utm_source=quantumbyte.ai&utm_medium=article&utm_campaign=construction_change_order_tracking&utm_content=cta_build_tracker) using Quantum Byte. + +## Reporting that helps you manage, not just record + +Tracking is only half the win. The other half is turning your log into decisions. + +### The dashboards worth building + +* **Aging report**: Changes sitting in "submitted" longer than your target window. + +* **Pending exposure**: Total pending cost and time impact, separate from approved. + +* **Reason code trends**: Design changes versus unforeseen conditions, tracked over time. + +* **Top originators**: Where requests are coming from, so you can prevent repeat issues. + +### Weekly operating rhythm + +* **Review**: Go through submitted and returned changes and assign next actions. + +* **Commit**: Agree on what needs pricing this week and who owns each item. + +* **Close**: Close out executed changes so the log stays honest. + +This aligns with general change control thinking in project management. The Project Management Institute (PMI) describes [change control as a process that justifies or rejects a change request to limit spurious changes and prevent cost overruns or missed milestones](https://www.pmi.org/learning/library/definition-change-control-project-management-8030). + +## Integrations that make change orders faster + +Integrations are where you turn admin into automation. + +* **Accounting**: Sync cost codes and committed costs so you stop double entry. + +* **Document management**: Link RFIs, drawings, and photos to each change. + +* **Scheduling**: Push approved time impacts into your schedule system. + +If you are operating at a higher scale and need governance, single sign-on (SSO), or deeper controls, Quantum Byte's [enterprise offering](https://quantumbyte.ai/enterprise) is built to tailor customizations for businesses. + +## Common mistakes to avoid + +These failures show up in almost every "we need a better change order process" cleanup. + +* **Letting the field bypass the log**: If the crew can do change work without a record, you will lose revenue. + +* **Treating approvals as verbal**: Verbal approvals turn into "I never agreed to that" later. + +* **Mixing change requests and change orders**: Track both, but keep them distinct. A request is a proposal. A change order is executed. + +* **Ignoring closeout**: Old "executed but not closed" items hide final costs. + +## Picking your next step + +If you want the fastest path to cleaner construction change order tracking, choose the smallest step that creates leverage. + +* **If you have low volume**: Standardize templates and enforce required fields. + +* **If you have medium volume**: Move to a single system of record with attachments and approvals. + +* **If you have high volume or unique workflows**: Build a custom tracker that matches your process and reporting. + +## What you now have in your toolkit + +You now have a practical definition of construction change order tracking, a field-tested workflow, a data checklist for every change, documentation best practices, and a clear way to choose between spreadsheets, construction platforms, or a custom app. You also have a blueprint for building an MVP tracker your team will actually use. + +## Frequently Asked Questions + +### What is the difference between a change request and a change order? + +A change request is a proposed change that is being documented and priced. A change order is the executed contract modification with approved cost and time impacts. + +### What should be included in a change order log? + +At minimum: a unique ID, status, scope description, reason code, cost impact, schedule impact, approver names and timestamps, and all supporting attachments (RFIs, photos, proposals, signed documents). + +### Who should approve construction change orders? + +It depends on your contract, but common approval paths include subcontractor and GC review, then owner and architect approval. The key is that the approver chain is defined upfront and recorded in the system. + +### Can I track change orders in Excel or Google Sheets? + +Yes for low volume, but it breaks down when you need attachment control, audit trails, approvals, and reliable version history. That is when a dedicated platform or a custom tracker becomes worth it. + +### When does a custom tracker make more sense than Procore or Autodesk Construction Cloud? + +A custom tracker makes sense when your workflow is unique, your reporting needs are specific, or you want a lightweight tool your team can adopt fast without paying for a full suite. It also helps when you need to connect change orders to your internal processes in a way off-the-shelf tools do not support. +`, + }, + // { + // id: "05-local-seo-plumber", + // description: + // "How-to guide for a local business niche. Tests ability to write for non-technical audiences and local SEO signals.", + // input: { + // primaryKeyword: "plumber SEO", + // title: "SEO for Plumbers: How to Get More Calls from Google in 2026", + // articleType: "how-to", + // notes: + // "Audience: plumbing business owners with zero SEO knowledge. Focus on Google Business Profile, local keywords, reviews, and basic on-page SEO. Keep language simple.", + // outline: null, + // project: QuantumByteProject, + // }, + // expectations: { + // minWordCount: 1200, + // maxWordCount: 2000, + // }, + // referenceOutput: null, + // }, +]; diff --git a/packages/api-seo/src/eval/content/score.ts b/packages/api-seo/src/eval/content/score.ts new file mode 100644 index 000000000..1e80bc8e1 --- /dev/null +++ b/packages/api-seo/src/eval/content/score.ts @@ -0,0 +1,442 @@ +/** + * Content evaluation scorer. + * + * Combines deterministic checks (keyword placement, word count, readability, + * heading structure) with LLM-as-a-Judge (G-Eval pattern) for subjective + * quality dimensions. + * + * The deterministic scorers run instantly. The LLM judge uses the same + * Gemini Flash model used in the writer's review loop, keeping costs low. + */ +import { google } from "@ai-sdk/google"; +import { generateText, jsonSchema, Output } from "ai"; +import { type } from "arktype"; +import type { ContentFixture, EvalResult, ScoreDimension } from "../types"; + +// --------------------------------------------------------------------------- +// Deterministic scorers +// --------------------------------------------------------------------------- + +function countWords(text: string): number { + return text + .replace(/[#*_`~[\]()>|!-]/g, " ") + .split(/\s+/) + .filter((w) => w.length > 0).length; +} + +function scoreWordCount( + markdown: string, + expectations: ContentFixture["expectations"], +): ScoreDimension { + const words = countWords(markdown); + const { minWordCount, maxWordCount } = expectations; + + let score: number; + if (words >= minWordCount && words <= maxWordCount) { + score = 10; + } else if (words < minWordCount) { + const ratio = words / minWordCount; + score = Math.max(1, Math.round(ratio * 10)); + } else { + // Over max -- mild penalty + const overRatio = words / maxWordCount; + score = Math.max(3, Math.round(10 / overRatio)); + } + + return { + name: "Word Count", + score, + weight: 0.1, + feedback: `${words} words (target: ${minWordCount}-${maxWordCount})`, + }; +} + +function scoreKeywordPlacement( + markdown: string, + primaryKeyword: string, + title: string, +): ScoreDimension { + const kw = primaryKeyword.toLowerCase(); + const titleLower = title.toLowerCase(); + const firstParagraphLower = extractFirstParagraph(markdown).toLowerCase(); + const descriptionLower = extractDescription(markdown)?.toLowerCase() ?? ""; + + const checks = { + inTitle: false, + inFirstParagraph: false, + inDescription: false, + }; + + if (titleLower.includes(kw)) { + checks.inTitle = true; + } + + if (firstParagraphLower.includes(kw)) { + checks.inFirstParagraph = true; + } + + if (descriptionLower.includes(kw)) { + checks.inDescription = true; + } + + const passed = Object.values(checks).filter(Boolean).length; + const score = Math.max(1, Math.round((passed / 3) * 10)); + + const failedChecks = Object.entries(checks) + .filter(([, v]) => !v) + .map(([k]) => k); + + return { + name: "Keyword Placement", + score, + weight: 0.1, + feedback: + failedChecks.length === 0 + ? "Primary keyword is present in title, first paragraph, and description." + : `Missing: ${failedChecks.join(", ")}`, + }; +} + +function scoreHeadingStructure(markdown: string): ScoreDimension { + const lines = markdown.split("\n"); + const headings = lines.filter((l) => /^#{1,6}\s/.test(l)); + + const checks = { + hasH2: false, + properHierarchy: true, + }; + + const h2Count = headings.filter((h) => /^##\s/.test(h)).length; + + checks.hasH2 = h2Count >= 1; + + // Check hierarchy: no jumps (H1 -> H3 without H2) + let prevLevel = 0; + for (const h of headings) { + const match = h.match(/^(#{1,6})\s/); + if (!match?.[1]) continue; + const level = match[1].length; + if (level > prevLevel + 1 && prevLevel > 0) { + checks.properHierarchy = false; + break; + } + prevLevel = level; + } + + const passed = Object.values(checks).filter(Boolean).length; + const score = Math.max(1, Math.round((passed / 2) * 10)); + + const failedChecks = Object.entries(checks) + .filter(([, v]) => !v) + .map(([k]) => k); + + return { + name: "Heading Structure", + score, + weight: 0.05, + feedback: + failedChecks.length === 0 + ? `${headings.length} headings, proper hierarchy` + : `Issues: ${failedChecks.join(", ")}`, + }; +} + +function extractFirstParagraph(markdown: string): string { + const blocks = markdown + .split(/\n\s*\n/) + .map((block) => block.trim()) + .filter((block) => block.length > 0); + + for (const block of blocks) { + if (/^#{1,6}\s/.test(block)) continue; + if (/^[-*+]\s/.test(block)) continue; + if (/^\d+\.\s/.test(block)) continue; + if (/^>\s/.test(block)) continue; + if (/^```/.test(block)) continue; + if (/^\|/.test(block)) continue; + return block; + } + + return ""; +} + +function extractDescription(markdown: string): string | null { + const frontmatterMatch = markdown.match( + /^---\s*\n([\s\S]*?)\n---(?:\n|$)/, + )?.[1]; + if (frontmatterMatch) { + const frontmatterDescription = frontmatterMatch.match( + /^\s*description:\s*(.+)$/im, + )?.[1]; + if (frontmatterDescription) { + return frontmatterDescription.trim().replace(/^["']|["']$/g, ""); + } + } + + const inlineDescriptionMatch = markdown.match( + /^\s*(meta\s+description|description)\s*:\s*(.+)$/im, + )?.[2]; + + if (inlineDescriptionMatch) { + return inlineDescriptionMatch.trim().replace(/^["']|["']$/g, ""); + } + + return null; +} + +function scoreReadability(markdown: string): ScoreDimension { + // Strip markdown formatting for text analysis + const text = markdown + .replace(/^#{1,6}\s.*$/gm, "") + .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") + .replace(/[*_`~]/g, "") + .replace(/!\[.*?\]\(.*?\)/g, "") + .trim(); + + const sentences = text.split(/[.!?]+/).filter((s) => s.trim().length > 10); + const paragraphs = text.split(/\n\n+/).filter((p) => p.trim().length > 0); + + const avgSentenceLength = + sentences.length > 0 + ? sentences.reduce((sum, s) => sum + s.trim().split(/\s+/).length, 0) / + sentences.length + : 0; + + const avgParagraphSentences = + paragraphs.length > 0 ? sentences.length / paragraphs.length : 0; + + // Targets: avg sentence 12-20 words, avg paragraph 2-5 sentences + let score = 10; + if (avgSentenceLength > 25) score -= 3; + else if (avgSentenceLength > 20) score -= 1; + if (avgSentenceLength < 8) score -= 2; + + if (avgParagraphSentences > 6) score -= 2; + else if (avgParagraphSentences > 5) score -= 1; + + score = Math.max(1, Math.min(10, score)); + + return { + name: "Readability", + score, + weight: 0.05, + feedback: `Avg sentence: ${avgSentenceLength.toFixed(1)} words. Avg paragraph: ${avgParagraphSentences.toFixed(1)} sentences.`, + }; +} + +// --------------------------------------------------------------------------- +// LLM-as-Judge (G-Eval pattern) +// --------------------------------------------------------------------------- + +const llmJudgeResultSchema = type({ + dimensions: type({ + name: "string", + score: "1 <= number <= 10", + feedback: "string", + }).array(), +}); + +type LlmJudgeResult = typeof llmJudgeResultSchema.infer; + +async function llmJudgeContent(args: { + article: string; + fixture: ContentFixture; +}): Promise { + const { article, fixture } = args; + + const prompt = `You are a senior SEO content evaluator. Score the following article on the dimensions below. For each dimension, provide a score (1-10) and specific feedback. + +
+${article} +
+ + +- Primary keyword: ${fixture.input.primaryKeyword} +- Title: ${fixture.input.title} +- Article type: ${fixture.input.articleType} +- Target website: ${fixture.input.project.websiteUrl} +- Business: ${fixture.input.project.businessBackground} +- Brand voice: ${fixture.input.project.brandVoice} +- Custom instructions: ${fixture.input.project.customInstructions} +${fixture.input.notes ? `- Notes: ${fixture.input.notes}` : ""} + + + +Apply these writer standards while scoring all six dimensions: +- Information gain: each major section should add something beyond generic SERP summaries. +- Search-intent fit and concise delivery (no filler for arbitrary word count). +- GEO structure: clear direct answer early, descriptive H2/H3 structure, extractable formats (lists/tables/FAQ where appropriate), entity-rich language. +- E-E-A-T signals: specific claims, concrete details, acknowledgment of tradeoffs, credible sourcing. +- Primary keyword discipline: title, opening sentence/paragraph, and meta description should all align naturally. +- Follow project custom instructions (including CTA guidance when relevant). + +1. **Content Depth & Expertise**: Demonstrates concrete expertise, non-generic analysis, and credible evidence. +2. **Natural Keyword Integration**: Uses primary keyword and semantic variants naturally without stuffing, while preserving readability. +3. **Coherence & Flow**: Reads smoothly with logical section sequencing and clear transitions. +4. **Brand Voice Adherence**: Matches brand voice and project custom instructions consistently. +5. **Actionability & Unique Value**: Gives specific next steps and tangible value, not abstract advice. +6. **Article Type Compliance**: Matches the required structure and conventions for ${fixture.input.articleType}. + + + +Respond with a JSON object only (no markdown code fences, no explanation): +{ + "dimensions": [ + { "name": "Content Depth & Expertise", "score": <1-10>, "feedback": "" }, + { "name": "Natural Keyword Integration", "score": <1-10>, "feedback": "" }, + { "name": "Coherence & Flow", "score": <1-10>, "feedback": "" }, + { "name": "Brand Voice Adherence", "score": <1-10>, "feedback": "" }, + { "name": "Actionability & Unique Value", "score": <1-10>, "feedback": "" }, + { "name": "Article Type Compliance", "score": <1-10>, "feedback": "" } + ] +} +`; + + const result = await generateText({ + model: google("gemini-3-flash-preview"), + output: Output.object({ + schema: llmJudgeResultSchema, + }), + prompt, + }); + + return result.output; +} + +// --------------------------------------------------------------------------- +// Pairwise comparison +// --------------------------------------------------------------------------- + +async function pairwiseCompare(args: { + current: string; + reference: string; + fixture: ContentFixture; +}): Promise<{ winner: "current" | "reference" | "tie"; reasoning: string }> { + const { current, reference, fixture } = args; + + const prompt = `You are comparing two versions of an SEO article. Determine which version is better overall. + + +- Primary keyword: ${fixture.input.primaryKeyword} +- Article type: ${fixture.input.articleType} +- Brand voice: ${fixture.input.project.brandVoice} + + + +${current} + + + +${reference} + + +Consider: content quality, SEO optimization, readability, brand voice adherence, and actionability.`; + + const result = await generateText({ + model: google("gemini-3-flash-preview"), + output: Output.object({ + schema: jsonSchema<{ + winner: "a" | "b" | "tie"; + reasoning: string; + }>({ + type: "object", + additionalProperties: false, + required: ["winner", "reasoning"], + properties: { + winner: { + type: "string", + enum: ["a", "b", "tie"], + }, + reasoning: { + type: "string", + }, + }, + }), + }), + prompt, + }); + + const parsed = result.output; + return { + winner: + parsed.winner === "a" + ? "current" + : parsed.winner === "b" + ? "reference" + : "tie", + reasoning: parsed.reasoning, + }; +} + +// --------------------------------------------------------------------------- +// Main scorer +// --------------------------------------------------------------------------- + +/** LLM-judged dimension weights (must sum with deterministic weights to 1.0) */ +const LLM_DIMENSION_WEIGHTS: Record = { + "Content Depth & Expertise": 0.2, + "Natural Keyword Integration": 0.1, + "Coherence & Flow": 0.1, + "Brand Voice Adherence": 0.1, + "Actionability & Unique Value": 0.1, + "Article Type Compliance": 0.1, +}; + +export async function scoreContent(args: { + output: string; + fixture: ContentFixture; + durationMs: number; +}): Promise> { + const { output, fixture, durationMs } = args; + + // Run deterministic scorers + const deterministicDimensions: ScoreDimension[] = [ + scoreWordCount(output, fixture.expectations), + scoreKeywordPlacement( + output, + fixture.input.primaryKeyword, + fixture.input.title, + ), + scoreHeadingStructure(output), + scoreReadability(output), + ]; + + // Run LLM judge + const llmResult = await llmJudgeContent({ article: output, fixture }); + + const llmDimensions: ScoreDimension[] = llmResult.dimensions.map((d) => ({ + name: d.name, + score: d.score, + weight: LLM_DIMENSION_WEIGHTS[d.name] ?? 0.1, + feedback: d.feedback, + })); + + const allDimensions = [...deterministicDimensions, ...llmDimensions]; + + // Weighted overall score + const totalWeight = allDimensions.reduce((sum, d) => sum + d.weight, 0); + const overallScore = + allDimensions.reduce((sum, d) => sum + d.score * d.weight, 0) / totalWeight; + + // Pairwise comparison against reference + let pairwiseVsReference: EvalResult["pairwiseVsReference"] = + null; + if (fixture.referenceOutput) { + pairwiseVsReference = await pairwiseCompare({ + current: output, + reference: fixture.referenceOutput, + fixture, + }); + } + + return { + fixtureId: fixture.id, + timestamp: new Date().toISOString(), + output, + dimensions: allDimensions, + overallScore: Math.round(overallScore * 100) / 100, + pairwiseVsReference, + durationMs, + fixture, + }; +} diff --git a/packages/api-seo/src/eval/strategy/fixtures/index.ts b/packages/api-seo/src/eval/strategy/fixtures/index.ts new file mode 100644 index 000000000..1cfd34173 --- /dev/null +++ b/packages/api-seo/src/eval/strategy/fixtures/index.ts @@ -0,0 +1,64 @@ +import type { StrategyFixture } from "../../types"; + +/** + * 3 strategy fixtures covering different business types and strategy goals. + * + * Each fixture simulates a realistic site that needs an SEO strategy. + * The `referenceOutput` starts null and should be populated with the + * current best strategy output after an initial run you're satisfied with. + */ +export const strategyFixtures: StrategyFixture[] = [ + { + id: "01-saas-market-entry", + description: + "New B2B SaaS entering a competitive market. Tests keyword research depth, content pillar logic, and realistic prioritization given low domain authority.", + input: { + instructions: + "Generate 2 strategy suggestions for a new B2B SaaS entering the project management space. The site has no existing content and a domain authority of approximately 10. Budget allows for 8 articles per month. Focus on strategies that can show results within 3-6 months, targeting long-tail keywords first before going after head terms.", + site: { + name: "TaskPilot", + websiteUrl: "https://taskpilot.app", + businessBackground: + "TaskPilot is a new project management tool for remote engineering teams (5-20 people). Launched 3 months ago. Key features: async standup automation, sprint planning with AI estimates, GitHub/GitLab integration. No blog content yet. Competing against Jira, Linear, Shortcut. Currently 200 monthly organic visitors from branded searches only. Based in Austin, TX. Funding: bootstrapped.", + industry: "B2B SaaS / Project Management", + }, + }, + referenceOutput: null, + }, + + { + id: "02-local-service-expansion", + description: + "Established local service business expanding to nearby cities. Tests local SEO strategy, service area targeting, and Google Business Profile optimization recommendations.", + input: { + instructions: + "Generate 2 strategy suggestions for a plumbing company that dominates their home city (Austin) but wants to expand to 3 nearby cities (Round Rock, Cedar Park, Georgetown). They have an existing blog with 30 articles but it has been neglected for 6 months. The site gets about 2,000 monthly organic visits, mostly from Austin-related queries. Focus on strategies that balance maintaining Austin rankings while expanding to new service areas.", + site: { + name: "Austin Pro Plumbing", + websiteUrl: "https://austinproplumbing.com", + businessBackground: + "Austin Pro Plumbing has been operating for 12 years. 15 trucks, serves residential and commercial clients. Top 3 in Google Maps for 'plumber Austin' and related terms. Existing blog covers common plumbing topics but stopped publishing 6 months ago. Has Google Business Profile with 450+ reviews (4.8 stars). Wants to double service area without losing current rankings. Budget: can add 4-6 articles per month.", + industry: "Local Services / Plumbing", + }, + }, + referenceOutput: null, + }, + + { + id: "03-ecommerce-content-refresh", + description: + "Mid-size ecommerce site with declining organic traffic. Tests content audit skills, decay identification, and refresh prioritization strategy.", + input: { + instructions: + "Generate 2 strategy suggestions for an ecommerce site that has seen a 30% decline in organic traffic over the last 6 months. The site has 150+ blog posts and 500+ product pages. Many blog posts are 2-3 years old and have never been updated. The site used to rank well for buyer-intent keywords but has been overtaken by competitors who publish more frequently. Focus on strategies that prioritize quick wins from refreshing existing content while also building new topical authority in underserved areas.", + site: { + name: "GearHub", + websiteUrl: "https://gearhub.com", + businessBackground: + "GearHub is a direct-to-consumer outdoor gear and equipment retailer. Founded 2019. Sells camping, hiking, climbing, and cycling gear. 500+ products, 150+ blog posts (buying guides, gear reviews, how-to articles). Domain authority approximately 45. Traffic declined from 80K to 55K monthly organic visits in the last 6 months after a Google core update. Strongest categories: camping gear and hiking boots. Weakest: cycling gear (new category, minimal content). Main competitors: REI, Backcountry, OutdoorGearLab. Budget: 8-12 articles per month.", + industry: "Ecommerce / Outdoor Gear", + }, + }, + referenceOutput: null, + }, +]; diff --git a/packages/api-seo/src/eval/strategy/score.ts b/packages/api-seo/src/eval/strategy/score.ts new file mode 100644 index 000000000..b9989a81c --- /dev/null +++ b/packages/api-seo/src/eval/strategy/score.ts @@ -0,0 +1,233 @@ +/** + * Strategy evaluation scorer. + * + * Strategy quality is inherently subjective, so this is almost entirely + * LLM-as-a-Judge (G-Eval pattern). Uses Gemini Flash to keep costs low. + */ +import { google } from "@ai-sdk/google"; +import { generateText } from "ai"; +import type { EvalResult, ScoreDimension, StrategyFixture } from "../types"; + +// --------------------------------------------------------------------------- +// LLM-as-Judge +// --------------------------------------------------------------------------- + +interface LlmJudgeResult { + dimensions: { + name: string; + score: number; + feedback: string; + }[]; +} + +async function llmJudgeStrategy(args: { + strategyOutput: string; + fixture: StrategyFixture; +}): Promise { + const { strategyOutput, fixture } = args; + + const prompt = `You are a senior SEO strategist evaluating a strategy recommendation. Score the following strategy output on the dimensions below. For each dimension, provide a score (1-10) and specific feedback. + + +${strategyOutput} + + + +- Site: ${fixture.input.site.name} (${fixture.input.site.websiteUrl}) +- Industry: ${fixture.input.site.industry} +- Business background: ${fixture.input.site.businessBackground} +- Instructions given: ${fixture.input.instructions} + + + +1. **Keyword Cluster Coherence**: Are the suggested keywords logically grouped into coherent clusters/topics? Do the clusters support a clear topical authority strategy? Are there gaps in the keyword coverage? + +2. **Search Intent Alignment**: Does the strategy correctly identify and align with the search intent behind each keyword/topic? Are the content types (blog post, landing page, guide) matched to the intent? + +3. **Competitive Gap Identification**: Does the strategy identify specific gaps or opportunities relative to competitors? Is the competitive analysis grounded in data rather than generic advice? + +4. **Prioritization Quality**: Is the sequencing of content and efforts logical? Are quick wins distinguished from long-term plays? Does it account for the site's current authority level? + +5. **Actionability & Specificity**: Can a content team execute this strategy without further research? Are deliverables specific (exact keywords, content types, target URLs) rather than vague recommendations? + +6. **Realistic Expectations**: Are the goals and timelines achievable given the site's current state? Does the strategy acknowledge constraints (budget, DA, competition) rather than promising unrealistic results? + + + +Respond with a JSON object only (no markdown code fences, no explanation): +{ + "dimensions": [ + { "name": "Keyword Cluster Coherence", "score": <1-10>, "feedback": "" }, + { "name": "Search Intent Alignment", "score": <1-10>, "feedback": "" }, + { "name": "Competitive Gap Identification", "score": <1-10>, "feedback": "" }, + { "name": "Prioritization Quality", "score": <1-10>, "feedback": "" }, + { "name": "Actionability & Specificity", "score": <1-10>, "feedback": "" }, + { "name": "Realistic Expectations", "score": <1-10>, "feedback": "" } + ] +} +`; + + const result = await generateText({ + model: google("gemini-3-flash-preview"), + prompt, + }); + + try { + const cleaned = result.text + .replace(/^```json?\s*/m, "") + .replace(/\s*```\s*$/m, "") + .trim(); + return JSON.parse(cleaned) as LlmJudgeResult; + } catch { + console.error( + "[eval] Failed to parse strategy LLM judge output:", + result.text.slice(0, 500), + ); + return { + dimensions: [ + { + name: "Keyword Cluster Coherence", + score: 5, + feedback: "Parse failed", + }, + { name: "Search Intent Alignment", score: 5, feedback: "Parse failed" }, + { + name: "Competitive Gap Identification", + score: 5, + feedback: "Parse failed", + }, + { name: "Prioritization Quality", score: 5, feedback: "Parse failed" }, + { + name: "Actionability & Specificity", + score: 5, + feedback: "Parse failed", + }, + { name: "Realistic Expectations", score: 5, feedback: "Parse failed" }, + ], + }; + } +} + +// --------------------------------------------------------------------------- +// Pairwise comparison +// --------------------------------------------------------------------------- + +async function pairwiseCompare(args: { + current: string; + reference: string; + fixture: StrategyFixture; +}): Promise<{ winner: "current" | "reference" | "tie"; reasoning: string }> { + const { current, reference, fixture } = args; + + const prompt = `You are comparing two SEO strategy recommendations for the same site. Determine which is better overall. + + +- Site: ${fixture.input.site.name} (${fixture.input.site.websiteUrl}) +- Industry: ${fixture.input.site.industry} +- Instructions: ${fixture.input.instructions} + + + +${current} + + + +${reference} + + +Consider: keyword research depth, competitive analysis, prioritization, actionability, and realism. + +Respond with JSON only (no markdown code fences): +{ + "winner": "a" | "b" | "tie", + "reasoning": "<2-3 sentence explanation>" +}`; + + const result = await generateText({ + model: google("gemini-3-flash-preview"), + prompt, + }); + + try { + const cleaned = result.text + .replace(/^```json?\s*/m, "") + .replace(/\s*```\s*$/m, "") + .trim(); + const parsed = JSON.parse(cleaned) as { + winner: "a" | "b" | "tie"; + reasoning: string; + }; + return { + winner: + parsed.winner === "a" + ? "current" + : parsed.winner === "b" + ? "reference" + : "tie", + reasoning: parsed.reasoning, + }; + } catch { + return { winner: "tie", reasoning: "Failed to parse comparison result." }; + } +} + +// --------------------------------------------------------------------------- +// Main scorer +// --------------------------------------------------------------------------- + +const DIMENSION_WEIGHTS: Record = { + "Keyword Cluster Coherence": 0.2, + "Search Intent Alignment": 0.2, + "Competitive Gap Identification": 0.15, + "Prioritization Quality": 0.15, + "Actionability & Specificity": 0.15, + "Realistic Expectations": 0.15, +}; + +export async function scoreStrategy(args: { + output: string; + fixture: StrategyFixture; + durationMs: number; +}): Promise> { + const { output, fixture, durationMs } = args; + + // Run LLM judge + const llmResult = await llmJudgeStrategy({ + strategyOutput: output, + fixture, + }); + + const dimensions: ScoreDimension[] = llmResult.dimensions.map((d) => ({ + name: d.name, + score: d.score, + weight: DIMENSION_WEIGHTS[d.name] ?? 0.15, + feedback: d.feedback, + })); + + // Weighted overall score + const totalWeight = dimensions.reduce((sum, d) => sum + d.weight, 0); + const overallScore = + dimensions.reduce((sum, d) => sum + d.score * d.weight, 0) / totalWeight; + + // Pairwise comparison against reference + let pairwiseVsReference: EvalResult["pairwiseVsReference"] = + null; + if (fixture.referenceOutput) { + pairwiseVsReference = await pairwiseCompare({ + current: output, + reference: fixture.referenceOutput, + fixture, + }); + } + + return { + fixtureId: fixture.id, + timestamp: new Date().toISOString(), + output, + dimensions, + overallScore: Math.round(overallScore * 100) / 100, + pairwiseVsReference, + durationMs, + fixture, + }; +} diff --git a/packages/api-seo/src/eval/types.ts b/packages/api-seo/src/eval/types.ts new file mode 100644 index 000000000..570e6a6e4 --- /dev/null +++ b/packages/api-seo/src/eval/types.ts @@ -0,0 +1,123 @@ +/** + * Shared types for the eval framework. + * + * This framework evaluates content writing and strategy generation quality + * using a mix of deterministic scorers and LLM-as-a-judge (G-Eval pattern). + * + * Workflow: + * 1. Load fixtures (golden test cases) + * 2. Run the workflow (writer or strategy) against each fixture + * 3. Score the output with deterministic + LLM-judged criteria + * 4. Save timestamped results + * 5. Compare against baseline (the current best output) + */ + +import type { ArticleType } from "@rectangular-labs/core/schemas/content-parsers"; + +// --------------------------------------------------------------------------- +// Content eval types +// --------------------------------------------------------------------------- + +interface ContentFixtureProject { + name: string; + websiteUrl: string; + businessBackground: string; + brandVoice: string; + customInstructions: string; +} + +interface ContentFixtureExpectations { + minWordCount: number; + maxWordCount: number; +} + +export interface ContentFixture { + /** Unique identifier, e.g. "01-best-of-list-project-mgmt" */ + id: string; + /** Human description of what this fixture tests */ + description: string; + /** Inputs that map to writer workflow context */ + input: { + primaryKeyword: string; + title: string; + articleType: ArticleType; + notes: string | null; + outline: string | null; + project: ContentFixtureProject; + }; + /** Deterministic expectations */ + expectations: ContentFixtureExpectations; + /** + * The current best output for this fixture. This is the standard to beat. + * Initially null -- populate after the first run you're happy with. + */ + referenceOutput: string | null; +} + +// --------------------------------------------------------------------------- +// Strategy eval types +// --------------------------------------------------------------------------- + +export interface StrategyFixtureSite { + name: string; + websiteUrl: string; + businessBackground: string; + industry: string; +} + +export interface StrategyFixture { + /** Unique identifier, e.g. "01-saas-market-entry" */ + id: string; + /** Human description of what this fixture tests */ + description: string; + /** Inputs for the strategy suggestion workflow */ + input: { + instructions: string; + site: StrategyFixtureSite; + }; + /** + * The current best output for this fixture. This is the standard to beat. + * Initially null -- populate after the first run you're happy with. + */ + referenceOutput: string | null; +} + +// --------------------------------------------------------------------------- +// Score types (shared) +// --------------------------------------------------------------------------- +export interface ScoreDimension { + name: string; + score: number; // 1-10 + weight: number; // 0-1, sums to 1 + feedback: string; +} + +export interface EvalResult { + fixtureId: string; + timestamp: string; + /** The raw output from the workflow */ + output: string; + /** Individual dimension scores */ + dimensions: ScoreDimension[]; + /** Weighted overall score */ + overallScore: number; + /** Pairwise comparison against reference (if reference exists) */ + pairwiseVsReference: { + winner: "current" | "reference" | "tie"; + reasoning: string; + } | null; + /** Duration of the workflow run in ms */ + durationMs: number; + /** The fixture used (for reproducibility) */ + fixture: TFixture; +} + +export interface EvalRunSummary { + runId: string; + timestamp: string; + type: "content" | "strategy"; + results: EvalResult[]; + averageScore: number; + /** How this run compares to the baseline */ + baselineDelta: number | null; +} From 94142a7435b755742ed7fc034d86372a7913e390 Mon Sep 17 00:00:00 2001 From: ElasticBottle Date: Mon, 2 Mar 2026 08:56:08 +0800 Subject: [PATCH 6/9] chore(seo): update admin route with evaluation test bed --- apps/seo/src/routeTree.gen.ts | 42 +- .../-components/project-chat-panel.tsx | 222 +++++- apps/seo/src/routes/_authed/admin/index.tsx | 673 ++++++++++++++++++ apps/seo/src/routes/_authed/admin/route.tsx | 292 -------- 4 files changed, 878 insertions(+), 351 deletions(-) create mode 100644 apps/seo/src/routes/_authed/admin/index.tsx delete mode 100644 apps/seo/src/routes/_authed/admin/route.tsx diff --git a/apps/seo/src/routeTree.gen.ts b/apps/seo/src/routeTree.gen.ts index 9a28944a5..e75e0c1c5 100644 --- a/apps/seo/src/routeTree.gen.ts +++ b/apps/seo/src/routeTree.gen.ts @@ -13,9 +13,9 @@ import { Route as LoginRouteImport } from './routes/login' import { Route as AuthedRouteRouteImport } from './routes/_authed/route' import { Route as IndexRouteImport } from './routes/index' import { Route as ApiSplatRouteImport } from './routes/api/$' -import { Route as AuthedAdminRouteRouteImport } from './routes/_authed/admin/route' import { Route as AuthedOrganizationSlugRouteRouteImport } from './routes/_authed/$organizationSlug/route' import { Route as AuthedOnboardingIndexRouteImport } from './routes/_authed/onboarding/index' +import { Route as AuthedAdminIndexRouteImport } from './routes/_authed/admin/index' import { Route as AuthedOrganizationSlugIndexRouteImport } from './routes/_authed/$organizationSlug/index' import { Route as ApiRpcSplatRouteImport } from './routes/api/rpc.$' import { Route as AuthedInviteInvitationIdRouteImport } from './routes/_authed/invite/$invitationId' @@ -55,11 +55,6 @@ const ApiSplatRoute = ApiSplatRouteImport.update({ path: '/api/$', getParentRoute: () => rootRouteImport, } as any) -const AuthedAdminRouteRoute = AuthedAdminRouteRouteImport.update({ - id: '/admin', - path: '/admin', - getParentRoute: () => AuthedRouteRoute, -} as any) const AuthedOrganizationSlugRouteRoute = AuthedOrganizationSlugRouteRouteImport.update({ id: '/$organizationSlug', @@ -71,6 +66,11 @@ const AuthedOnboardingIndexRoute = AuthedOnboardingIndexRouteImport.update({ path: '/onboarding/', getParentRoute: () => AuthedRouteRoute, } as any) +const AuthedAdminIndexRoute = AuthedAdminIndexRouteImport.update({ + id: '/admin/', + path: '/admin/', + getParentRoute: () => AuthedRouteRoute, +} as any) const AuthedOrganizationSlugIndexRoute = AuthedOrganizationSlugIndexRouteImport.update({ id: '/', @@ -193,12 +193,12 @@ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/login': typeof LoginRoute '/$organizationSlug': typeof AuthedOrganizationSlugRouteRouteWithChildren - '/admin': typeof AuthedAdminRouteRoute '/api/$': typeof ApiSplatRoute '/$organizationSlug/$projectSlug': typeof AuthedOrganizationSlugProjectSlugRouteRouteWithChildren '/invite/$invitationId': typeof AuthedInviteInvitationIdRoute '/api/rpc/$': typeof ApiRpcSplatRoute '/$organizationSlug/': typeof AuthedOrganizationSlugIndexRoute + '/admin': typeof AuthedAdminIndexRoute '/onboarding': typeof AuthedOnboardingIndexRoute '/$organizationSlug/$projectSlug/settings': typeof AuthedOrganizationSlugProjectSlugSettingsRouteRouteWithChildren '/$organizationSlug/settings/team': typeof AuthedOrganizationSlugSettingsTeamRoute @@ -219,11 +219,11 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof IndexRoute '/login': typeof LoginRoute - '/admin': typeof AuthedAdminRouteRoute '/api/$': typeof ApiSplatRoute '/invite/$invitationId': typeof AuthedInviteInvitationIdRoute '/api/rpc/$': typeof ApiRpcSplatRoute '/$organizationSlug': typeof AuthedOrganizationSlugIndexRoute + '/admin': typeof AuthedAdminIndexRoute '/onboarding': typeof AuthedOnboardingIndexRoute '/$organizationSlug/settings/team': typeof AuthedOrganizationSlugSettingsTeamRoute '/$organizationSlug/$projectSlug': typeof AuthedOrganizationSlugProjectSlugIndexRoute @@ -246,12 +246,12 @@ export interface FileRoutesById { '/_authed': typeof AuthedRouteRouteWithChildren '/login': typeof LoginRoute '/_authed/$organizationSlug': typeof AuthedOrganizationSlugRouteRouteWithChildren - '/_authed/admin': typeof AuthedAdminRouteRoute '/api/$': typeof ApiSplatRoute '/_authed/$organizationSlug/$projectSlug': typeof AuthedOrganizationSlugProjectSlugRouteRouteWithChildren '/_authed/invite/$invitationId': typeof AuthedInviteInvitationIdRoute '/api/rpc/$': typeof ApiRpcSplatRoute '/_authed/$organizationSlug/': typeof AuthedOrganizationSlugIndexRoute + '/_authed/admin/': typeof AuthedAdminIndexRoute '/_authed/onboarding/': typeof AuthedOnboardingIndexRoute '/_authed/$organizationSlug/$projectSlug/settings': typeof AuthedOrganizationSlugProjectSlugSettingsRouteRouteWithChildren '/_authed/$organizationSlug/settings/team': typeof AuthedOrganizationSlugSettingsTeamRoute @@ -275,12 +275,12 @@ export interface FileRouteTypes { | '/' | '/login' | '/$organizationSlug' - | '/admin' | '/api/$' | '/$organizationSlug/$projectSlug' | '/invite/$invitationId' | '/api/rpc/$' | '/$organizationSlug/' + | '/admin' | '/onboarding' | '/$organizationSlug/$projectSlug/settings' | '/$organizationSlug/settings/team' @@ -301,11 +301,11 @@ export interface FileRouteTypes { to: | '/' | '/login' - | '/admin' | '/api/$' | '/invite/$invitationId' | '/api/rpc/$' | '/$organizationSlug' + | '/admin' | '/onboarding' | '/$organizationSlug/settings/team' | '/$organizationSlug/$projectSlug' @@ -327,12 +327,12 @@ export interface FileRouteTypes { | '/_authed' | '/login' | '/_authed/$organizationSlug' - | '/_authed/admin' | '/api/$' | '/_authed/$organizationSlug/$projectSlug' | '/_authed/invite/$invitationId' | '/api/rpc/$' | '/_authed/$organizationSlug/' + | '/_authed/admin/' | '/_authed/onboarding/' | '/_authed/$organizationSlug/$projectSlug/settings' | '/_authed/$organizationSlug/settings/team' @@ -389,13 +389,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiSplatRouteImport parentRoute: typeof rootRouteImport } - '/_authed/admin': { - id: '/_authed/admin' - path: '/admin' - fullPath: '/admin' - preLoaderRoute: typeof AuthedAdminRouteRouteImport - parentRoute: typeof AuthedRouteRoute - } '/_authed/$organizationSlug': { id: '/_authed/$organizationSlug' path: '/$organizationSlug' @@ -410,6 +403,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthedOnboardingIndexRouteImport parentRoute: typeof AuthedRouteRoute } + '/_authed/admin/': { + id: '/_authed/admin/' + path: '/admin' + fullPath: '/admin' + preLoaderRoute: typeof AuthedAdminIndexRouteImport + parentRoute: typeof AuthedRouteRoute + } '/_authed/$organizationSlug/': { id: '/_authed/$organizationSlug/' path: '/' @@ -634,16 +634,16 @@ const AuthedOrganizationSlugRouteRouteWithChildren = interface AuthedRouteRouteChildren { AuthedOrganizationSlugRouteRoute: typeof AuthedOrganizationSlugRouteRouteWithChildren - AuthedAdminRouteRoute: typeof AuthedAdminRouteRoute AuthedInviteInvitationIdRoute: typeof AuthedInviteInvitationIdRoute + AuthedAdminIndexRoute: typeof AuthedAdminIndexRoute AuthedOnboardingIndexRoute: typeof AuthedOnboardingIndexRoute } const AuthedRouteRouteChildren: AuthedRouteRouteChildren = { AuthedOrganizationSlugRouteRoute: AuthedOrganizationSlugRouteRouteWithChildren, - AuthedAdminRouteRoute: AuthedAdminRouteRoute, AuthedInviteInvitationIdRoute: AuthedInviteInvitationIdRoute, + AuthedAdminIndexRoute: AuthedAdminIndexRoute, AuthedOnboardingIndexRoute: AuthedOnboardingIndexRoute, } diff --git a/apps/seo/src/routes/_authed/$organizationSlug/-components/project-chat-panel.tsx b/apps/seo/src/routes/_authed/$organizationSlug/-components/project-chat-panel.tsx index 29a4f827b..79163e802 100644 --- a/apps/seo/src/routes/_authed/$organizationSlug/-components/project-chat-panel.tsx +++ b/apps/seo/src/routes/_authed/$organizationSlug/-components/project-chat-panel.tsx @@ -89,6 +89,7 @@ import { import { useIsApple } from "@rectangular-labs/ui/hooks/use-apple"; import { cn } from "@rectangular-labs/ui/utils/cn"; import { useLocation } from "@tanstack/react-router"; +import { lastAssistantMessageIsCompleteWithApprovalResponses } from "ai"; import { useEffect, useMemo, useRef, useState } from "react"; import { Fragment } from "react/jsx-runtime"; import { getApiClient } from "~/lib/api"; @@ -96,6 +97,10 @@ import { useProjectChat } from "./project-chat-provider"; type ChatMessagePart = SeoChatMessage["parts"][number]; type ChatToolPart = Extract; +type DeleteDataToolPart = Extract< + ChatToolPart, + { type: "tool-delete_existing_data" } +>; function AskQuestionsToolPart({ part, @@ -298,6 +303,124 @@ function AskQuestionsToolPart({ ); } +function DeleteDataToolPart({ + part, + onRespond, +}: { + part: DeleteDataToolPart; + onRespond: (args: { id: string; approved: boolean; reason?: string }) => void; +}) { + const [denialReason, setDenialReason] = useState(""); + + if (part.type !== "tool-delete_existing_data") return null; + + const input = part.input; + if (!input) { + return ( +
+ Delete request is missing input parameters. +
+ ); + } + + const entityLabel = + input.entityType === "strategy" ? "strategy" : "content draft"; + const isApprovalRequested = part.state === "approval-requested"; + + if (part.state === "input-streaming" || part.state === "input-available") { + return ( + + Preparing delete request + + ); + } + + return ( +
+
+
Delete {entityLabel}
+
+ ID: {input.id} +
+ {input.reason ? ( +
+ Reason: {input.reason} +
+ ) : null} + {input.entityType === "strategy" ? ( +
+ This only removes the strategy. Content previously linked to it will + remain in your project. +
+ ) : ( +
+ Deleting this content also unpublishes it from your site. +
+ )} + + {part.state === "output-available" && + part.output && + typeof part.output === "object" && + "success" in part.output && + part.output.success === true ? ( +
+ Deletion approved and completed. +
+ ) : null} + {part.state === "output-error" ? ( +
+ Delete failed: {part.errorText} +
+ ) : null} + {part.state === "output-denied" ? ( +
+ Delete request denied. + {part.approval.reason ? ` Reason: ${part.approval.reason}` : ""} +
+ ) : null} +
+ + {isApprovalRequested ? ( +
+ setDenialReason(event.target.value)} + placeholder="Optional denial reason" + value={denialReason} + /> +
+ + +
+
+ ) : null} +
+ ); +} + function inferCurrentPage(pathname: string): ProjectChatCurrentPage { if (pathname.includes("/content")) return "content-list"; if (pathname.includes("/settings")) return "settings"; @@ -329,45 +452,53 @@ function ChatConversation({ const textareaRef = useRef(null); const chatIdRef = useRef(chatId); - const { messages, setMessages, sendMessage, status, regenerate, stop } = - useChat({ - ...(!!chatIdRef.current && !isMessagesLoading - ? { - id: chatIdRef.current, - messages: initialMessages, - } - : {}), - transport: { - reconnectToStream: async () => null, - sendMessages: async ({ abortSignal, messages, messageId, trigger }) => { - console.log("messageId", messageId, trigger); - const eventIterator = await getApiClient().chat.sendMessage( - { - organizationId, - projectId, - currentPage, - messages, - chatId: chatIdRef.current, - messageId, - model: undefined, - }, - { signal: abortSignal }, - ); - return eventIteratorToUnproxiedDataStream(eventIterator); - }, - }, - onError: (error) => { - console.error("project chat error", error); - }, - onToolCall: ({ toolCall }) => { - console.log("toolCall", toolCall); - }, - onFinish: ({ message }) => { - const createdChatId = message.metadata?.chatId ?? null; - if (!createdChatId || !!chatIdRef.current) return; - onAdoptChatId(createdChatId); + const { + messages, + setMessages, + sendMessage, + status, + regenerate, + stop, + addToolApprovalResponse, + } = useChat({ + ...(!!chatIdRef.current && !isMessagesLoading + ? { + id: chatIdRef.current, + messages: initialMessages, + } + : {}), + transport: { + reconnectToStream: async () => null, + sendMessages: async ({ abortSignal, messages, messageId, trigger }) => { + console.log("messageId", messageId, trigger); + const eventIterator = await getApiClient().chat.sendMessage( + { + organizationId, + projectId, + currentPage, + messages, + chatId: chatIdRef.current, + messageId, + model: undefined, + }, + { signal: abortSignal }, + ); + return eventIteratorToUnproxiedDataStream(eventIterator); }, - }); + }, + onError: (error) => { + console.error("project chat error", error); + }, + onToolCall: ({ toolCall }) => { + console.log("toolCall", toolCall); + }, + onFinish: ({ message }) => { + const createdChatId = message.metadata?.chatId ?? null; + if (!createdChatId || !!chatIdRef.current) return; + onAdoptChatId(createdChatId); + }, + sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses, + }); useEffect(() => { chatIdRef.current = chatId; @@ -497,6 +628,21 @@ function ChatConversation({ ); } + case "tool-delete_existing_data": { + return ( + { + void addToolApprovalResponse({ + id, + approved, + reason, + }); + }} + part={part} + /> + ); + } default: { return null; } diff --git a/apps/seo/src/routes/_authed/admin/index.tsx b/apps/seo/src/routes/_authed/admin/index.tsx new file mode 100644 index 000000000..d95c6eb94 --- /dev/null +++ b/apps/seo/src/routes/_authed/admin/index.tsx @@ -0,0 +1,673 @@ +import { Button } from "@rectangular-labs/ui/components/ui/button"; +import { Input } from "@rectangular-labs/ui/components/ui/input"; +import { Label } from "@rectangular-labs/ui/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@rectangular-labs/ui/components/ui/select"; +import { toast } from "@rectangular-labs/ui/components/ui/sonner"; +import { Textarea } from "@rectangular-labs/ui/components/ui/textarea"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { createFileRoute, notFound } from "@tanstack/react-router"; +import { useEffect, useState } from "react"; +import { getApiClientRq } from "~/lib/api"; + +export const Route = createFileRoute("/_authed/admin/")({ + beforeLoad: ({ context }) => { + if (!context.user?.email?.endsWith("fluidposts.com")) { + throw notFound(); + } + }, + component: RouteComponent, +}); + +function RouteComponent() { + const api = getApiClientRq(); + const [organizationSlug, setOrganizationSlug] = useState(""); + const [projectSlug, setProjectSlug] = useState(""); + const [instructions, setInstructions] = useState(""); + const [phaseStrategyName, setPhaseStrategyName] = useState(""); + const [evalType, setEvalType] = useState<"content" | "strategy">("content"); + const [selectedFixtureId, setSelectedFixtureId] = useState(""); + const [generatedOutput, setGeneratedOutput] = useState(""); + const [generatedDurationMs, setGeneratedDurationMs] = useState(0); + const [generatedFixtureId, setGeneratedFixtureId] = useState(""); + const [generatedType, setGeneratedType] = useState<"content" | "strategy">( + "content", + ); + const [generationJobId, setGenerationJobId] = useState(""); + const [lastGenerationHandledJobId, setLastGenerationHandledJobId] = + useState(""); + const [generatedAt, setGeneratedAt] = useState(""); + const [generatedOutputFileName, setGeneratedOutputFileName] = useState(""); + const [generatedStepsJson, setGeneratedStepsJson] = useState(""); + const [generatedStepsFileName, setGeneratedStepsFileName] = useState(""); + const [scoreJson, setScoreJson] = useState(""); + const [scoreFileName, setScoreFileName] = useState(""); + const [scoreResult, setScoreResult] = useState<{ + overallScore: number; + dimensions: { name: string; score: number; feedback: string }[]; + } | null>(null); + + const { data: fixturesData, isLoading: isFixturesLoading } = useQuery( + api.admin.listEvalFixtures.queryOptions(), + ); + const { data: generationStatus } = useQuery( + api.admin.getEvalGenerationStatus.queryOptions({ + input: { jobId: generationJobId }, + enabled: !!generationJobId, + refetchInterval: (context) => { + const status = context.state.data?.status; + if (status === "pending") { + return 30_000; + } + return false; + }, + }), + ); + const { mutate: triggerOnboarding, isPending } = useMutation( + api.admin.triggerOnboardingTask.mutationOptions({ + onSuccess: () => { + toast.success("Triggered onboarding workflow successfully."); + }, + onError: (error) => { + toast.error(error.message); + }, + }), + ); + + const { mutate: triggerStrategySuggestions, isPending: isPendingStrategy } = + useMutation( + api.admin.triggerStrategySuggestionsTask.mutationOptions({ + onSuccess: () => { + toast.success( + "Triggered strategy suggestions workflow successfully.", + ); + setInstructions(""); + }, + onError: (error) => { + toast.error(error.message); + }, + }), + ); + + const { mutate: generateEvalOutput, isPending: isPendingEvalGeneration } = + useMutation( + api.admin.generateEvalOutput.mutationOptions({ + onSuccess: (data) => { + setGenerationJobId(data.jobId); + setGeneratedOutput(""); + setGeneratedDurationMs(0); + setGeneratedFixtureId(data.fixtureId); + setGeneratedType(data.type); + setGeneratedAt(""); + setGeneratedOutputFileName(""); + setGeneratedStepsJson(""); + setGeneratedStepsFileName(""); + setScoreJson(""); + setScoreFileName(""); + setScoreResult(null); + toast.success(`Started ${data.type} fixture generation.`); + }, + onError: (error) => { + toast.error(error.message); + }, + }), + ); + + const { mutate: scoreEvalOutput, isPending: isPendingEvalScoring } = + useMutation( + api.admin.scoreEvalOutput.mutationOptions({ + onSuccess: (data) => { + setScoreResult({ + overallScore: data.overallScore, + dimensions: data.dimensions.map((dimension) => ({ + name: dimension.name, + score: dimension.score, + feedback: dimension.feedback, + })), + }); + setScoreJson(data.scoreJson); + setScoreFileName(data.scoreFileName); + toast.success(`Scored: ${data.overallScore}/10`); + }, + onError: (error) => { + toast.error(error.message); + }, + }), + ); + + const { mutate: triggerStrategyPhase, isPending: isPendingStrategyPhase } = + useMutation( + api.admin.triggerStrategyPhaseGenerationTask.mutationOptions({ + onSuccess: () => { + toast.success( + "Triggered strategy phase generation workflow successfully.", + ); + setPhaseStrategyName(""); + }, + onError: (error) => { + toast.error(error.message); + }, + }), + ); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!organizationSlug.trim()) { + toast.error("Please enter an organization slug"); + return; + } + if (!projectSlug.trim()) { + toast.error("Please enter a project slug"); + return; + } + triggerOnboarding({ organizationSlug, projectSlug }); + }; + + const handleStrategySubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!organizationSlug.trim()) { + toast.error("Please enter an organization slug"); + return; + } + if (!projectSlug.trim()) { + toast.error("Please enter a project slug"); + return; + } + triggerStrategySuggestions({ + organizationSlug, + projectSlug, + instructions, + }); + }; + + const handleStrategyPhaseSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!organizationSlug.trim()) { + toast.error("Please enter an organization slug"); + return; + } + if (!projectSlug.trim()) { + toast.error("Please enter a project slug"); + return; + } + if (!phaseStrategyName.trim()) { + toast.error("Please enter a strategy name"); + return; + } + + triggerStrategyPhase({ + organizationSlug, + projectSlug, + strategyName: phaseStrategyName, + }); + }; + + const fixtureOptions = + evalType === "content" + ? (fixturesData?.content ?? []) + : (fixturesData?.strategy ?? []); + const isGenerationPending = generationStatus?.status === "pending"; + + useEffect(() => { + if (!generationStatus || !generationJobId) { + return; + } + if (generationStatus.jobId === lastGenerationHandledJobId) { + return; + } + if (generationStatus.status === "completed") { + setGeneratedOutput(generationStatus.output ?? ""); + setGeneratedDurationMs(generationStatus.durationMs ?? 0); + setGeneratedFixtureId(generationStatus.fixtureId ?? ""); + if (generationStatus.type) { + setGeneratedType(generationStatus.type); + } + setGeneratedAt(generationStatus.generatedAt ?? ""); + setGeneratedOutputFileName(generationStatus.outputFileName ?? ""); + setGeneratedStepsJson(generationStatus.stepsJson ?? ""); + setGeneratedStepsFileName(generationStatus.stepsFileName ?? ""); + setLastGenerationHandledJobId(generationStatus.jobId); + toast.success( + `Generated ${generationStatus.type} fixture output in ${generationStatus.durationMs ?? 0}ms.`, + ); + return; + } + if (generationStatus.status === "failed") { + setLastGenerationHandledJobId(generationStatus.jobId); + toast.error(generationStatus.error ?? "Fixture generation failed"); + return; + } + if (generationStatus.status === "not_found") { + setLastGenerationHandledJobId(generationStatus.jobId); + toast.error("Fixture generation job expired or was not found."); + } + }, [generationStatus, generationJobId, lastGenerationHandledJobId]); + + const handleGenerateEvalOutput = (e: React.FormEvent) => { + e.preventDefault(); + if (!selectedFixtureId.trim()) { + toast.error("Please select a fixture"); + return; + } + generateEvalOutput({ + type: evalType, + fixtureId: selectedFixtureId, + }); + }; + + const handleScoreEvalOutput = (e: React.FormEvent) => { + e.preventDefault(); + if (!generatedOutput.trim()) { + toast.error("No generated output to score"); + return; + } + if (!generatedFixtureId.trim()) { + toast.error("No generated fixture selected"); + return; + } + + scoreEvalOutput({ + type: generatedType, + fixtureId: generatedFixtureId, + output: generatedOutput, + durationMs: generatedDurationMs, + }); + }; + + const downloadTextFile = ({ + content, + fileName, + contentType, + }: { + content: string; + fileName: string; + contentType: string; + }) => { + const blob = new Blob([content], { type: contentType }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = fileName; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); + }; + + return ( +
+
+

Admin

+

+ Internal tools for the fluidposts team. +

+
+ +
+
+

+ Trigger Onboarding Task +

+

+ Trigger the onboarding workflow in the api-seo package for a + specific project. +

+
+
+
+
+ + setOrganizationSlug(e.target.value)} + placeholder="e.g. acme" + value={organizationSlug} + /> +
+
+ + setProjectSlug(e.target.value)} + placeholder="e.g. acme-corp" + value={projectSlug} + /> +
+ +
+
+
+ +
+
+

+ Trigger Strategy Suggestions Task +

+

+ Trigger the strategy suggestions workflow in the api-seo package for + a specific project. +

+
+
+
+
+ + setOrganizationSlug(e.target.value)} + placeholder="e.g. acme" + value={organizationSlug} + /> +
+
+ + setProjectSlug(e.target.value)} + placeholder="e.g. acme-corp" + value={projectSlug} + /> +
+
+ +