diff --git a/.gitignore b/.gitignore
index 95a74b6..d0857bb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,8 +10,6 @@ dist/
.DS_Store
.env.*
coverage/
-__pycache__/
-*.pyc
# GitHub PAT / token files — belt-and-suspenders
*github-pat*
diff --git a/apps/web/app/servers/[slug]/page.tsx b/apps/web/app/servers/[slug]/page.tsx
index 6bf5477..fd01cef 100644
--- a/apps/web/app/servers/[slug]/page.tsx
+++ b/apps/web/app/servers/[slug]/page.tsx
@@ -68,7 +68,6 @@ import {
IconInfoCircle,
IconLink,
IconAlertCircle,
- IconRobot,
} from "@tabler/icons-react";
import { ServerOutboundLink } from "@/components/ServerOutboundLink";
@@ -329,32 +328,6 @@ export default async function ServerDetailPage({
{/* Left: Main content */}
- {/* AI Summary — labeled supplement ONLY (migration 006, Slice 3 of the
- indexing-recovery plan). Never replaces `description` on-page and
- never feeds SoftwareApplication.description in the JSON-LD (see
- lib/metadata.ts#generateServerJsonLd, which reads server.description).
- Rendered only when a summary has been backfilled. */}
- {server.ai_summary && (
-
-
-
- AI Summary
-
-
- {server.ai_summary}
-
-
- Generated summary — supplements, not a substitute for the README below.
-
-
- )}
-
{/* Overview / README */}
{
const page = Math.max(1, params.page || 1);
diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts
index 275c79e..a4f0b7a 100644
--- a/packages/shared/src/types.ts
+++ b/packages/shared/src/types.ts
@@ -69,13 +69,6 @@ export interface Server {
is_official: boolean;
featured: boolean;
- // AI summary enrichment (migration 006) — labeled supplement only.
- // Never use in place of `description` for SoftwareApplication.description
- // in JSON-LD or as primary on-page body copy (see
- // specs/stage-6-slices/00-recovery-plan.md, Guardrails section).
- ai_summary: string | null;
- ai_summary_generated_at: string | null;
-
// Timestamps
created_at: string;
updated_at: string;
@@ -91,9 +84,7 @@ export interface ServerTool {
created_at: string;
}
-// ai_summary / ai_summary_generated_at are detail-page-only (not selected by
-// SERVER_LIST_COLUMNS, same rationale as readme_content — see queries.ts).
-export type ServerListItem = Omit;
+export type ServerListItem = Omit;
export interface ServerWithTools extends Server {
tools: ServerTool[];
diff --git a/scripts/mcpfind/ai_summary_generate.py b/scripts/mcpfind/ai_summary_generate.py
deleted file mode 100644
index dae8503..0000000
--- a/scripts/mcpfind/ai_summary_generate.py
+++ /dev/null
@@ -1,551 +0,0 @@
-#!/usr/bin/env python3
-"""Nightly backfill: generate `ai_summary` for gated (isIndexable) servers.
-
-Part of Slice 3 (Enrichment backfill) of the mcpfind indexing-recovery plan
-(specs/stage-6-slices/00-recovery-plan.md). Migration 006
-(supabase/migrations/006_ai_summary.sql) added five provenance columns to
-`servers`: ai_summary, ai_summary_generated_at, ai_summary_model,
-ai_summary_prompt_version, ai_summary_source_hash.
-
-GUARDRAIL (recovery-plan "Guardrails: anti scaled-content-abuse"): ai_summary
-is a labeled supplement ONLY. It is rendered in a clearly-labeled "AI Summary"
-block on the server detail page (apps/web/app/servers/[slug]/page.tsx) and is
-NEVER substituted for `description` in the JSON-LD SoftwareApplication.description
-(apps/web/lib/metadata.ts#generateServerJsonLd) or used as primary on-page body
-copy. This script only ever writes to the ai_summary_* columns — it never
-touches `description`.
-
-What this script does
-----------------------
-1. Selects candidate rows from `servers` and re-applies, in Python, the same
- gating predicate as the TypeScript `isIndexable()` (apps/web/lib/indexable.ts)
- so only servers that clear mcpfind's real-data quality bar (>= 3 of 5
- source-data signals; see `is_indexable()` below) are ever summarized. Signal
- count and thresholds are kept in lockstep with indexable.ts by hand — if you
- change one, change the other.
-2. For each gated server, computes a source hash over the same fields
- documented on migration 006's `ai_summary_source_hash` column comment:
- name|description|readme_content[:8000]|category|package_name|package_type|registry_tags
- (registry_tags, being an array, is joined with "," before hashing — this is
- this script's own serialization choice; it is not separately specified by
- the migration comment).
-3. Skips rows whose stored `ai_summary_source_hash` already matches the freshly
- computed hash — idempotent, no wasted regeneration on an unchanged server
- (recovery-plan guardrail: "Skip-if-unchanged via source hash").
-4. For rows needing (re)generation, builds a factual-summary prompt and posts
- it to the local Mac Mini Claude Code queue (never a raw Anthropic API key —
- see this org's convention: all AI generation runs through
- http://127.0.0.1:7600, not a direct Anthropic API call).
-5. Writes back ai_summary, ai_summary_generated_at (UTC now),
- ai_summary_model ("claude-code-local-queue"), ai_summary_prompt_version
- ("v1" by default, override with --prompt-version), and the new
- ai_summary_source_hash.
-
-Local Claude Code queue contract (assumed — no prior client existed in this
-repo to match; grepped for "7600" and "claude-code-local-queue" across the
-repo and found none other than the migration 006 column comments):
- POST {LOCAL_QUEUE_URL}/generate
- Body: {"prompt": "", "max_tokens": 500}
- Response (200): JSON object containing the generated text under one of
- "completion", "text", "response", "result", or "output" (checked in
- that order — defensive against minor key-naming variance).
-If your actual queue endpoint uses a different contract, update
-`call_local_queue()` only — every other function in this script is
-queue-contract-agnostic.
-
-Environment variables (same names as packages/sync/src/index.ts and
-apps/web/lib/supabase.ts — the existing Supabase client conventions in this
-repo):
- SUPABASE_URL - required. Supabase project REST URL.
- SUPABASE_SERVICE_ROLE_KEY - required. Service-role key (write access;
- the anon key used by the web app is read-only
- under RLS and cannot UPDATE ai_summary_*).
- CLAUDE_CODE_LOCAL_QUEUE_URL - optional. Overrides the default
- http://127.0.0.1:7600 local queue base URL.
-
-Usage:
- python3 scripts/mcpfind/ai_summary_generate.py [--dry-run] [--limit N]
- [--prompt-version v1] [--page-size 500] [--sleep-seconds 0.5] [-v]
-
-Exit codes:
- 0 - completed with zero per-row failures (including the trivial case of
- zero gated servers).
- 1 - configuration error (missing env vars) — nothing was attempted.
- 2 - completed, but one or more rows failed to generate/update; see the
- logged per-row errors. Safe to re-run — failed rows retry from scratch
- next run, since their source hash was never updated.
-
-Nightly cron: schedule via launchd (Mac Mini convention — no remote triggers,
-see wiki/rules/no-remote-triggers.md). This script has no side effects beyond
-Supabase writes and local-queue calls, so re-running after a partial failure
-is always safe.
-"""
-
-from __future__ import annotations
-
-import argparse
-import hashlib
-import logging
-import os
-import sys
-import time
-from dataclasses import dataclass
-from datetime import datetime, timezone
-from typing import Any, Optional
-
-import requests
-
-# ---------------------------------------------------------------------------
-# Constants — keep README_MIN_LENGTH / MIN_SIGNALS in lockstep with
-# apps/web/lib/indexable.ts. Do not drift these independently.
-# ---------------------------------------------------------------------------
-README_MIN_LENGTH = 400
-MIN_SIGNALS = 3
-
-SOURCE_HASH_README_TRUNCATE = 8000
-DEFAULT_PROMPT_VERSION = "v1"
-AI_SUMMARY_MODEL_LABEL = "claude-code-local-queue"
-DEFAULT_LOCAL_QUEUE_URL = "http://127.0.0.1:7600"
-DEFAULT_PAGE_SIZE = 500
-SUMMARY_WORD_MIN = 150
-SUMMARY_WORD_MAX = 200
-
-# Columns pulled from `servers` — the union of what is_indexable() needs plus
-# the fields the source hash and prompt are built from. Mirrors
-# SERVER_DETAIL_COLUMNS in apps/web/lib/queries.ts, minus fields this script
-# doesn't use (e.g. github_stars/tool_count are in isIndexable's signal set
-# and included below; UI-only fields like npm_weekly_downloads are not).
-CANDIDATE_COLUMNS = (
- "id,name,description,readme_content,category,package_name,package_type,"
- "registry_tags,registry_status,github_archived,has_tools,tool_count,"
- "github_stars,ai_summary_source_hash"
-)
-
-logger = logging.getLogger("ai_summary_generate")
-
-
-@dataclass
-class RunStats:
- scanned: int = 0
- gated: int = 0
- skipped_unchanged: int = 0
- generated: int = 0
- failed: int = 0
-
-
-class LocalQueueError(RuntimeError):
- """Raised when the local Claude Code queue call fails or returns something unusable."""
-
-
-class SupabaseError(RuntimeError):
- """Raised on a non-2xx response from the Supabase REST API."""
-
-
-# ---------------------------------------------------------------------------
-# Gating — Python port of apps/web/lib/indexable.ts#isIndexable.
-# ---------------------------------------------------------------------------
-def is_indexable(row: dict[str, Any]) -> bool:
- """Re-implements isIndexable() from apps/web/lib/indexable.ts.
-
- Hard exclusions first (deprecated / archived are never indexable
- regardless of signal count), then a >= MIN_SIGNALS-of-5 count over the
- same five source-data signals used on the TypeScript side.
- """
- if row.get("registry_status") == "deprecated":
- return False
- if row.get("github_archived"):
- return False
-
- signals = 0
-
- readme_content = row.get("readme_content") or ""
- if len(readme_content.strip()) >= README_MIN_LENGTH:
- signals += 1
-
- if row.get("has_tools") or (row.get("tool_count") or 0) > 0:
- signals += 1
-
- if row.get("package_name") and row.get("package_type"):
- signals += 1
-
- if (row.get("github_stars") or 0) > 0:
- signals += 1
-
- if row.get("category"):
- signals += 1
-
- return signals >= MIN_SIGNALS
-
-
-# ---------------------------------------------------------------------------
-# Source hash — matches migration 006's ai_summary_source_hash column comment.
-# ---------------------------------------------------------------------------
-def compute_source_hash(row: dict[str, Any]) -> str:
- """SHA-256 over name|description|readme_content[:8000]|category|package_name|package_type|registry_tags.
-
- Field order and truncation match the column comment on
- servers.ai_summary_source_hash in supabase/migrations/006_ai_summary.sql.
- registry_tags is an array; this script serializes it by joining with ","
- (not separately specified upstream — documented here as this script's
- own convention so any other future writer of this column can match it).
- """
- name = row.get("name") or ""
- description = row.get("description") or ""
- readme = (row.get("readme_content") or "")[:SOURCE_HASH_README_TRUNCATE]
- category = row.get("category") or ""
- package_name = row.get("package_name") or ""
- package_type = row.get("package_type") or ""
- registry_tags = ",".join(row.get("registry_tags") or [])
-
- source = "|".join(
- [name, description, readme, category, package_name, package_type, registry_tags]
- )
- return hashlib.sha256(source.encode("utf-8")).hexdigest()
-
-
-# ---------------------------------------------------------------------------
-# Prompt construction
-# ---------------------------------------------------------------------------
-def build_prompt(row: dict[str, Any]) -> str:
- name = row.get("name") or "this MCP server"
- description = row.get("description") or "(no description provided)"
- category = row.get("category") or "uncategorized"
- package_name = row.get("package_name") or "(no package)"
- package_type = row.get("package_type") or "unknown"
- readme_excerpt = (row.get("readme_content") or "")[:SOURCE_HASH_README_TRUNCATE]
-
- return (
- "You are writing a factual, neutral 150-200 word summary of an MCP "
- "(Model Context Protocol) server for a directory listing page. This "
- "summary is a labeled supplement displayed alongside — never in place "
- "of — the server's existing description and README. Do not write "
- "marketing copy, do not invent capabilities not evidenced below, and "
- "do not exceed 200 words or fall under 150 words.\n\n"
- f"Server name: {name}\n"
- f"Category: {category}\n"
- f"Package: {package_name} ({package_type})\n"
- f"Existing short description: {description}\n"
- f"README excerpt:\n{readme_excerpt}\n\n"
- "Write the 150-200 word factual summary now. Output only the summary "
- "text, no headings, no preamble."
- )
-
-
-# ---------------------------------------------------------------------------
-# Local Claude Code queue client
-# ---------------------------------------------------------------------------
-def call_local_queue(
- prompt: str,
- base_url: str,
- timeout_seconds: float = 120.0,
- max_attempts: int = 3,
-) -> str:
- """POST `prompt` to the local Mac Mini Claude Code queue and return generated text.
-
- See the module docstring for the assumed request/response contract. Retries
- on network errors and 5xx responses with linear backoff; does not retry on
- 4xx (a bad request will not fix itself on retry).
- """
- url = f"{base_url.rstrip('/')}/generate"
- last_error: Optional[Exception] = None
-
- for attempt in range(1, max_attempts + 1):
- try:
- resp = requests.post(
- url,
- json={"prompt": prompt, "max_tokens": 500},
- timeout=timeout_seconds,
- )
- except requests.RequestException as exc:
- last_error = exc
- logger.warning(
- "Local queue request failed (attempt %d/%d): %s", attempt, max_attempts, exc
- )
- time.sleep(2 * attempt)
- continue
-
- if resp.status_code >= 500:
- last_error = LocalQueueError(f"Local queue HTTP {resp.status_code}: {resp.text[:500]}")
- logger.warning(
- "Local queue returned %d (attempt %d/%d)", resp.status_code, attempt, max_attempts
- )
- time.sleep(2 * attempt)
- continue
-
- if resp.status_code >= 400:
- raise LocalQueueError(
- f"Local queue rejected request with HTTP {resp.status_code}: {resp.text[:500]}"
- )
-
- try:
- data = resp.json()
- except ValueError as exc:
- raise LocalQueueError(f"Local queue returned non-JSON response: {exc}") from exc
-
- for key in ("completion", "text", "response", "result", "output"):
- value = data.get(key) if isinstance(data, dict) else None
- if isinstance(value, str) and value.strip():
- return value.strip()
-
- raise LocalQueueError(
- f"Local queue response had no usable text field (checked completion/text/"
- f"response/result/output): {data!r}"
- )
-
- raise LocalQueueError(
- f"Local queue call failed after {max_attempts} attempts: {last_error}"
- )
-
-
-def generate_summary(row: dict[str, Any], base_url: str) -> str:
- prompt = build_prompt(row)
- summary = call_local_queue(prompt, base_url=base_url)
-
- word_count = len(summary.split())
- if not (SUMMARY_WORD_MIN <= word_count <= SUMMARY_WORD_MAX):
- # Non-fatal: the model doesn't always hit the target window exactly.
- # Log so drift is visible without failing the whole backfill run.
- logger.warning(
- "Generated summary for %r is %d words (target %d-%d) — storing anyway",
- row.get("name"),
- word_count,
- SUMMARY_WORD_MIN,
- SUMMARY_WORD_MAX,
- )
-
- return summary
-
-
-# ---------------------------------------------------------------------------
-# Supabase REST client (PostgREST) — no supabase-py dependency, just requests.
-# Mirrors the SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY convention used by
-# packages/sync/src/index.ts and packages/sync/src/recategorize-other.ts.
-# ---------------------------------------------------------------------------
-def _rest_headers(service_role_key: str, prefer: Optional[str] = None) -> dict[str, str]:
- headers = {
- "apikey": service_role_key,
- "Authorization": f"Bearer {service_role_key}",
- "Content-Type": "application/json",
- }
- if prefer:
- headers["Prefer"] = prefer
- return headers
-
-
-def fetch_candidate_servers(
- supabase_url: str,
- service_role_key: str,
- page_size: int,
-) -> list[dict[str, Any]]:
- """Paginate through non-deprecated, non-archived servers.
-
- Pushes down the two hard exclusions from isIndexable() (registry_status
- and github_archived) as query filters — those two never depend on signal
- count, so filtering server-side is safe and cuts payload size. The
- remaining 5-signal scoring (README length, tools, install command, GitHub
- stars, category) is computed client-side in is_indexable(), since it's a
- threshold over multiple independent fields rather than a single filter.
- """
- rest_url = f"{supabase_url.rstrip('/')}/rest/v1/servers"
- headers = _rest_headers(service_role_key)
- rows: list[dict[str, Any]] = []
- offset = 0
-
- while True:
- params = {
- "select": CANDIDATE_COLUMNS,
- "registry_status": "eq.active",
- "github_archived": "eq.false",
- "order": "id.asc",
- "limit": str(page_size),
- "offset": str(offset),
- }
- resp = requests.get(rest_url, headers=headers, params=params, timeout=30)
- if not resp.ok:
- raise SupabaseError(
- f"Fetching candidate servers failed: HTTP {resp.status_code}: {resp.text[:500]}"
- )
- page = resp.json()
- if not isinstance(page, list):
- raise SupabaseError(f"Unexpected non-list response from Supabase: {page!r}")
-
- rows.extend(page)
- logger.debug("Fetched page at offset %d: %d rows", offset, len(page))
-
- if len(page) < page_size:
- break
- offset += page_size
-
- return rows
-
-
-def update_server_summary(
- supabase_url: str,
- service_role_key: str,
- server_id: str,
- ai_summary: str,
- ai_summary_source_hash: str,
- prompt_version: str,
-) -> None:
- rest_url = f"{supabase_url.rstrip('/')}/rest/v1/servers"
- headers = _rest_headers(service_role_key, prefer="return=minimal")
- body = {
- "ai_summary": ai_summary,
- "ai_summary_generated_at": datetime.now(timezone.utc).isoformat(),
- "ai_summary_model": AI_SUMMARY_MODEL_LABEL,
- "ai_summary_prompt_version": prompt_version,
- "ai_summary_source_hash": ai_summary_source_hash,
- }
- resp = requests.patch(
- rest_url,
- headers=headers,
- params={"id": f"eq.{server_id}"},
- json=body,
- timeout=30,
- )
- if not resp.ok:
- raise SupabaseError(
- f"Updating server {server_id} failed: HTTP {resp.status_code}: {resp.text[:500]}"
- )
-
-
-# ---------------------------------------------------------------------------
-# Main
-# ---------------------------------------------------------------------------
-def parse_args(argv: Optional[list[str]] = None) -> argparse.Namespace:
- parser = argparse.ArgumentParser(description=__doc__.splitlines()[0] if __doc__ else "")
- parser.add_argument(
- "--dry-run",
- action="store_true",
- help="Compute gating + hashes and log what would change, without calling the "
- "local queue or writing to Supabase.",
- )
- parser.add_argument(
- "--limit",
- type=int,
- default=None,
- help="Cap the number of servers processed this run (after gating). Useful for "
- "testing or throttling a single nightly window.",
- )
- parser.add_argument(
- "--prompt-version",
- default=DEFAULT_PROMPT_VERSION,
- help=f"Value stored in ai_summary_prompt_version (default: {DEFAULT_PROMPT_VERSION}). "
- "Bump this to force full regeneration on the next run for all gated servers "
- "(they'll have a stale prompt version but the source hash alone won't change — "
- "see migration 006's column comment on ai_summary_prompt_version).",
- )
- parser.add_argument(
- "--page-size",
- type=int,
- default=DEFAULT_PAGE_SIZE,
- help=f"Supabase REST pagination page size (default: {DEFAULT_PAGE_SIZE}).",
- )
- parser.add_argument(
- "--sleep-seconds",
- type=float,
- default=0.5,
- help="Delay between local-queue calls, to avoid hammering the queue (default: 0.5s).",
- )
- parser.add_argument("-v", "--verbose", action="store_true", help="Debug-level logging.")
- return parser.parse_args(argv)
-
-
-def main(argv: Optional[list[str]] = None) -> int:
- args = parse_args(argv)
-
- logging.basicConfig(
- level=logging.DEBUG if args.verbose else logging.INFO,
- format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
- )
-
- supabase_url = os.environ.get("SUPABASE_URL")
- service_role_key = os.environ.get("SUPABASE_SERVICE_ROLE_KEY")
- local_queue_url = os.environ.get("CLAUDE_CODE_LOCAL_QUEUE_URL", DEFAULT_LOCAL_QUEUE_URL)
-
- if not supabase_url or not service_role_key:
- logger.error(
- "Missing SUPABASE_URL or SUPABASE_SERVICE_ROLE_KEY environment variables. "
- "Aborting — nothing was attempted."
- )
- return 1
-
- stats = RunStats()
-
- logger.info("Fetching candidate servers from Supabase...")
- try:
- candidates = fetch_candidate_servers(supabase_url, service_role_key, args.page_size)
- except SupabaseError as exc:
- logger.error("Failed to fetch candidate servers: %s", exc)
- return 1
-
- stats.scanned = len(candidates)
- logger.info("Fetched %d non-deprecated, non-archived candidate servers.", stats.scanned)
-
- gated = [row for row in candidates if is_indexable(row)]
- stats.gated = len(gated)
- logger.info(
- "%d of %d candidates clear the isIndexable() quality bar (>= %d of 5 signals).",
- stats.gated,
- stats.scanned,
- MIN_SIGNALS,
- )
-
- if args.limit is not None:
- gated = gated[: args.limit]
- logger.info("--limit %d applied — processing %d servers this run.", args.limit, len(gated))
-
- for row in gated:
- server_id = row.get("id")
- name = row.get("name") or server_id
- new_hash = compute_source_hash(row)
- existing_hash = row.get("ai_summary_source_hash")
-
- if existing_hash and existing_hash == new_hash:
- stats.skipped_unchanged += 1
- logger.debug("Skipping %r — source hash unchanged.", name)
- continue
-
- if args.dry_run:
- logger.info(
- "[dry-run] Would (re)generate ai_summary for %r (id=%s, hash %s -> %s).",
- name,
- server_id,
- existing_hash,
- new_hash,
- )
- stats.generated += 1
- continue
-
- try:
- summary = generate_summary(row, base_url=local_queue_url)
- update_server_summary(
- supabase_url,
- service_role_key,
- server_id=server_id,
- ai_summary=summary,
- ai_summary_source_hash=new_hash,
- prompt_version=args.prompt_version,
- )
- stats.generated += 1
- logger.info("Generated + stored ai_summary for %r (id=%s).", name, server_id)
- except (LocalQueueError, SupabaseError) as exc:
- stats.failed += 1
- logger.error("Failed to (re)generate ai_summary for %r (id=%s): %s", name, server_id, exc)
-
- time.sleep(args.sleep_seconds)
-
- logger.info(
- "Done. scanned=%d gated=%d skipped_unchanged=%d generated=%d failed=%d",
- stats.scanned,
- stats.gated,
- stats.skipped_unchanged,
- stats.generated,
- stats.failed,
- )
-
- return 2 if stats.failed > 0 else 0
-
-
-if __name__ == "__main__":
- sys.exit(main())
diff --git a/scripts/mcpfind/requirements.txt b/scripts/mcpfind/requirements.txt
deleted file mode 100644
index 5dd01bc..0000000
--- a/scripts/mcpfind/requirements.txt
+++ /dev/null
@@ -1,13 +0,0 @@
-# Dependencies for scripts/mcpfind/ai_summary_generate.py (and future scripts
-# in this directory). This repo is otherwise a pnpm/Node monorepo with no
-# existing Python tooling, so this is a minimal standalone requirements file
-# rather than a full poetry/uv project.
-#
-# Run via a venv:
-# python3 -m venv .venv && source .venv/bin/activate
-# pip install -r scripts/mcpfind/requirements.txt
-# python3 scripts/mcpfind/ai_summary_generate.py
-#
-# Or via uv, without a persistent venv:
-# uv run --with requests scripts/mcpfind/ai_summary_generate.py
-requests>=2.31.0,<3
diff --git a/supabase/migrations/006_ai_summary.sql b/supabase/migrations/006_ai_summary.sql
deleted file mode 100644
index 5082500..0000000
--- a/supabase/migrations/006_ai_summary.sql
+++ /dev/null
@@ -1,48 +0,0 @@
--- Migration 006: AI-generated server summaries
--- Adds five provenance columns to the servers table for the AI summary pipeline.
--- See specs/mcpfind/mcpfind-server-ai-summary-pipeline.md for the full design.
---
--- Notes:
--- • search_vector trigger deliberately NOT updated — generated prose is for AI
--- passage extraction, not for re-weighting on-site full-text search.
--- • RLS already allows public SELECT on servers; the new columns inherit it.
--- No new policy is needed.
--- • These columns will be NULL for all existing rows until the Mac Mini nightly
--- ai_summary_generate.py orchestrator backfills them.
--- • Migration numbering: 001/002/003/005 → 006 (004 was intentionally skipped).
-
-ALTER TABLE servers ADD COLUMN IF NOT EXISTS ai_summary TEXT;
-ALTER TABLE servers ADD COLUMN IF NOT EXISTS ai_summary_generated_at TIMESTAMPTZ;
-ALTER TABLE servers ADD COLUMN IF NOT EXISTS ai_summary_model TEXT;
-ALTER TABLE servers ADD COLUMN IF NOT EXISTS ai_summary_prompt_version TEXT;
-ALTER TABLE servers ADD COLUMN IF NOT EXISTS ai_summary_source_hash TEXT;
-
--- Index for incremental orchestrator: quickly find servers that need a new summary.
--- Covers: (a) never summarized, (b) source changed, (c) prompt version bumped.
-CREATE INDEX IF NOT EXISTS idx_servers_ai_summary_null
- ON servers (id)
- WHERE ai_summary IS NULL;
-
--- Comment block to document intent alongside the schema.
-COMMENT ON COLUMN servers.ai_summary IS
- 'AI-generated 150-200 word factual summary generated via local Mac Mini queue. '
- 'NULL until backfilled. Labeled supplement ONLY — must render as a clearly labeled '
- '"AI Summary" block, never substituted into structured-data SoftwareApplication.description '
- 'or used as primary on-page body copy (see specs/stage-6-slices/00-recovery-plan.md, '
- 'Guardrails: anti scaled-content-abuse).';
-
-COMMENT ON COLUMN servers.ai_summary_generated_at IS
- 'Timestamp when ai_summary was last generated or regenerated.';
-
-COMMENT ON COLUMN servers.ai_summary_model IS
- 'Identifies the generation path, e.g. "claude-code-local-queue". '
- 'Not a raw Anthropic model ID — generation runs through 127.0.0.1:7600.';
-
-COMMENT ON COLUMN servers.ai_summary_prompt_version IS
- 'Prompt template version string (e.g. "v1"). '
- 'Bump to trigger full regeneration for all servers on the next nightly run.';
-
-COMMENT ON COLUMN servers.ai_summary_source_hash IS
- 'SHA-256 of the source fields used for generation: '
- 'name|description|readme_content[:8000]|category|package_name|package_type|registry_tags. '
- 'Enables skip-if-unchanged idempotency without querying ai_summary content.';