From 2fb415ddddb54dfd8f747700c43923606d9af6ab Mon Sep 17 00:00:00 2001 From: Dmitry Ginzburg <33052194+Ultraivanov@users.noreply.github.com> Date: Thu, 16 Apr 2026 01:31:08 +0300 Subject: [PATCH 01/46] Add V2 roadmap for CMS and AI acceleration --- docs/roadmaps/2026-04-16-v2-cms-ai-roadmap.md | 210 ++++++++++++++++++ 1 file changed, 210 insertions(+) create mode 100644 docs/roadmaps/2026-04-16-v2-cms-ai-roadmap.md diff --git a/docs/roadmaps/2026-04-16-v2-cms-ai-roadmap.md b/docs/roadmaps/2026-04-16-v2-cms-ai-roadmap.md new file mode 100644 index 0000000..a3c91d4 --- /dev/null +++ b/docs/roadmaps/2026-04-16-v2-cms-ai-roadmap.md @@ -0,0 +1,210 @@ +# Portfolio V2 Roadmap (CMS + AI Acceleration) + +Date: 2026-04-16 +Owner: Dima Ginzburg + +## 1) Why V2 + +Current site is stable in production, but case production is still too manual and slow. +V2 is a separate delivery track focused on: + +- faster case creation +- stronger CMS reliability +- repeatable visual system for engineering-oriented case covers +- AI-assisted intake from multiple sources (not only Figma) + +## 2) V2 Delivery Model (Safe Zone) + +Recommended setup: + +- Git branch family: `codex/v2-*` +- Separate Vercel project for V2 previews (or dedicated V2 environment) +- Optional V2 domain: `v2.ginzburg.work` +- Merge to production only after phased validation + +Release rule: + +- `main` stays conservative and client-safe +- V2 features land behind flags and are validated in isolated previews first + +## 3) Scope of V2 + +### A. CMS Reliability + Speed + +- Modularize admin editor (split monolith editor into smaller blocks/hooks) +- Field-level validation and save-state UX +- Upload preflight + deterministic error feedback +- Local drafts + conflict-safe save flow + +### B. Image Preprocessor (Raster + SVG) + +Goal: never fail upload because of platform limits unless file is fundamentally invalid. + +Pipeline: + +1. Client preflight (size/mime/dimensions) +2. Progressive compression loop (quality + resize) +3. Optional WebP conversion +4. Hard stop under configured target bytes +5. Server fallback transform if client side misses target + +Output in CMS: + +- Before/after size +- Processing status +- Clear reason if rejected + +### C. Cover Generator (Blueprint System) + +Goal: consistent, engineering-style case covers with low manual effort. + +Visual system: + +- blueprint base (blue paper, grid, linework) +- strict typography template +- case title + optional short subtitle +- deterministic style variants by case angle: + - `behavioral-model` + - `ux-driven` + - `agentic-flow` + +Result: + +- repeatable output +- visually coherent library +- quick regeneration when case wording changes + +### D. AI Case Intake Assistant + +Goal: create high-quality case drafts quickly from real project artifacts. + +Primary source adapters: + +1. Figma adapter (existing workflow) +2. GitHub adapter (new priority for AI projects) +3. Optional text/doc adapters later (Notion/Docs/Markdown folders) + +GitHub adapter extracts: + +- README, docs, ADRs +- key PRs, issues, milestones +- architecture signals (modules, services, APIs) +- timeline of decisions and outcomes + +Then it generates a CMS draft mapped to containers: + +- Context +- Problem +- Constraints +- Role +- Approach +- Solution +- Outcome + +Important: draft-only mode by default (human review before save/publish). + +## 4) AI Integrations to Accelerate Production + +### 4.1 Repo -> Case Draft + +- Input: GitHub repo URL +- Output: structured draft + evidence links per section +- Benefit: AI projects without Figma are still fast to convert into portfolio cases + +### 4.2 Narrative Gap Detector + +- Finds weak or missing sections (e.g. no measurable outcome, unclear constraints) +- Suggests concrete rewrites with confidence score + +### 4.3 Artifact-to-Block Auto Mapper + +- Maps artifacts to CMS block types automatically: + - diagrams/images -> media + - PR/issue references -> link + - bullet evidence -> list + - narrative synthesis -> paragraph + +### 4.4 Case Consistency QA Bot + +- Checks tone, structure, section order, verbosity, and claims vs evidence +- Flags unsupported statements and missing proof + +### 4.5 One-Click “Case Starter” + +- User submits source URL(s) +- System generates: + - draft case JSON + - cover candidate (blueprint mode) + - suggested title/subtitle variants + +## 5) Implementation Phases + +## Phase 0 — Foundation (0.5 day) + +- Lint/test/build baseline cleanup for V2 branch +- CI gates for V2 branch +- Feature-flag skeleton + +Deliverable: reliable engineering baseline for fast iteration. + +## Phase 1 — Upload & Preprocessor (1-2 days) + +- Add robust raster preprocessor pipeline +- Add preflight checks + user feedback states +- Add tests for oversize/error/timeout cases + +Deliverable: upload success rate near 100% for valid inputs. + +## Phase 2 — Blueprint Cover Generator (1-2 days) + +- Implement generator templates + mode variants +- Add “Generate cover” action in CMS +- Add deterministic naming/storage conventions + +Deliverable: repeatable engineering-style covers in minutes. + +## Phase 3 — GitHub Intake to Draft (2-4 days) + +- Implement GitHub source adapter +- Build draft composer to CMS schema +- Add review/confirm step before save + +Deliverable: from repo docs to editable case draft with evidence. + +## Phase 4 — CMS UX + Quality Layer (1-2 days) + +- Split editor into modules +- Add sticky save bar and inline validation +- Add quality checklist before publish + +Deliverable: faster, safer authoring flow with fewer content losses. + +## 6) Risks and Controls + +Risks: + +- AI hallucination in generated case copy +- noisy GitHub sources (incomplete docs) +- visual generator drift from brand system + +Controls: + +- evidence-linked generation (claim -> source) +- draft-only workflow (human approval required) +- template locking for cover system + +## 7) Success Metrics + +- Time-to-first-draft per case: target < 30 minutes +- Manual effort reduction per case: target 40-60% +- Upload failure rate for valid files: target < 2% +- Shareable case throughput per month: +2x from current baseline + +## 8) Immediate Next Steps + +1. Approve V2 scope and phase order +2. Start Phase 0 on `codex/v2-*` branch line +3. Implement Phase 1 (preprocessor) first +4. Parallel-design blueprint cover templates while Phase 1 is in progress +5. Start GitHub adapter as first AI-source expansion + From 353515fbeff69e87ea0832bc1eeba61169044503 Mon Sep 17 00:00:00 2001 From: Dmitry Ginzburg <33052194+Ultraivanov@users.noreply.github.com> Date: Thu, 16 Apr 2026 01:49:30 +0300 Subject: [PATCH 02/46] Add GitHub draft intake MVP and blueprint cover samples --- docs/specs/2026-04-16-github-intake-mvp.md | 76 +++ .../blueprint-samples/cover-agentic-flow.svg | 70 +++ .../cover-behavioral-model.svg | 68 +++ public/blueprint-samples/cover-ux-driven.svg | 67 +++ src/app/admin/page.tsx | 146 +++++ src/app/api/intake/github/route.ts | 71 +++ src/lib/__tests__/github-case-intake.test.ts | 84 +++ src/lib/github-case-intake.ts | 502 ++++++++++++++++++ 8 files changed, 1084 insertions(+) create mode 100644 docs/specs/2026-04-16-github-intake-mvp.md create mode 100644 public/blueprint-samples/cover-agentic-flow.svg create mode 100644 public/blueprint-samples/cover-behavioral-model.svg create mode 100644 public/blueprint-samples/cover-ux-driven.svg create mode 100644 src/app/api/intake/github/route.ts create mode 100644 src/lib/__tests__/github-case-intake.test.ts create mode 100644 src/lib/github-case-intake.ts diff --git a/docs/specs/2026-04-16-github-intake-mvp.md b/docs/specs/2026-04-16-github-intake-mvp.md new file mode 100644 index 0000000..d86bb27 --- /dev/null +++ b/docs/specs/2026-04-16-github-intake-mvp.md @@ -0,0 +1,76 @@ +# GitHub -> CMS Intake MVP + +Date: 2026-04-16 +Branch: `codex/v2-roadmap-cms-ai` + +## Goal + +Generate an editable case-study draft in CMS from a GitHub repository URL, especially for AI projects that do not have Figma artifacts. + +## Scope (MVP) + +Input: +- GitHub repository URL +- Focus angle: `ux-driven` | `behavioral-model` | `agentic-flow` + +Output: +- Draft case JSON mapped into CMS structure: + - Context + - Problem + - Constraints + - Role + - Approach + - Solution + - Outcome +- Evidence links (repo + selected PR/issue URLs) + +Out of scope: +- Auto-publish +- Perfect semantic accuracy without human review +- Full screenshot crawler and Storybook extraction (next phase) + +## Architecture + +1. `POST /api/intake/github` + - validates repository URL + - fetches repository data through GitHub API + - returns generated draft + evidence links + +2. `src/lib/github-case-intake.ts` + - URL parsing + - repository signal fetching (README, merged PRs, closed issues) + - heuristic mapping into case schema + +3. Admin UI integration + - new AI intake block + - draft generation trigger + - user confirmation before replacing current form data + - evidence list for transparency + +## Data Sources (MVP) + +- Repository metadata (`/repos/{owner}/{repo}`) +- README (`/repos/{owner}/{repo}/readme`) +- Closed merged PRs (`/pulls`) +- Closed issues (`/issues`, excluding PR entries) + +## Safety & Reliability + +- Draft-only behavior (manual review before save) +- Clear error propagation for invalid URL / GitHub failures +- Evidence links exposed in UI for human verification +- Existing local draft behavior retained + +## Limitations + +- Heuristic extraction may miss nuanced design decisions +- Repository text quality strongly affects output quality +- No automatic screenshots from runtime UI yet + +## Next Iterations + +1. Add runtime screenshot capture for key routes. +2. Add commit-to-feature clustering to isolate UX-impacting changes. +3. Add confidence scoring per generated section. +4. Add “quality gate” checklist before save. + diff --git a/public/blueprint-samples/cover-agentic-flow.svg b/public/blueprint-samples/cover-agentic-flow.svg new file mode 100644 index 0000000..adf171c --- /dev/null +++ b/public/blueprint-samples/cover-agentic-flow.svg @@ -0,0 +1,70 @@ + + + + + + + + + + + + + + + + + + + + + SYSTEM CASE COVER / AGENTIC FLOW + + + MEGAMOD + + + Structuring Multi-Product Meaning Through Agentic Entry Points + + + + ANGLE: AGENTIC-FLOW + + + + INTENT + + + ORCHESTRATOR + + + POLICY + + + TOOLING + + + OUTCOME + + + + + + + + + + + + + + + + + + + + + blueprint-kit.v0 / repeatable cover system / engineering narrative emphasis + + diff --git a/public/blueprint-samples/cover-behavioral-model.svg b/public/blueprint-samples/cover-behavioral-model.svg new file mode 100644 index 0000000..915d649 --- /dev/null +++ b/public/blueprint-samples/cover-behavioral-model.svg @@ -0,0 +1,68 @@ + + + + + + + + + + + + + + + + + + + + + SYSTEM CASE COVER / BEHAVIORAL MODEL + + + TRAVEL BOOKING PLATFORM + + + Decision Architecture Before Inventory Exposure + + + + ANGLE: BEHAVIORAL-MODEL + + + + + + + + + + + INTENT + CONTEXT + ELIGIBILITY + OPTION SET + COMMIT + + + + + + + + + + + + + + + + + + + + state logic emphasis / constraints before commitment / reusable blueprint family + + diff --git a/public/blueprint-samples/cover-ux-driven.svg b/public/blueprint-samples/cover-ux-driven.svg new file mode 100644 index 0000000..7c39073 --- /dev/null +++ b/public/blueprint-samples/cover-ux-driven.svg @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + SYSTEM CASE COVER / UX DRIVEN + + + RAILWAY BOOKING FLOW + + + From Fragmented Steps to Operational Continuity + + + + ANGLE: UX-DRIVEN + + + + + + + + + + + DISCOVERY + VALIDATION + SELECTION + REVIEW + CONFIRMATION + + + + + + + + + + + + + + + + + + + flow readability emphasis / user decision continuity / production-ready cover preset + + diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index dac978c..fcb90db 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -79,6 +79,15 @@ interface CaseDraftEnvelope { data: CaseStudy; } +type IntakeFocus = "ux-driven" | "behavioral-model" | "agentic-flow"; + +interface GitHubIntakeApiResponse { + ok?: boolean; + draft?: CaseStudy; + evidence?: string[]; + error?: string | { message?: string }; +} + const MEDIA_UPLOAD_TIMEOUT_MS = 90_000; const MAX_CLIENT_UPLOAD_BYTES = 3_500_000; // Keep request below Vercel function payload ceiling. const DRAFT_STORAGE_PREFIX = "cms-case-draft:"; @@ -153,6 +162,10 @@ export default function AdminPage() { >({}); const [availableDraft, setAvailableDraft] = useState(null); const [draftSavedAt, setDraftSavedAt] = useState(null); + const [githubRepoUrl, setGitHubRepoUrl] = useState(""); + const [githubFocus, setGitHubFocus] = useState("ux-driven"); + const [generatingGitHubDraft, setGeneratingGitHubDraft] = useState(false); + const [githubEvidence, setGitHubEvidence] = useState([]); const getBlockKey = (sectionIndex: number, blockIndex: number): string => `${sectionIndex}:${blockIndex}`; @@ -596,6 +609,74 @@ export default function AdminPage() { } }; + const applyGeneratedDraft = (draft: CaseStudy) => { + const normalizedDraft: CaseStudy = { + ...draft, + slug: selectedCase, + title: draft.title || caseData?.title || selectedCase, + subtitle: draft.subtitle || caseData?.subtitle || "", + coverSrc: draft.coverSrc || "/cases/example/cover.png", + coverAlt: draft.coverAlt || `${draft.title || selectedCase} cover`, + facts: Array.isArray(draft.facts) ? draft.facts : [], + sections: Array.isArray(draft.sections) ? draft.sections : [], + }; + + setCaseData(normalizedDraft); + const savedDraft = writeCaseDraft(selectedCase, normalizedDraft); + if (savedDraft) { + setDraftSavedAt(savedDraft.updatedAt); + setAvailableDraft(null); + } + }; + + const handleGenerateGitHubDraft = async () => { + if (!githubRepoUrl.trim()) { + setMessage("❌ GitHub repository URL is required."); + return; + } + + setGeneratingGitHubDraft(true); + setMessage(""); + try { + const response = await fetch("/api/intake/github", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + repoUrl: githubRepoUrl.trim(), + focus: githubFocus, + }), + }); + + const payload = (await response.json()) as GitHubIntakeApiResponse; + if (!response.ok || !payload.draft) { + setMessage(`❌ Draft generation failed: ${getApiErrorMessage(payload)}`); + return; + } + + setGitHubEvidence(Array.isArray(payload.evidence) ? payload.evidence : []); + + const shouldApply = window.confirm( + "Replace current case form with generated draft? Local draft is still available via browser storage." + ); + + if (!shouldApply) { + setMessage("ℹ️ Draft generated. Apply cancelled."); + return; + } + + applyGeneratedDraft(payload.draft); + setMessage("✅ GitHub draft generated and applied. Review sections, then save."); + } catch (error) { + setMessage( + `❌ Draft generation failed: ${ + error instanceof Error ? error.message : "Unknown error" + }` + ); + } finally { + setGeneratingGitHubDraft(false); + } + }; + // Section management const updateSection = (sectionIndex: number, field: keyof Section, value: string) => { if (!caseData) return; @@ -703,6 +784,71 @@ export default function AdminPage() { +
+ +
+ setGitHubRepoUrl(e.target.value)} + style={{ ...inputStyle, flex: 1, minWidth: 320 }} + placeholder="https://github.com/owner/repo" + /> + + +
+

+ Generates a draft from README + issues + merged PRs. Review carefully before saving. +

+ {githubEvidence.length > 0 ? ( +
+ + Evidence links ({githubEvidence.length}) + +
    + {githubEvidence.slice(0, 8).map((href) => ( +
  • + + {href} + +
  • + ))} +
+
+ ) : null} +
+
= new Set([ + "ux-driven", + "behavioral-model", + "agentic-flow", +]); + +export async function POST(request: Request) { + try { + const payload = (await request.json()) as GitHubIntakePayload; + const repoUrl = typeof payload.repoUrl === "string" ? payload.repoUrl.trim() : ""; + const focus = normalizeFocus(payload.focus); + + if (!repoUrl) { + return apiError(400, "INVALID_REQUEST", "repoUrl is required."); + } + + const repoRef = parseGitHubRepoUrl(repoUrl); + if (!repoRef) { + return apiError( + 400, + "INVALID_REPO_URL", + "Provide a valid GitHub repository URL like https://github.com/owner/repo." + ); + } + + const signals = await fetchGitHubSignals({ + owner: repoRef.owner, + repo: repoRef.repo, + token: process.env.GITHUB_PAT, + }); + + const { draft, evidence } = buildCaseDraftFromSignals(signals, focus); + + return apiSuccess({ + draft, + evidence, + source: { + owner: repoRef.owner, + repo: repoRef.repo, + focus, + }, + }); + } catch (error) { + return apiError( + 500, + "GITHUB_INTAKE_FAILED", + error instanceof Error ? error.message : "Failed to generate draft from GitHub" + ); + } +} + +function normalizeFocus(value: unknown): IntakeFocus { + if (typeof value === "string" && ALLOWED_FOCUS.has(value as IntakeFocus)) { + return value as IntakeFocus; + } + return "ux-driven"; +} + diff --git a/src/lib/__tests__/github-case-intake.test.ts b/src/lib/__tests__/github-case-intake.test.ts new file mode 100644 index 0000000..069ebb2 --- /dev/null +++ b/src/lib/__tests__/github-case-intake.test.ts @@ -0,0 +1,84 @@ +import { + buildCaseDraftFromSignals, + parseGitHubRepoUrl, + type GitHubSignals, +} from "@/lib/github-case-intake"; + +describe("parseGitHubRepoUrl", () => { + it("parses standard repository URLs", () => { + expect(parseGitHubRepoUrl("https://github.com/vercel/next.js")).toEqual({ + owner: "vercel", + repo: "next.js", + }); + }); + + it("parses URLs with extra path and strips .git suffix", () => { + expect( + parseGitHubRepoUrl("https://github.com/Ultraivanov/portfolio.git/issues/12") + ).toEqual({ + owner: "Ultraivanov", + repo: "portfolio", + }); + }); + + it("returns null for unsupported hosts", () => { + expect(parseGitHubRepoUrl("https://gitlab.com/group/repo")).toBeNull(); + }); +}); + +describe("buildCaseDraftFromSignals", () => { + const signals: GitHubSignals = { + repo: { + name: "agent-workbench", + full_name: "acme/agent-workbench", + description: "Orchestrated agent workflows for support operations", + html_url: "https://github.com/acme/agent-workbench", + stargazers_count: 42, + forks_count: 7, + open_issues_count: 3, + default_branch: "main", + language: "TypeScript", + }, + readme: + "Agent Workbench\n\nThis system improves user flow and reduces onboarding friction for support teams.", + mergedPulls: [ + { + title: "Improve onboarding flow and validation states", + body: "Added clearer UX for empty states and error handling.", + html_url: "https://github.com/acme/agent-workbench/pull/101", + merged_at: "2026-04-10T00:00:00Z", + }, + ], + closedIssues: [ + { + title: "Confusing navigation in onboarding", + body: "Users fail to complete setup.", + html_url: "https://github.com/acme/agent-workbench/issues/89", + }, + ], + }; + + it("produces a valid draft with expected structural sections", () => { + const { draft, evidence } = buildCaseDraftFromSignals(signals, "ux-driven"); + + expect(draft.slug).toBe("agent-workbench"); + expect(draft.title).toBe("Agent Workbench"); + expect(draft.sections.map((section) => section.title)).toEqual([ + "Context", + "Problem", + "Constraints", + "Role", + "Approach", + "Solution", + "Outcome", + ]); + expect(draft.facts.length).toBeGreaterThan(0); + expect(evidence).toContain("https://github.com/acme/agent-workbench"); + }); + + it("switches subtitle based on selected focus", () => { + const { draft } = buildCaseDraftFromSignals(signals, "agentic-flow"); + expect(draft.subtitle.toLowerCase()).toContain("agentic flow"); + }); +}); + diff --git a/src/lib/github-case-intake.ts b/src/lib/github-case-intake.ts new file mode 100644 index 0000000..d5c568f --- /dev/null +++ b/src/lib/github-case-intake.ts @@ -0,0 +1,502 @@ +import { fetchGitHubWithRetry } from "@/lib/github-api"; + +export type CaseBlock = + | { discriminant: "paragraph"; value: { text: string } } + | { discriminant: "list"; value: { items: string[] } } + | { discriminant: "link"; value: { label: string; href: string } } + | { discriminant: "media"; value: { src: string; alt: string; caption?: string } }; + +export type CaseDraft = { + slug: string; + title: string; + subtitle: string; + coverSrc: string; + coverAlt: string; + facts: Array<{ label: string; value: string | string[]; href?: string }>; + sections: Array<{ title: string; blocks: CaseBlock[] }>; + seo?: { + metaTitle?: string; + metaDescription?: string; + ogImage?: string; + }; +}; + +export type IntakeFocus = "behavioral-model" | "ux-driven" | "agentic-flow"; + +export type GitHubRepoRef = { + owner: string; + repo: string; +}; + +type GitHubRepoInfo = { + name: string; + full_name: string; + description: string | null; + html_url: string; + stargazers_count: number; + forks_count: number; + open_issues_count: number; + default_branch: string; + language: string | null; +}; + +type GitHubPullRequest = { + title: string; + body: string | null; + html_url: string; + merged_at: string | null; +}; + +type GitHubIssue = { + title: string; + body: string | null; + html_url: string; + pull_request?: unknown; +}; + +export type GitHubSignals = { + repo: GitHubRepoInfo; + readme: string; + mergedPulls: GitHubPullRequest[]; + closedIssues: GitHubIssue[]; +}; + +export function parseGitHubRepoUrl(value: string): GitHubRepoRef | null { + const raw = value.trim(); + if (!raw) return null; + + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + return null; + } + + if (parsed.hostname !== "github.com") { + return null; + } + + const parts = parsed.pathname + .replace(/^\/+|\/+$/g, "") + .split("/") + .filter(Boolean); + + if (parts.length < 2) { + return null; + } + + const owner = parts[0]; + const repo = parts[1]?.replace(/\.git$/i, ""); + + if (!owner || !repo) { + return null; + } + + return { owner, repo }; +} + +export async function fetchGitHubSignals(params: { + owner: string; + repo: string; + token?: string; +}): Promise { + const { owner, repo, token } = params; + const base = `https://api.github.com/repos/${owner}/${repo}`; + + const repoResponse = await fetchGitHubWithRetry(`${base}`, { + headers: buildHeaders(token), + }); + + if (!repoResponse.ok) { + throw new Error(await readGitHubError(repoResponse, "Failed to load repository")); + } + + const repoJson = (await repoResponse.json()) as GitHubRepoInfo; + + const readmeResponse = await fetchGitHubWithRetry(`${base}/readme`, { + headers: { + ...buildHeaders(token), + Accept: "application/vnd.github.raw+json", + }, + }); + + const readme = + readmeResponse.ok && readmeResponse.status !== 204 + ? await readmeResponse.text() + : ""; + + const pullsResponse = await fetchGitHubWithRetry( + `${base}/pulls?state=closed&sort=updated&direction=desc&per_page=30`, + { + headers: buildHeaders(token), + } + ); + const pullsJson = pullsResponse.ok + ? ((await pullsResponse.json()) as GitHubPullRequest[]) + : []; + const mergedPulls = pullsJson.filter((pr) => Boolean(pr.merged_at)).slice(0, 12); + + const issuesResponse = await fetchGitHubWithRetry( + `${base}/issues?state=closed&sort=updated&direction=desc&per_page=30`, + { + headers: buildHeaders(token), + } + ); + const issuesJson = issuesResponse.ok + ? ((await issuesResponse.json()) as GitHubIssue[]) + : []; + const closedIssues = issuesJson + .filter((issue) => !issue.pull_request) + .slice(0, 12); + + return { + repo: repoJson, + readme, + mergedPulls, + closedIssues, + }; +} + +export function buildCaseDraftFromSignals( + signals: GitHubSignals, + focus: IntakeFocus = "ux-driven" +): { draft: CaseDraft; evidence: string[] } { + const { repo, readme, mergedPulls, closedIssues } = signals; + const repoSlug = slugify(repo.name || repo.full_name.split("/").pop() || "case"); + const title = toCaseTitle(repo.name || repoSlug); + const repoUrl = repo.html_url; + + const evidenceLinks: string[] = [repoUrl]; + for (const pr of mergedPulls.slice(0, 5)) { + evidenceLinks.push(pr.html_url); + } + for (const issue of closedIssues.slice(0, 5)) { + evidenceLinks.push(issue.html_url); + } + + const textPool = [ + readme, + ...mergedPulls.flatMap((pr) => [pr.title, pr.body ?? ""]), + ...closedIssues.flatMap((issue) => [issue.title, issue.body ?? ""]), + ]; + + const problemItems = extractSignalItems(textPool, PROBLEM_KEYWORDS, 4); + const constraintItems = extractSignalItems(textPool, CONSTRAINT_KEYWORDS, 4); + const solutionItems = extractSignalItems(textPool, SOLUTION_KEYWORDS_BY_FOCUS[focus], 5); + + const subtitle = focusSubtitle(focus); + const contextIntro = firstMeaningfulParagraph(readme) || + repo.description || + "Repository artifacts indicate an actively evolving product system with design-impacting decisions."; + + const sections: Array<{ title: string; blocks: CaseBlock[] }> = [ + { + title: "Context", + blocks: [ + { + discriminant: "paragraph", + value: { + text: `Source analyzed: ${repo.full_name}.\n\n${contextIntro}`, + }, + }, + ], + }, + { + title: "Problem", + blocks: [ + { + discriminant: "paragraph", + value: { + text: + "Based on repository issues and pull requests, the product had friction points that affected clarity, flow quality, or decision confidence.", + }, + }, + { + discriminant: "list", + value: { + items: + problemItems.length > 0 + ? problemItems + : [ + "Multiple user journeys and states needed better consistency.", + "Design intent was distributed across issues and PR discussions.", + ], + }, + }, + ], + }, + { + title: "Constraints", + blocks: [ + { + discriminant: "list", + value: { + items: + constraintItems.length > 0 + ? constraintItems + : [ + "Work had to fit existing architecture and release rhythm.", + "Changes needed to remain compatible with production UI patterns.", + ], + }, + }, + ], + }, + { + title: "Role", + blocks: [ + { + discriminant: "paragraph", + value: { + text: + "Design interpretation and system framing based on repository artifacts (README, docs, issues, and merged pull requests). Final narrative should be reviewed and refined by the case owner.", + }, + }, + ], + }, + { + title: "Approach", + blocks: [ + { + discriminant: "list", + value: { + items: [ + "Mapped user-facing changes from merged pull requests.", + "Grouped decisions by flow, interaction behavior, and system constraints.", + `Framed the case through the selected angle: ${focus}.`, + ], + }, + }, + ], + }, + { + title: "Solution", + blocks: [ + { + discriminant: "list", + value: { + items: + solutionItems.length > 0 + ? solutionItems + : [ + "Introduced clearer interaction logic across critical flows.", + "Aligned implementation details with consistent product behavior.", + ], + }, + }, + ], + }, + { + title: "Outcome", + blocks: [ + { + discriminant: "list", + value: { + items: [ + `Repository stars: ${repo.stargazers_count}`, + `Repository forks: ${repo.forks_count}`, + `Open issues at analysis time: ${repo.open_issues_count}`, + `${mergedPulls.length} merged PRs were used as implementation evidence.`, + ], + }, + }, + { + discriminant: "link", + value: { + label: "Primary source repository", + href: repoUrl, + }, + }, + ], + }, + ]; + + const draft: CaseDraft = { + slug: repoSlug, + title, + subtitle, + coverSrc: "/cases/example/cover.png", + coverAlt: `${title} case cover`, + facts: [ + { + label: "domain", + value: "AI-enabled digital product", + }, + { + label: "role", + value: "Product/UX design analysis from repository evidence", + }, + { + label: "repository", + value: repo.full_name, + href: repoUrl, + }, + { + label: "scope", + value: [ + "README/docs interpretation", + `${mergedPulls.length} merged PRs reviewed`, + `${closedIssues.length} closed issues reviewed`, + ], + }, + ], + sections, + seo: { + metaTitle: `${title} | Case Study`, + metaDescription: subtitle, + }, + }; + + return { + draft, + evidence: Array.from(new Set(evidenceLinks)), + }; +} + +function buildHeaders(token?: string): Record { + const headers: Record = { + Accept: "application/vnd.github+json", + }; + if (token) { + headers.Authorization = `Bearer ${token}`; + } + return headers; +} + +async function readGitHubError(response: Response, fallback: string): Promise { + try { + const payload = (await response.json()) as { message?: string }; + return payload.message || fallback; + } catch { + return fallback; + } +} + +function firstMeaningfulParagraph(markdown: string): string { + const cleaned = markdown + .split("\n") + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith("#") && !line.startsWith("![")) // remove headings/images + .join("\n"); + + const paragraph = cleaned.split(/\n{2,}/).find((chunk) => chunk.trim().length > 60); + if (!paragraph) return ""; + return paragraph.replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1").trim(); +} + +function extractSignalItems( + texts: string[], + keywords: readonly string[], + limit: number +): string[] { + const rows = texts + .flatMap((text) => splitToCandidateLines(text)) + .map((line) => normalizeSentence(line)) + .filter((line) => line.length >= 28 && line.length <= 180) + .filter((line) => containsKeyword(line, keywords)); + + return Array.from(new Set(rows)).slice(0, limit); +} + +function splitToCandidateLines(text: string): string[] { + return text + .split(/\n|\. |; |\u2022|- /g) + .map((part) => part.trim()) + .filter(Boolean); +} + +function normalizeSentence(value: string): string { + return value + .replace(/`/g, "") + .replace(/\s+/g, " ") + .replace(/\[([^\]]+)\]\(([^)]+)\)/g, "$1") + .replace(/^[-*]\s*/, "") + .trim(); +} + +function containsKeyword(text: string, keywords: readonly string[]): boolean { + const lower = text.toLowerCase(); + return keywords.some((word) => lower.includes(word)); +} + +function focusSubtitle(focus: IntakeFocus): string { + if (focus === "behavioral-model") { + return "Behavioral model case draft generated from repository artifacts"; + } + if (focus === "agentic-flow") { + return "Agentic flow case draft generated from repository artifacts"; + } + return "UX-driven case draft generated from repository artifacts"; +} + +function slugify(value: string): string { + return value + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 64) || "generated-case"; +} + +function toCaseTitle(value: string): string { + return value + .replace(/[-_]+/g, " ") + .replace(/\s+/g, " ") + .trim() + .replace(/\b\w/g, (char) => char.toUpperCase()); +} + +const PROBLEM_KEYWORDS = [ + "problem", + "issue", + "bug", + "friction", + "confus", + "broken", + "error", + "fail", +] as const; + +const CONSTRAINT_KEYWORDS = [ + "constraint", + "limit", + "tradeoff", + "compatib", + "legacy", + "performance", + "security", + "policy", +] as const; + +const SOLUTION_KEYWORDS_BY_FOCUS: Record = { + "ux-driven": [ + "ux", + "user", + "flow", + "navigation", + "onboarding", + "interaction", + "accessibility", + "layout", + ], + "behavioral-model": [ + "state", + "decision", + "validation", + "eligibility", + "rule", + "policy", + "logic", + "constraint", + ], + "agentic-flow": [ + "agent", + "ai", + "llm", + "tool", + "workflow", + "orchestrat", + "prompt", + "assistant", + ], +}; + From f929683da069c09bf6036a46a6efbd01550eb92b Mon Sep 17 00:00:00 2001 From: Dmitry Ginzburg <33052194+Ultraivanov@users.noreply.github.com> Date: Thu, 16 Apr 2026 02:01:58 +0300 Subject: [PATCH 03/46] Add runtime route crawl and screenshot planning for GitHub intake --- docs/specs/2026-04-16-github-intake-mvp.md | 7 +- src/app/admin/page.tsx | 89 +++++- src/app/api/intake/github/route.ts | 20 ++ src/lib/__tests__/github-case-intake.test.ts | 35 +++ src/lib/github-case-intake.ts | 274 +++++++++++++++++-- 5 files changed, 397 insertions(+), 28 deletions(-) diff --git a/docs/specs/2026-04-16-github-intake-mvp.md b/docs/specs/2026-04-16-github-intake-mvp.md index d86bb27..24dab77 100644 --- a/docs/specs/2026-04-16-github-intake-mvp.md +++ b/docs/specs/2026-04-16-github-intake-mvp.md @@ -23,6 +23,8 @@ Output: - Solution - Outcome - Evidence links (repo + selected PR/issue URLs) +- Route candidates discovered from `app/**/page.*` or `src/app/**/page.*` +- Runtime screenshot plan (if runtime base URL is provided) Out of scope: - Auto-publish @@ -40,12 +42,15 @@ Out of scope: - URL parsing - repository signal fetching (README, merged PRs, closed issues) - heuristic mapping into case schema + - route extraction from repository tree + - runtime screenshot URL planning 3. Admin UI integration - new AI intake block - draft generation trigger - user confirmation before replacing current form data - evidence list for transparency + - route and screenshot-plan preview ## Data Sources (MVP) @@ -66,6 +71,7 @@ Out of scope: - Heuristic extraction may miss nuanced design decisions - Repository text quality strongly affects output quality - No automatic screenshots from runtime UI yet +- Screenshot artifacts are generated as planned URLs (deterministic crawl plan), not binary storage in this phase. ## Next Iterations @@ -73,4 +79,3 @@ Out of scope: 2. Add commit-to-feature clustering to isolate UX-impacting changes. 3. Add confidence scoring per generated section. 4. Add “quality gate” checklist before save. - diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index fcb90db..b87ec23 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -85,6 +85,13 @@ interface GitHubIntakeApiResponse { ok?: boolean; draft?: CaseStudy; evidence?: string[]; + routeCandidates?: string[]; + runtimeScreenshots?: Array<{ + route: string; + pageUrl: string; + screenshotUrl: string; + status: "planned"; + }>; error?: string | { message?: string }; } @@ -164,8 +171,19 @@ export default function AdminPage() { const [draftSavedAt, setDraftSavedAt] = useState(null); const [githubRepoUrl, setGitHubRepoUrl] = useState(""); const [githubFocus, setGitHubFocus] = useState("ux-driven"); + const [githubRuntimeBaseUrl, setGitHubRuntimeBaseUrl] = useState(""); + const [githubScreenshotLimit, setGitHubScreenshotLimit] = useState(6); const [generatingGitHubDraft, setGeneratingGitHubDraft] = useState(false); const [githubEvidence, setGitHubEvidence] = useState([]); + const [githubRouteCandidates, setGitHubRouteCandidates] = useState([]); + const [githubRuntimeScreenshots, setGitHubRuntimeScreenshots] = useState< + Array<{ + route: string; + pageUrl: string; + screenshotUrl: string; + status: "planned"; + }> + >([]); const getBlockKey = (sectionIndex: number, blockIndex: number): string => `${sectionIndex}:${blockIndex}`; @@ -644,6 +662,8 @@ export default function AdminPage() { body: JSON.stringify({ repoUrl: githubRepoUrl.trim(), focus: githubFocus, + runtimeBaseUrl: githubRuntimeBaseUrl.trim() || undefined, + screenshotLimit: githubScreenshotLimit, }), }); @@ -654,6 +674,12 @@ export default function AdminPage() { } setGitHubEvidence(Array.isArray(payload.evidence) ? payload.evidence : []); + setGitHubRouteCandidates( + Array.isArray(payload.routeCandidates) ? payload.routeCandidates : [] + ); + setGitHubRuntimeScreenshots( + Array.isArray(payload.runtimeScreenshots) ? payload.runtimeScreenshots : [] + ); const shouldApply = window.confirm( "Replace current case form with generated draft? Local draft is still available via browser storage." @@ -828,8 +854,31 @@ export default function AdminPage() { {generatingGitHubDraft ? "Generating..." : "Generate Draft"}
+
+ setGitHubRuntimeBaseUrl(e.target.value)} + style={{ ...inputStyle, flex: 1, minWidth: 320 }} + placeholder="Runtime URL for screenshot crawl (optional), e.g. https://my-app.vercel.app" + /> + + setGitHubScreenshotLimit( + Math.max(1, Math.min(12, Number.parseInt(e.target.value || "6", 10) || 6)) + ) + } + style={{ ...inputStyle, width: 140, flex: "0 0 140px" }} + placeholder="Shots" + /> +

- Generates a draft from README + issues + merged PRs. Review carefully before saving. + Generates a draft from README + issues + merged PRs. If runtime URL is provided, it + also discovers app routes and prepares screenshot-crawl artifacts.

{githubEvidence.length > 0 ? (
@@ -847,6 +896,44 @@ export default function AdminPage() {
) : null} + {githubRouteCandidates.length > 0 ? ( +
+ + Route candidates ({githubRouteCandidates.length}) + +
    + {githubRouteCandidates.slice(0, 12).map((route) => ( +
  • + {route} +
  • + ))} +
+
+ ) : null} + {githubRuntimeScreenshots.length > 0 ? ( +
+ + Runtime screenshot plan ({githubRuntimeScreenshots.length}) + +
    + {githubRuntimeScreenshots.slice(0, 8).map((shot) => ( +
  • +
    + {shot.route} + {" -> "} + + page + {" "} + /{" "} + + screenshot + +
    +
  • + ))} +
+
+ ) : null}
diff --git a/src/app/api/intake/github/route.ts b/src/app/api/intake/github/route.ts index 9645a40..8f5d7b8 100644 --- a/src/app/api/intake/github/route.ts +++ b/src/app/api/intake/github/route.ts @@ -9,6 +9,8 @@ import { type GitHubIntakePayload = { repoUrl?: unknown; focus?: unknown; + runtimeBaseUrl?: unknown; + screenshotLimit?: unknown; }; const ALLOWED_FOCUS: ReadonlySet = new Set([ @@ -22,6 +24,9 @@ export async function POST(request: Request) { const payload = (await request.json()) as GitHubIntakePayload; const repoUrl = typeof payload.repoUrl === "string" ? payload.repoUrl.trim() : ""; const focus = normalizeFocus(payload.focus); + const runtimeBaseUrl = + typeof payload.runtimeBaseUrl === "string" ? payload.runtimeBaseUrl.trim() : ""; + const screenshotLimit = normalizeScreenshotLimit(payload.screenshotLimit); if (!repoUrl) { return apiError(400, "INVALID_REQUEST", "repoUrl is required."); @@ -40,6 +45,9 @@ export async function POST(request: Request) { owner: repoRef.owner, repo: repoRef.repo, token: process.env.GITHUB_PAT, + runtimeBaseUrl: runtimeBaseUrl || undefined, + screenshotLimit, + screenshotTemplate: process.env.GITHUB_INTAKE_SCREENSHOT_TEMPLATE, }); const { draft, evidence } = buildCaseDraftFromSignals(signals, focus); @@ -51,7 +59,10 @@ export async function POST(request: Request) { owner: repoRef.owner, repo: repoRef.repo, focus, + runtimeBaseUrl: runtimeBaseUrl || null, }, + routeCandidates: signals.routeCandidates, + runtimeScreenshots: signals.runtimeScreenshots, }); } catch (error) { return apiError( @@ -69,3 +80,12 @@ function normalizeFocus(value: unknown): IntakeFocus { return "ux-driven"; } +function normalizeScreenshotLimit(value: unknown): number { + if (typeof value !== "number") { + return 6; + } + if (!Number.isFinite(value)) { + return 6; + } + return Math.max(1, Math.min(12, Math.round(value))); +} diff --git a/src/lib/__tests__/github-case-intake.test.ts b/src/lib/__tests__/github-case-intake.test.ts index 069ebb2..f4da98f 100644 --- a/src/lib/__tests__/github-case-intake.test.ts +++ b/src/lib/__tests__/github-case-intake.test.ts @@ -1,5 +1,6 @@ import { buildCaseDraftFromSignals, + extractNextAppRoutesFromPaths, parseGitHubRepoUrl, type GitHubSignals, } from "@/lib/github-case-intake"; @@ -56,6 +57,8 @@ describe("buildCaseDraftFromSignals", () => { html_url: "https://github.com/acme/agent-workbench/issues/89", }, ], + routeCandidates: ["/", "/work", "/contact"], + runtimeScreenshots: [], }; it("produces a valid draft with expected structural sections", () => { @@ -80,5 +83,37 @@ describe("buildCaseDraftFromSignals", () => { const { draft } = buildCaseDraftFromSignals(signals, "agentic-flow"); expect(draft.subtitle.toLowerCase()).toContain("agentic flow"); }); + + it("adds visual artifacts section when runtime screenshots are provided", () => { + const withScreenshots: GitHubSignals = { + ...signals, + runtimeScreenshots: [ + { + route: "/", + pageUrl: "https://example.com/", + screenshotUrl: + "https://image.thum.io/get/png/noanimate/width/1600/crop/900/https%3A%2F%2Fexample.com%2F", + status: "planned", + }, + ], + }; + + const { draft } = buildCaseDraftFromSignals(withScreenshots, "ux-driven"); + expect(draft.sections.some((section) => section.title === "Visual Artifacts")).toBe(true); + }); }); +describe("extractNextAppRoutesFromPaths", () => { + it("extracts static routes from Next app-router paths", () => { + const routes = extractNextAppRoutesFromPaths([ + "src/app/page.tsx", + "src/app/work/page.tsx", + "src/app/work/[slug]/page.tsx", + "src/app/(marketing)/pricing/page.tsx", + "src/app/api/intake/github/route.ts", + "app/contact/page.jsx", + ]); + + expect(routes).toEqual(["/", "/work", "/contact", "/pricing"]); + }); +}); diff --git a/src/lib/github-case-intake.ts b/src/lib/github-case-intake.ts index d5c568f..e66cef6 100644 --- a/src/lib/github-case-intake.ts +++ b/src/lib/github-case-intake.ts @@ -28,6 +28,13 @@ export type GitHubRepoRef = { repo: string; }; +export type RuntimeScreenshot = { + route: string; + pageUrl: string; + screenshotUrl: string; + status: "planned"; +}; + type GitHubRepoInfo = { name: string; full_name: string; @@ -54,11 +61,28 @@ type GitHubIssue = { pull_request?: unknown; }; +type GitHubBranch = { + commit?: { + sha?: string; + }; +}; + +type GitHubTreeEntry = { + path?: string; + type?: string; +}; + +type GitHubTreeResponse = { + tree?: GitHubTreeEntry[]; +}; + export type GitHubSignals = { repo: GitHubRepoInfo; readme: string; mergedPulls: GitHubPullRequest[]; closedIssues: GitHubIssue[]; + routeCandidates: string[]; + runtimeScreenshots: RuntimeScreenshot[]; }; export function parseGitHubRepoUrl(value: string): GitHubRepoRef | null { @@ -99,6 +123,9 @@ export async function fetchGitHubSignals(params: { owner: string; repo: string; token?: string; + runtimeBaseUrl?: string; + screenshotLimit?: number; + screenshotTemplate?: string; }): Promise { const { owner, repo, token } = params; const base = `https://api.github.com/repos/${owner}/${repo}`; @@ -149,11 +176,30 @@ export async function fetchGitHubSignals(params: { .filter((issue) => !issue.pull_request) .slice(0, 12); + const routeCandidates = await fetchRepoRouteCandidates({ + owner, + repo, + defaultBranch: repoJson.default_branch, + token, + }); + + const runtimeScreenshots = buildRuntimeScreenshots({ + runtimeBaseUrl: params.runtimeBaseUrl, + routes: routeCandidates, + limit: params.screenshotLimit ?? 6, + screenshotTemplate: + params.screenshotTemplate || + process.env.GITHUB_INTAKE_SCREENSHOT_TEMPLATE || + "https://image.thum.io/get/png/noanimate/width/1600/crop/900/{url}", + }); + return { repo: repoJson, readme, mergedPulls, closedIssues, + routeCandidates, + runtimeScreenshots, }; } @@ -161,7 +207,14 @@ export function buildCaseDraftFromSignals( signals: GitHubSignals, focus: IntakeFocus = "ux-driven" ): { draft: CaseDraft; evidence: string[] } { - const { repo, readme, mergedPulls, closedIssues } = signals; + const { + repo, + readme, + mergedPulls, + closedIssues, + routeCandidates, + runtimeScreenshots, + } = signals; const repoSlug = slugify(repo.name || repo.full_name.split("/").pop() || "case"); const title = toCaseTitle(repo.name || repoSlug); const repoUrl = repo.html_url; @@ -173,6 +226,10 @@ export function buildCaseDraftFromSignals( for (const issue of closedIssues.slice(0, 5)) { evidenceLinks.push(issue.html_url); } + for (const screenshot of runtimeScreenshots) { + evidenceLinks.push(screenshot.pageUrl); + evidenceLinks.push(screenshot.screenshotUrl); + } const textPool = [ readme, @@ -185,7 +242,8 @@ export function buildCaseDraftFromSignals( const solutionItems = extractSignalItems(textPool, SOLUTION_KEYWORDS_BY_FOCUS[focus], 5); const subtitle = focusSubtitle(focus); - const contextIntro = firstMeaningfulParagraph(readme) || + const contextIntro = + firstMeaningfulParagraph(readme) || repo.description || "Repository artifacts indicate an actively evolving product system with design-impacting decisions."; @@ -264,6 +322,9 @@ export function buildCaseDraftFromSignals( "Mapped user-facing changes from merged pull requests.", "Grouped decisions by flow, interaction behavior, and system constraints.", `Framed the case through the selected angle: ${focus}.`, + routeCandidates.length + ? `Discovered ${routeCandidates.length} runtime route candidates from app router files.` + : "No static app routes were automatically discovered in repository tree.", ], }, }, @@ -286,30 +347,57 @@ export function buildCaseDraftFromSignals( }, ], }, - { - title: "Outcome", - blocks: [ - { - discriminant: "list", - value: { - items: [ - `Repository stars: ${repo.stargazers_count}`, - `Repository forks: ${repo.forks_count}`, - `Open issues at analysis time: ${repo.open_issues_count}`, - `${mergedPulls.length} merged PRs were used as implementation evidence.`, - ], - }, + ]; + + if (runtimeScreenshots.length > 0) { + sections.push({ + title: "Visual Artifacts", + blocks: runtimeScreenshots.flatMap((shot, index) => { + const label = `Runtime route ${index + 1}: ${shot.route}`; + return [ + { + discriminant: "media", + value: { + src: shot.screenshotUrl, + alt: `${title} runtime screenshot ${shot.route}`, + caption: `${label} (planned capture)`, + }, + } as CaseBlock, + { + discriminant: "link", + value: { + label: `Open route ${shot.route}`, + href: shot.pageUrl, + }, + } as CaseBlock, + ]; + }), + }); + } + + sections.push({ + title: "Outcome", + blocks: [ + { + discriminant: "list", + value: { + items: [ + `Repository stars: ${repo.stargazers_count}`, + `Repository forks: ${repo.forks_count}`, + `Open issues at analysis time: ${repo.open_issues_count}`, + `${mergedPulls.length} merged PRs were used as implementation evidence.`, + ], }, - { - discriminant: "link", - value: { - label: "Primary source repository", - href: repoUrl, - }, + }, + { + discriminant: "link", + value: { + label: "Primary source repository", + href: repoUrl, }, - ], - }, - ]; + }, + ], + }); const draft: CaseDraft = { slug: repoSlug, @@ -337,6 +425,7 @@ export function buildCaseDraftFromSignals( "README/docs interpretation", `${mergedPulls.length} merged PRs reviewed`, `${closedIssues.length} closed issues reviewed`, + `${routeCandidates.length} app routes discovered`, ], }, ], @@ -353,6 +442,136 @@ export function buildCaseDraftFromSignals( }; } +export function extractNextAppRoutesFromPaths(paths: string[]): string[] { + const routes = new Set(); + + for (const inputPath of paths) { + const path = inputPath.replace(/\\/g, "/"); + let relative: string | null = null; + + if (path.startsWith("src/app/")) { + relative = path.slice("src/app/".length); + } else if (path.startsWith("app/")) { + relative = path.slice("app/".length); + } + + if (!relative) { + continue; + } + + if (!/(^|\/)page\.(t|j)sx?$/.test(relative)) { + continue; + } + + if (relative.startsWith("api/") || relative.includes("/api/")) { + continue; + } + + const routePart = relative.replace(/(^|\/)page\.(t|j)sx?$/, ""); + const rawSegments = routePart.split("/").filter(Boolean); + + if (rawSegments.some((segment) => segment.startsWith("[") || segment.startsWith("@"))) { + continue; + } + + const segments = rawSegments.filter( + (segment) => !(segment.startsWith("(") && segment.endsWith(")")) + ); + + const route = segments.length > 0 ? `/${segments.join("/")}` : "/"; + routes.add(route); + } + + const priority = ["/", "/work", "/contact", "/pricing", "/docs", "/dashboard"]; + + return Array.from(routes).sort((a, b) => { + const ai = priority.indexOf(a); + const bi = priority.indexOf(b); + + if (ai !== -1 && bi !== -1) return ai - bi; + if (ai !== -1) return -1; + if (bi !== -1) return 1; + + return a.localeCompare(b); + }); +} + +function buildRuntimeScreenshots(params: { + runtimeBaseUrl?: string; + routes: string[]; + limit: number; + screenshotTemplate: string; +}): RuntimeScreenshot[] { + const base = (params.runtimeBaseUrl || "").trim(); + if (!base) { + return []; + } + + let parsed: URL; + try { + parsed = new URL(base); + } catch { + return []; + } + + return params.routes.slice(0, Math.max(1, params.limit)).map((route) => { + const pageUrl = new URL(route, withTrailingSlash(parsed.toString())).toString(); + const screenshotUrl = params.screenshotTemplate.replace( + /\{url\}/g, + encodeURIComponent(pageUrl) + ); + + return { + route, + pageUrl, + screenshotUrl, + status: "planned", + }; + }); +} + +async function fetchRepoRouteCandidates(params: { + owner: string; + repo: string; + defaultBranch: string; + token?: string; +}): Promise { + const { owner, repo, defaultBranch, token } = params; + const base = `https://api.github.com/repos/${owner}/${repo}`; + + const branchResponse = await fetchGitHubWithRetry(`${base}/branches/${defaultBranch}`, { + headers: buildHeaders(token), + }); + + if (!branchResponse.ok) { + return []; + } + + const branchJson = (await branchResponse.json()) as GitHubBranch; + const commitSha = branchJson.commit?.sha; + if (!commitSha) { + return []; + } + + const treeResponse = await fetchGitHubWithRetry( + `${base}/git/trees/${commitSha}?recursive=1`, + { + headers: buildHeaders(token), + } + ); + + if (!treeResponse.ok) { + return []; + } + + const treeJson = (await treeResponse.json()) as GitHubTreeResponse; + const filePaths = (treeJson.tree || []) + .filter((entry) => entry.type === "blob" && typeof entry.path === "string") + .map((entry) => entry.path as string); + + return extractNextAppRoutesFromPaths(filePaths).slice(0, 12); +} + function buildHeaders(token?: string): Record { const headers: Record = { Accept: "application/vnd.github+json", @@ -376,7 +595,7 @@ function firstMeaningfulParagraph(markdown: string): string { const cleaned = markdown .split("\n") .map((line) => line.trim()) - .filter((line) => line && !line.startsWith("#") && !line.startsWith("![")) // remove headings/images + .filter((line) => line && !line.startsWith("#") && !line.startsWith("![")) .join("\n"); const paragraph = cleaned.split(/\n{2,}/).find((chunk) => chunk.trim().length > 60); @@ -445,6 +664,10 @@ function toCaseTitle(value: string): string { .replace(/\b\w/g, (char) => char.toUpperCase()); } +function withTrailingSlash(value: string): string { + return value.endsWith("/") ? value : `${value}/`; +} + const PROBLEM_KEYWORDS = [ "problem", "issue", @@ -499,4 +722,3 @@ const SOLUTION_KEYWORDS_BY_FOCUS: Record = { "assistant", ], }; - From 33e24b180bff4dfcbdc7fb3cf6cf6a343fbaa3af Mon Sep 17 00:00:00 2001 From: Dmitry Ginzburg <33052194+Ultraivanov@users.noreply.github.com> Date: Thu, 16 Apr 2026 02:13:40 +0300 Subject: [PATCH 04/46] Implement runtime screenshot import into case assets --- docs/specs/2026-04-16-github-intake-mvp.md | 13 +- src/app/admin/page.tsx | 168 +++++++++++++-- .../api/intake/github/runtime-import/route.ts | 198 ++++++++++++++++++ 3 files changed, 354 insertions(+), 25 deletions(-) create mode 100644 src/app/api/intake/github/runtime-import/route.ts diff --git a/docs/specs/2026-04-16-github-intake-mvp.md b/docs/specs/2026-04-16-github-intake-mvp.md index 24dab77..128c905 100644 --- a/docs/specs/2026-04-16-github-intake-mvp.md +++ b/docs/specs/2026-04-16-github-intake-mvp.md @@ -38,19 +38,26 @@ Out of scope: - fetches repository data through GitHub API - returns generated draft + evidence links -2. `src/lib/github-case-intake.ts` +2. `POST /api/intake/github/runtime-import` + - takes screenshot plan + case slug + - downloads remote screenshot images + - stores screenshots into `public/cases//...` through GitHub API + - returns imported/failed items for UI reconciliation + +3. `src/lib/github-case-intake.ts` - URL parsing - repository signal fetching (README, merged PRs, closed issues) - heuristic mapping into case schema - route extraction from repository tree - runtime screenshot URL planning -3. Admin UI integration +4. Admin UI integration - new AI intake block - draft generation trigger - user confirmation before replacing current form data - evidence list for transparency - route and screenshot-plan preview + - import action to convert planned runtime screenshots into local case assets ## Data Sources (MVP) @@ -71,7 +78,7 @@ Out of scope: - Heuristic extraction may miss nuanced design decisions - Repository text quality strongly affects output quality - No automatic screenshots from runtime UI yet -- Screenshot artifacts are generated as planned URLs (deterministic crawl plan), not binary storage in this phase. +- Runtime import relies on external screenshot provider availability and remote URL reachability. ## Next Iterations diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index b87ec23..372f1df 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -95,6 +95,22 @@ interface GitHubIntakeApiResponse { error?: string | { message?: string }; } +interface RuntimeImportApiResponse { + imported?: Array<{ + route: string; + pageUrl: string; + src: string; + bytes: number; + }>; + failed?: Array<{ + route: string; + pageUrl: string; + screenshotUrl: string; + reason: string; + }>; + error?: string | { message?: string }; +} + const MEDIA_UPLOAD_TIMEOUT_MS = 90_000; const MAX_CLIENT_UPLOAD_BYTES = 3_500_000; // Keep request below Vercel function payload ceiling. const DRAFT_STORAGE_PREFIX = "cms-case-draft:"; @@ -184,6 +200,7 @@ export default function AdminPage() { status: "planned"; }> >([]); + const [importingRuntimeScreenshots, setImportingRuntimeScreenshots] = useState(false); const getBlockKey = (sectionIndex: number, blockIndex: number): string => `${sectionIndex}:${blockIndex}`; @@ -703,6 +720,93 @@ export default function AdminPage() { } }; + const handleImportRuntimeScreenshots = async () => { + if (!caseData || githubRuntimeScreenshots.length === 0) { + setMessage("❌ No runtime screenshots to import."); + return; + } + + setImportingRuntimeScreenshots(true); + setMessage(""); + try { + const response = await fetch("/api/intake/github/runtime-import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + slug: selectedCase, + screenshots: githubRuntimeScreenshots, + }), + }); + + const payload = (await response.json()) as RuntimeImportApiResponse; + if (!response.ok) { + setMessage(`❌ Runtime import failed: ${getApiErrorMessage(payload)}`); + return; + } + + const imported = Array.isArray(payload.imported) ? payload.imported : []; + const failed = Array.isArray(payload.failed) ? payload.failed : []; + + if (imported.length === 0) { + setMessage( + `❌ Runtime import finished with no imported screenshots.${failed.length ? " See failed list." : ""}` + ); + return; + } + + const byRoute = new Map(imported.map((item) => [item.route, item.src])); + const nextSections = caseData.sections.map((section) => { + if (section.title !== "Visual Artifacts") { + return section; + } + + return { + ...section, + blocks: section.blocks.map((block) => { + if (block.discriminant !== "media") { + return block; + } + + const routeMatch = (block.value.alt || "").match(/runtime screenshot\s+(.+)$/i); + const route = routeMatch?.[1]?.trim(); + if (!route) { + return block; + } + + const src = byRoute.get(route); + if (!src) { + return block; + } + + return { + ...block, + value: { + ...block.value, + src, + caption: `Runtime screenshot ${route} (imported)`, + }, + }; + }), + }; + }); + + updateField("sections", nextSections); + setMessage( + `✅ Imported ${imported.length} runtime screenshots${ + failed.length ? ` (${failed.length} failed)` : "" + }.` + ); + } catch (error) { + setMessage( + `❌ Runtime import failed: ${ + error instanceof Error ? error.message : "Unknown error" + }` + ); + } finally { + setImportingRuntimeScreenshots(false); + } + }; + // Section management const updateSection = (sectionIndex: number, field: keyof Section, value: string) => { if (!caseData) return; @@ -911,28 +1015,48 @@ export default function AdminPage() { ) : null} {githubRuntimeScreenshots.length > 0 ? ( -
- - Runtime screenshot plan ({githubRuntimeScreenshots.length}) - -
    - {githubRuntimeScreenshots.slice(0, 8).map((shot) => ( -
  • -
    - {shot.route} - {" -> "} - - page - {" "} - /{" "} - - screenshot - -
    -
  • - ))} -
-
+
+
+ + Runtime screenshot plan ({githubRuntimeScreenshots.length}) + +
    + {githubRuntimeScreenshots.slice(0, 8).map((shot) => ( +
  • +
    + {shot.route} + {" -> "} + + page + {" "} + /{" "} + + screenshot + +
    +
  • + ))} +
+
+ +
) : null}
diff --git a/src/app/api/intake/github/runtime-import/route.ts b/src/app/api/intake/github/runtime-import/route.ts new file mode 100644 index 0000000..e66abed --- /dev/null +++ b/src/app/api/intake/github/runtime-import/route.ts @@ -0,0 +1,198 @@ +import { apiError, apiSuccess } from "@/lib/api-response"; +import { fetchGitHubWithRetry } from "@/lib/github-api"; + +type RuntimeScreenshotInput = { + route?: unknown; + pageUrl?: unknown; + screenshotUrl?: unknown; +}; + +type RuntimeImportPayload = { + slug?: unknown; + screenshots?: unknown; +}; + +const MAX_SCREENSHOT_BYTES = 8 * 1024 * 1024; // 8 MiB per screenshot +const FETCH_TIMEOUT_MS = 20_000; + +export async function POST(request: Request) { + const githubToken = process.env.GITHUB_PAT; + const githubRepo = process.env.GITHUB_REPO || "Ultraivanov/portfolio"; + const githubBranch = process.env.GITHUB_BRANCH || "main"; + + if (!githubToken) { + return apiError(500, "CONFIG_ERROR", "GitHub PAT not configured"); + } + + try { + const payload = (await request.json()) as RuntimeImportPayload; + const slug = typeof payload.slug === "string" ? payload.slug.trim() : ""; + const screenshots = normalizeScreenshots(payload.screenshots); + + if (!slug || !isSafeSlug(slug)) { + return apiError(400, "INVALID_REQUEST", "slug must be a safe non-empty string."); + } + + if (screenshots.length === 0) { + return apiError(400, "INVALID_REQUEST", "screenshots must be a non-empty array."); + } + + const imported: Array<{ + route: string; + pageUrl: string; + src: string; + bytes: number; + }> = []; + const failed: Array<{ + route: string; + pageUrl: string; + screenshotUrl: string; + reason: string; + }> = []; + + for (let i = 0; i < screenshots.length; i += 1) { + const shot = screenshots[i]; + try { + const buffer = await fetchImageBuffer(shot.screenshotUrl); + if (buffer.byteLength > MAX_SCREENSHOT_BYTES) { + throw new Error( + `Screenshot is too large (${buffer.byteLength} bytes). Max ${MAX_SCREENSHOT_BYTES} bytes.` + ); + } + + const filePath = `public/cases/${slug}/runtime-${Date.now()}-${i + 1}.png`; + const base64Content = buffer.toString("base64"); + + const updateResponse = await fetchGitHubWithRetry( + `https://api.github.com/repos/${githubRepo}/contents/${filePath}`, + { + method: "PUT", + headers: { + Authorization: `Bearer ${githubToken}`, + Accept: "application/vnd.github+json", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + message: `Import runtime screenshot ${slug} ${shot.route}`, + content: base64Content, + branch: githubBranch, + }), + } + ); + + if (!updateResponse.ok) { + throw new Error( + (await safeReadGitHubMessage(updateResponse)) || + `GitHub upload failed with ${updateResponse.status}` + ); + } + + imported.push({ + route: shot.route, + pageUrl: shot.pageUrl, + src: filePath.replace(/^public/, ""), + bytes: buffer.byteLength, + }); + } catch (error) { + failed.push({ + route: shot.route, + pageUrl: shot.pageUrl, + screenshotUrl: shot.screenshotUrl, + reason: error instanceof Error ? error.message : "Unknown import error", + }); + } + } + + return apiSuccess({ + imported, + failed, + slug, + }); + } catch (error) { + return apiError( + 500, + "RUNTIME_IMPORT_FAILED", + error instanceof Error ? error.message : "Failed to import runtime screenshots" + ); + } +} + +function normalizeScreenshots(value: unknown): Array<{ + route: string; + pageUrl: string; + screenshotUrl: string; +}> { + if (!Array.isArray(value)) { + return []; + } + + const result: Array<{ route: string; pageUrl: string; screenshotUrl: string }> = []; + for (const row of value as RuntimeScreenshotInput[]) { + const route = typeof row.route === "string" ? row.route.trim() : ""; + const pageUrl = typeof row.pageUrl === "string" ? row.pageUrl.trim() : ""; + const screenshotUrl = + typeof row.screenshotUrl === "string" ? row.screenshotUrl.trim() : ""; + + if (!route || !pageUrl || !screenshotUrl) { + continue; + } + + if (!isHttpUrl(pageUrl) || !isHttpUrl(screenshotUrl)) { + continue; + } + + result.push({ route, pageUrl, screenshotUrl }); + } + return result.slice(0, 12); +} + +function isSafeSlug(value: string): boolean { + return /^[a-z0-9-]+$/i.test(value); +} + +function isHttpUrl(value: string): boolean { + try { + const url = new URL(value); + return url.protocol === "http:" || url.protocol === "https:"; + } catch { + return false; + } +} + +async function fetchImageBuffer(url: string): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + try { + const response = await fetch(url, { + method: "GET", + signal: controller.signal, + headers: { + Accept: "image/*", + }, + }); + + if (!response.ok) { + throw new Error(`Screenshot fetch failed with HTTP ${response.status}`); + } + + const contentType = response.headers.get("content-type") || ""; + if (!contentType.startsWith("image/")) { + throw new Error(`Unexpected content type: ${contentType || "unknown"}`); + } + + const arrayBuffer = await response.arrayBuffer(); + return Buffer.from(arrayBuffer); + } finally { + clearTimeout(timeout); + } +} + +async function safeReadGitHubMessage(response: Response): Promise { + try { + const payload = (await response.json()) as { message?: string }; + return payload.message; + } catch { + return undefined; + } +} + From 7a767dfbe08b1487413101106660a5522388b353 Mon Sep 17 00:00:00 2001 From: Dmitry Ginzburg <33052194+Ultraivanov@users.noreply.github.com> Date: Thu, 16 Apr 2026 02:37:43 +0300 Subject: [PATCH 05/46] Switch GitHub intake to LLM-first analysis mode --- docs/specs/2026-04-16-github-intake-mvp.md | 3 + src/app/admin/page.tsx | 33 +- src/app/api/intake/github/route.ts | 45 ++- src/lib/github-case-intake-llm.ts | 341 +++++++++++++++++++++ 4 files changed, 419 insertions(+), 3 deletions(-) create mode 100644 src/lib/github-case-intake-llm.ts diff --git a/docs/specs/2026-04-16-github-intake-mvp.md b/docs/specs/2026-04-16-github-intake-mvp.md index 128c905..c00277e 100644 --- a/docs/specs/2026-04-16-github-intake-mvp.md +++ b/docs/specs/2026-04-16-github-intake-mvp.md @@ -12,6 +12,7 @@ Generate an editable case-study draft in CMS from a GitHub repository URL, espec Input: - GitHub repository URL - Focus angle: `ux-driven` | `behavioral-model` | `agentic-flow` +- Analysis mode: `llm` (default) | `heuristic` (fallback/debug) Output: - Draft case JSON mapped into CMS structure: @@ -37,6 +38,7 @@ Out of scope: - validates repository URL - fetches repository data through GitHub API - returns generated draft + evidence links + - supports LLM synthesis layer (`OPENAI_API_KEY`) over extracted repo artifacts 2. `POST /api/intake/github/runtime-import` - takes screenshot plan + case slug @@ -72,6 +74,7 @@ Out of scope: - Clear error propagation for invalid URL / GitHub failures - Evidence links exposed in UI for human verification - Existing local draft behavior retained +- LLM mode requires explicit server key (`OPENAI_API_KEY`) and remains server-side only. ## Limitations diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 372f1df..52d3f2c 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -80,6 +80,7 @@ interface CaseDraftEnvelope { } type IntakeFocus = "ux-driven" | "behavioral-model" | "agentic-flow"; +type AnalysisMode = "llm" | "heuristic"; interface GitHubIntakeApiResponse { ok?: boolean; @@ -92,6 +93,14 @@ interface GitHubIntakeApiResponse { screenshotUrl: string; status: "planned"; }>; + llm?: { + model?: string; + usage?: { + promptTokens?: number; + completionTokens?: number; + totalTokens?: number; + }; + } | null; error?: string | { message?: string }; } @@ -187,6 +196,7 @@ export default function AdminPage() { const [draftSavedAt, setDraftSavedAt] = useState(null); const [githubRepoUrl, setGitHubRepoUrl] = useState(""); const [githubFocus, setGitHubFocus] = useState("ux-driven"); + const [githubAnalysisMode, setGitHubAnalysisMode] = useState("llm"); const [githubRuntimeBaseUrl, setGitHubRuntimeBaseUrl] = useState(""); const [githubScreenshotLimit, setGitHubScreenshotLimit] = useState(6); const [generatingGitHubDraft, setGeneratingGitHubDraft] = useState(false); @@ -201,6 +211,7 @@ export default function AdminPage() { }> >([]); const [importingRuntimeScreenshots, setImportingRuntimeScreenshots] = useState(false); + const [githubLlmInfo, setGitHubLlmInfo] = useState(null); const getBlockKey = (sectionIndex: number, blockIndex: number): string => `${sectionIndex}:${blockIndex}`; @@ -679,6 +690,7 @@ export default function AdminPage() { body: JSON.stringify({ repoUrl: githubRepoUrl.trim(), focus: githubFocus, + analysisMode: githubAnalysisMode, runtimeBaseUrl: githubRuntimeBaseUrl.trim() || undefined, screenshotLimit: githubScreenshotLimit, }), @@ -697,6 +709,7 @@ export default function AdminPage() { setGitHubRuntimeScreenshots( Array.isArray(payload.runtimeScreenshots) ? payload.runtimeScreenshots : [] ); + setGitHubLlmInfo(payload.llm ?? null); const shouldApply = window.confirm( "Replace current case form with generated draft? Local draft is still available via browser storage." @@ -941,6 +954,14 @@ export default function AdminPage() { + + + +
- Defaults to "{caseData.title} | Dmitry Ginzburg" if empty + Defaults to "{caseData.title} | Dmitry Ginzburg" if empty
@@ -1902,3 +1971,16 @@ export default function AdminPage() { ); } + +function severityRank(severity: DraftQualityIssue["severity"]): number { + switch (severity) { + case "critical": + return 0; + case "warning": + return 1; + case "info": + return 2; + default: + return 3; + } +} diff --git a/src/lib/__tests__/case-draft-quality.test.ts b/src/lib/__tests__/case-draft-quality.test.ts new file mode 100644 index 0000000..5fd167e --- /dev/null +++ b/src/lib/__tests__/case-draft-quality.test.ts @@ -0,0 +1,141 @@ +import { + analyzeCaseDraftQuality, + REQUIRED_CASE_SECTIONS, + type CaseDraftLike, +} from "@/lib/case-draft-quality"; + +function createBaseDraft(): CaseDraftLike { + return { + title: "Test Case", + subtitle: "Test subtitle", + facts: [{ label: "role", value: "Product Designer" }], + sections: [ + { + title: "Context", + blocks: [ + { + discriminant: "paragraph", + value: { + text: "This product serves distributed teams and had measurable UX friction in key flows.", + }, + }, + ], + }, + { + title: "Problem", + blocks: [ + { + discriminant: "paragraph", + value: { text: "Users were dropping off in onboarding due to unclear step transitions." }, + }, + ], + }, + { + title: "Constraints", + blocks: [ + { + discriminant: "list", + value: { items: ["Legacy backend contracts", "Two-week release cadence"] }, + }, + ], + }, + { + title: "Role", + blocks: [ + { + discriminant: "paragraph", + value: { text: "I led product discovery, UX strategy, and alignment with engineering." }, + }, + ], + }, + { + title: "Approach", + blocks: [ + { + discriminant: "paragraph", + value: { text: "We combined repo telemetry, interviews, and hypothesis-driven iterations." }, + }, + ], + }, + { + title: "Solution", + blocks: [ + { + discriminant: "paragraph", + value: { text: "Introduced progressive disclosure and clearer system feedback states." }, + }, + ], + }, + { + title: "Outcome", + blocks: [ + { + discriminant: "paragraph", + value: { text: "Activation improved by 17% and support tickets dropped by 22% in 4 weeks." }, + }, + ], + }, + ], + }; +} + +describe("analyzeCaseDraftQuality", () => { + it("passes required sections and avoids critical issues for a complete draft", () => { + const report = analyzeCaseDraftQuality(createBaseDraft(), { + evidenceLinks: ["https://github.com/example/repo/pull/1"], + }); + + expect(report.summary.critical).toBe(0); + expect(report.score).toBeGreaterThanOrEqual(80); + expect( + report.checklist.find((item) => item.id === "evidence-links")?.passed + ).toBe(true); + }); + + it("flags missing required sections as critical", () => { + const draft = createBaseDraft(); + draft.sections = draft.sections.filter((section) => section.title !== "Outcome"); + + const report = analyzeCaseDraftQuality(draft); + + expect(report.summary.critical).toBeGreaterThan(0); + expect(report.issues.some((issue) => issue.id === "missing-outcome")).toBe(true); + expect( + report.checklist + .filter((item) => item.id.startsWith("required-section-")) + .map((item) => item.label) + ).toHaveLength(REQUIRED_CASE_SECTIONS.length); + }); + + it("flags metric claims without evidence links", () => { + const report = analyzeCaseDraftQuality(createBaseDraft(), { + evidenceLinks: [], + }); + + expect(report.issues.some((issue) => issue.id === "metric-without-evidence")).toBe(true); + expect(report.issues.some((issue) => issue.id === "missing-evidence-links")).toBe(true); + }); + + it("flags weak constraints when constraints are generic", () => { + const draft = createBaseDraft(); + const constraints = draft.sections.find((section) => section.title === "Constraints"); + if (!constraints) { + throw new Error("Expected constraints section in test setup."); + } + constraints.blocks = [ + { + discriminant: "paragraph", + value: { text: "There were some constraints." }, + }, + ]; + + const report = analyzeCaseDraftQuality(draft, { + evidenceLinks: ["https://github.com/example/repo/issues/2"], + }); + + expect(report.issues.some((issue) => issue.id === "weak-constraints")).toBe(true); + expect( + report.checklist.find((item) => item.id === "constraints-signal")?.passed + ).toBe(false); + }); +}); diff --git a/src/lib/case-draft-quality.ts b/src/lib/case-draft-quality.ts new file mode 100644 index 0000000..c7cde92 --- /dev/null +++ b/src/lib/case-draft-quality.ts @@ -0,0 +1,243 @@ +type FactValue = string | string[]; + +type CaseBlock = + | { discriminant: "paragraph"; value: { text?: string } } + | { discriminant: "list"; value: { items?: string[] } } + | { discriminant: "link"; value: { label?: string; href?: string } } + | { discriminant: "media"; value: { src?: string; alt?: string; caption?: string } }; + +type CaseSection = { + title: string; + blocks: CaseBlock[]; +}; + +export type CaseDraftLike = { + title: string; + subtitle: string; + facts: Array<{ label: string; value: FactValue; href?: string }>; + sections: CaseSection[]; +}; + +export const REQUIRED_CASE_SECTIONS = [ + "Context", + "Problem", + "Constraints", + "Role", + "Approach", + "Solution", + "Outcome", +] as const; + +export type QualitySeverity = "critical" | "warning" | "info"; + +export type DraftQualityIssue = { + id: string; + severity: QualitySeverity; + message: string; + section?: string; +}; + +export type DraftQualityChecklistItem = { + id: string; + label: string; + passed: boolean; + details?: string; +}; + +export type DraftQualityReport = { + score: number; + checklist: DraftQualityChecklistItem[]; + issues: DraftQualityIssue[]; + summary: { + critical: number; + warning: number; + info: number; + }; +}; + +export function analyzeCaseDraftQuality( + draft: CaseDraftLike, + options?: { evidenceLinks?: string[] } +): DraftQualityReport { + const sectionsByTitle = new Map( + draft.sections.map((section) => [normalizeTitle(section.title), section]) + ); + const issues: DraftQualityIssue[] = []; + const checklist: DraftQualityChecklistItem[] = []; + + for (const requiredSection of REQUIRED_CASE_SECTIONS) { + const section = sectionsByTitle.get(normalizeTitle(requiredSection)); + const hasSection = Boolean(section); + checklist.push({ + id: `required-section-${requiredSection.toLowerCase()}`, + label: `Section "${requiredSection}" present`, + passed: hasSection, + details: hasSection ? undefined : `Missing required section "${requiredSection}"`, + }); + + if (!section) { + issues.push({ + id: `missing-${requiredSection.toLowerCase()}`, + severity: "critical", + section: requiredSection, + message: `Missing required section: ${requiredSection}.`, + }); + continue; + } + + if (!hasMeaningfulSectionContent(section)) { + issues.push({ + id: `empty-${requiredSection.toLowerCase()}`, + severity: "warning", + section: requiredSection, + message: `Section "${requiredSection}" has insufficient content.`, + }); + } + } + + const constraints = sectionsByTitle.get("constraints"); + if (constraints) { + const constraintsSignal = extractConstraintSignal(constraints); + checklist.push({ + id: "constraints-signal", + label: "Constraints section has concrete constraints", + passed: constraintsSignal, + details: constraintsSignal + ? undefined + : "Add at least 2 explicit constraints or a concrete constraints paragraph.", + }); + if (!constraintsSignal) { + issues.push({ + id: "weak-constraints", + severity: "warning", + section: "Constraints", + message: "Constraints look generic. Add concrete limits/tradeoffs.", + }); + } + } + + const outcome = sectionsByTitle.get("outcome"); + if (outcome) { + const hasOutcomeMetric = hasMetricSignal(outcome); + checklist.push({ + id: "outcome-metric", + label: "Outcome section includes measurable signal", + passed: hasOutcomeMetric, + details: hasOutcomeMetric + ? undefined + : "Outcome should include measurable impact (number, %, latency, conversion, etc.).", + }); + if (!hasOutcomeMetric) { + issues.push({ + id: "weak-outcome-metric", + severity: "warning", + section: "Outcome", + message: "Outcome has no measurable signal.", + }); + } + } + + const evidenceLinks = (options?.evidenceLinks || []).filter((href) => isHttpUrl(href)); + const hasEvidence = evidenceLinks.length > 0; + checklist.push({ + id: "evidence-links", + label: "Evidence links are available", + passed: hasEvidence, + details: hasEvidence ? `${evidenceLinks.length} link(s)` : "No evidence links detected.", + }); + if (!hasEvidence) { + issues.push({ + id: "missing-evidence-links", + severity: "warning", + message: "No evidence links attached to the draft.", + }); + } + + const hasMetricWithNoEvidence = + hasEvidence === false && + draft.sections.some((section) => hasMetricSignal(section)); + if (hasMetricWithNoEvidence) { + issues.push({ + id: "metric-without-evidence", + severity: "warning", + message: "Draft contains quantitative claims without evidence links.", + }); + } + + const passedCount = checklist.filter((item) => item.passed).length; + const score = checklist.length === 0 ? 100 : Math.round((passedCount / checklist.length) * 100); + + return { + score, + checklist, + issues, + summary: { + critical: issues.filter((issue) => issue.severity === "critical").length, + warning: issues.filter((issue) => issue.severity === "warning").length, + info: issues.filter((issue) => issue.severity === "info").length, + }, + }; +} + +function normalizeTitle(value: string): string { + return value.trim().toLowerCase(); +} + +function hasMeaningfulSectionContent(section: CaseSection): boolean { + return section.blocks.some((block) => { + if (block.discriminant === "paragraph") { + return Boolean(block.value.text && block.value.text.trim().length >= 30); + } + if (block.discriminant === "list") { + return Boolean(block.value.items && block.value.items.filter(Boolean).length >= 1); + } + if (block.discriminant === "link") { + return Boolean(block.value.href && isHttpUrl(block.value.href)); + } + if (block.discriminant === "media") { + return Boolean(block.value.src && block.value.alt); + } + return false; + }); +} + +function extractConstraintSignal(section: CaseSection): boolean { + for (const block of section.blocks) { + if (block.discriminant === "list") { + const count = (block.value.items || []).map((item) => item.trim()).filter(Boolean).length; + if (count >= 2) return true; + } + if (block.discriminant === "paragraph") { + const text = block.value.text?.trim() || ""; + if (text.length >= 80) return true; + } + } + return false; +} + +function hasMetricSignal(section: CaseSection): boolean { + const texts: string[] = []; + for (const block of section.blocks) { + if (block.discriminant === "paragraph" && block.value.text) { + texts.push(block.value.text); + } + if (block.discriminant === "list" && Array.isArray(block.value.items)) { + texts.push(...block.value.items); + } + } + + return texts.some((text) => + /(\d+(\.\d+)?\s?%|\d+(\.\d+)?\s?(ms|s|sec|seconds|min|minutes|hours|users|sessions|tickets|errors|crashes|days|weeks|months|x))/i.test( + text + ) + ); +} + +function isHttpUrl(value: string): boolean { + try { + const parsed = new URL(value); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + return false; + } +} From a912b5c9ac6a0490262ef9e5bb28e3cf15bb8d0d Mon Sep 17 00:00:00 2001 From: Dmitry Ginzburg <33052194+Ultraivanov@users.noreply.github.com> Date: Thu, 16 Apr 2026 23:40:57 +0300 Subject: [PATCH 09/46] feat(cms-ai): add intake confidence summary for github drafts --- .codex/blocks/R-02.md | 36 ++++- src/app/admin/page.tsx | 62 ++++++++ src/app/api/intake/github/route.ts | 4 + src/lib/__tests__/case-draft-quality.test.ts | 27 ++++ src/lib/case-draft-quality.ts | 146 +++++++++++++++++++ 5 files changed, 269 insertions(+), 6 deletions(-) diff --git a/.codex/blocks/R-02.md b/.codex/blocks/R-02.md index f04ad6c..be13131 100644 --- a/.codex/blocks/R-02.md +++ b/.codex/blocks/R-02.md @@ -20,8 +20,8 @@ Active CMS+AI sprint tasks are executed through approved Change Plans, verified | ID | Task | Status | Done When | |---------|-------------------------------------------|-------------|-----------| | R-02-T1 | Sync `.codex` workflow state to active block | done | `PHASES.md` points to `.codex/blocks/R-02.md`, active task is explicit, and snapshot records sync status | -| R-02-T2 | Finalize and commit Sprint S1 changes via approval gates | in-progress | User approves commit after review; commit includes snapshot + sprint plan + S1 implementation files | -| R-02-T3 | Implement S2 intake confidence signals | pending | `/api/intake/github` returns typed confidence summary and admin displays it in AI intake panel | +| R-02-T2 | Finalize and commit Sprint S1 changes via approval gates | done | User approves commit after review; commit includes snapshot + sprint plan + S1 implementation files | +| R-02-T3 | Implement S2 intake confidence signals | done | `/api/intake/github` returns typed confidence summary and admin displays it in AI intake panel | > New tasks are added here as the block progresses via `init-task`. @@ -31,10 +31,10 @@ Active CMS+AI sprint tasks are executed through approved Change Plans, verified | Field | Value | |-----------|-------| -| Task ID | R-02-T2 | -| Title | Finalize and commit Sprint S1 changes via approval gates | -| Status | in-progress | -| Done When | User approves commit after review; commit includes snapshot + sprint plan + S1 implementation files | +| Task ID | R-02-T3 | +| Title | Implement S2 intake confidence signals | +| Status | done | +| Done When | `/api/intake/github` returns typed confidence summary and admin displays it in AI intake panel | --- @@ -77,6 +77,27 @@ Run sync-check and validation commands, stage only approved Sprint S1 files thro **Risks:** Accidental inclusion of duplicate artifacts or unrelated worktree files. Mitigated by explicit `git add` whitelist and staged diff review. +### R-02-T3 — Implement S2 intake confidence signals + +**Files to modify:** +- `.codex/blocks/R-02.md` — task status and change-plan log. +- `src/lib/case-draft-quality.ts` — add reusable intake confidence summary helpers. +- `src/lib/__tests__/case-draft-quality.test.ts` — add confidence-focused tests. +- `src/app/api/intake/github/route.ts` — include typed confidence payload in intake response. +- `src/app/admin/page.tsx` — render confidence summary in AI intake panel. + +**Files to create:** +- none (unless helper extraction becomes necessary during implementation). + +**Files NOT touched:** +- duplicate `* 2.*` artifacts and unrelated CMS/upload files. + +**Approach:** +Build confidence from existing draft-quality signals in one shared lib function, return it from intake API, and render it in admin with compact overall and section-level diagnostics. + +**Risks:** +Overly noisy confidence output can reduce clarity; mitigate with concise section summaries and capped detail display. + --- ## Refactor Backlog @@ -94,6 +115,9 @@ Accidental inclusion of duplicate artifacts or unrelated worktree files. Mitigat | 2026-04-16 | R-02-T1 | in-progress | Workflow sync task started with approved Change Plan. | | 2026-04-16 | R-02-T1 | done | Active block routing fixed: `PHASES.md` now points to `R-02.md`. | | 2026-04-16 | R-02-T2 | in-progress | Started staged validation + review flow for Sprint S1 commit. | +| 2026-04-16 | R-02-T2 | done | Sprint S1 changes committed and pushed with explicit approval gates. | +| 2026-04-16 | R-02-T3 | in-progress | Started S2 confidence signal implementation after sync-check and approved plan. | +| 2026-04-16 | R-02-T3 | done | Added typed confidence summary in API and AI Intake admin panel, with tests. | --- diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 8bf87e8..56a3c13 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -3,6 +3,7 @@ import { useEffect, useMemo, useState } from "react"; import { analyzeCaseDraftQuality, + type DraftIntakeConfidence, type DraftQualityIssue, type DraftQualityReport, } from "@/lib/case-draft-quality"; @@ -107,6 +108,7 @@ interface GitHubIntakeApiResponse { }; commandCount?: number; } | null; + confidence?: DraftIntakeConfidence | null; extractor?: { requested?: boolean; executed?: boolean; @@ -242,6 +244,7 @@ export default function AdminPage() { >([]); const [importingRuntimeScreenshots, setImportingRuntimeScreenshots] = useState(false); const [githubLlmInfo, setGitHubLlmInfo] = useState(null); + const [githubConfidence, setGitHubConfidence] = useState(null); const draftQualityReport: DraftQualityReport | null = useMemo(() => { if (!caseData) return null; return analyzeCaseDraftQuality(caseData, { evidenceLinks: githubEvidence }); @@ -836,6 +839,7 @@ export default function AdminPage() { setGeneratingGitHubDraft(true); setMessage(""); + setGitHubConfidence(null); try { const response = await fetch("/api/intake/github", { method: "POST", @@ -852,6 +856,7 @@ export default function AdminPage() { const payload = (await response.json()) as GitHubIntakeApiResponse; if (!response.ok || !payload.draft) { + setGitHubConfidence(null); setMessage(`❌ Draft generation failed: ${getApiErrorMessage(payload)}`); return; } @@ -864,6 +869,7 @@ export default function AdminPage() { Array.isArray(payload.runtimeScreenshots) ? payload.runtimeScreenshots : [] ); setGitHubLlmInfo(payload.llm ?? null); + setGitHubConfidence(payload.confidence ?? null); const shouldApply = window.confirm( "Replace current case form with generated draft? Local draft is still available via browser storage." @@ -895,6 +901,7 @@ export default function AdminPage() { `✅ GitHub draft generated and applied. Review sections, then save.${extractorStatus}` ); } catch (error) { + setGitHubConfidence(null); setMessage( `❌ Draft generation failed: ${ error instanceof Error ? error.message : "Unknown error" @@ -1271,6 +1278,46 @@ export default function AdminPage() { : ""}

) : null} + {githubConfidence ? ( +
+

+ Confidence:{" "} + + {githubConfidence.overallScore}/100 ({githubConfidence.overallLevel}) + + {" • "} + checklist {githubConfidence.checklistPassed}/{githubConfidence.checklistTotal} + {" • "} + critical {githubConfidence.summary.critical} + {" • "} + warnings {githubConfidence.summary.warning} +

+
+ + Section confidence ({githubConfidence.sections.length}) + +
    + {githubConfidence.sections.map((section) => ( +
  • + {section.section}:{" "} + + {section.score}/100 ({section.level}) + + {section.notes.length > 0 ? ` — ${section.notes.join(" ")}` : ""} +
  • + ))} +
+
+
+ ) : null} {githubEvidence.length > 0 ? (
@@ -1984,3 +2031,18 @@ function severityRank(severity: DraftQualityIssue["severity"]): number { return 3; } } + +function confidenceLevelColor(level: DraftIntakeConfidence["overallLevel"] | "missing"): string { + switch (level) { + case "strong": + return "#16a34a"; + case "medium": + return "#ca8a04"; + case "weak": + return "#dc2626"; + case "missing": + return "#7f1d1d"; + default: + return "var(--color-text-primary)"; + } +} diff --git a/src/app/api/intake/github/route.ts b/src/app/api/intake/github/route.ts index 8d46e3a..7e6c17c 100644 --- a/src/app/api/intake/github/route.ts +++ b/src/app/api/intake/github/route.ts @@ -10,6 +10,7 @@ import { executeExtractorCommands, } from "@/lib/github-case-extractor"; import { synthesizeCaseDraftWithLlm } from "@/lib/github-case-intake-llm"; +import { buildDraftIntakeConfidence } from "@/lib/case-draft-quality"; type GitHubIntakePayload = { repoUrl?: unknown; @@ -126,9 +127,12 @@ export async function POST(request: Request) { } } + const confidence = buildDraftIntakeConfidence(draft, { evidenceLinks: evidence }); + return apiSuccess({ draft, evidence, + confidence, source: { owner: repoRef.owner, repo: repoRef.repo, diff --git a/src/lib/__tests__/case-draft-quality.test.ts b/src/lib/__tests__/case-draft-quality.test.ts index 5fd167e..c1cc67a 100644 --- a/src/lib/__tests__/case-draft-quality.test.ts +++ b/src/lib/__tests__/case-draft-quality.test.ts @@ -1,5 +1,6 @@ import { analyzeCaseDraftQuality, + buildDraftIntakeConfidence, REQUIRED_CASE_SECTIONS, type CaseDraftLike, } from "@/lib/case-draft-quality"; @@ -139,3 +140,29 @@ describe("analyzeCaseDraftQuality", () => { ).toBe(false); }); }); + +describe("buildDraftIntakeConfidence", () => { + it("returns section-level confidence for all required sections", () => { + const confidence = buildDraftIntakeConfidence(createBaseDraft(), { + evidenceLinks: ["https://github.com/example/repo/pull/1"], + }); + + expect(confidence.sections).toHaveLength(REQUIRED_CASE_SECTIONS.length); + expect(confidence.overallScore).toBeGreaterThanOrEqual(70); + expect(confidence.overallLevel).toBe("strong"); + }); + + it("marks missing sections as missing level", () => { + const draft = createBaseDraft(); + draft.sections = draft.sections.filter((section) => section.title !== "Outcome"); + + const confidence = buildDraftIntakeConfidence(draft, { + evidenceLinks: ["https://github.com/example/repo/issues/1"], + }); + + const outcome = confidence.sections.find((section) => section.section === "Outcome"); + expect(outcome?.level).toBe("missing"); + expect(outcome?.score).toBe(0); + expect(confidence.summary.critical).toBeGreaterThan(0); + }); +}); diff --git a/src/lib/case-draft-quality.ts b/src/lib/case-draft-quality.ts index c7cde92..4f70c2e 100644 --- a/src/lib/case-draft-quality.ts +++ b/src/lib/case-draft-quality.ts @@ -55,6 +55,34 @@ export type DraftQualityReport = { }; }; +export type IntakeConfidenceLevel = "strong" | "medium" | "weak" | "missing"; + +export type DraftSectionConfidence = { + section: (typeof REQUIRED_CASE_SECTIONS)[number]; + score: number; + level: IntakeConfidenceLevel; + summary: { + critical: number; + warning: number; + info: number; + }; + notes: string[]; +}; + +export type DraftIntakeConfidence = { + overallScore: number; + overallLevel: Exclude; + checklistPassed: number; + checklistTotal: number; + summary: { + critical: number; + warning: number; + info: number; + }; + sections: DraftSectionConfidence[]; + topIssues: DraftQualityIssue[]; +}; + export function analyzeCaseDraftQuality( draft: CaseDraftLike, options?: { evidenceLinks?: string[] } @@ -179,6 +207,94 @@ export function analyzeCaseDraftQuality( }; } +export function buildDraftIntakeConfidence( + draft: CaseDraftLike, + options?: { evidenceLinks?: string[] } +): DraftIntakeConfidence { + const quality = analyzeCaseDraftQuality(draft, options); + const checklistPassed = quality.checklist.filter((item) => item.passed).length; + const checklistTotal = quality.checklist.length; + const checklistMap = new Map(quality.checklist.map((item) => [item.id, item])); + + const sections = REQUIRED_CASE_SECTIONS.map((sectionName) => { + const sectionIssues = quality.issues.filter( + (issue) => normalizeTitle(issue.section || "") === normalizeTitle(sectionName) + ); + const hasMissingIssue = sectionIssues.some((issue) => issue.id === `missing-${sectionName.toLowerCase()}`); + + if (hasMissingIssue) { + return { + section: sectionName, + score: 0, + level: "missing", + summary: { + critical: sectionIssues.filter((issue) => issue.severity === "critical").length, + warning: sectionIssues.filter((issue) => issue.severity === "warning").length, + info: sectionIssues.filter((issue) => issue.severity === "info").length, + }, + notes: sectionIssues.map((issue) => issue.message).slice(0, 2), + } satisfies DraftSectionConfidence; + } + + const summary = { + critical: sectionIssues.filter((issue) => issue.severity === "critical").length, + warning: sectionIssues.filter((issue) => issue.severity === "warning").length, + info: sectionIssues.filter((issue) => issue.severity === "info").length, + }; + + let score = 78; + score -= summary.critical * 35; + score -= summary.warning * 18; + score -= summary.info * 8; + + if (sectionName === "Outcome") { + if (checklistMap.get("outcome-metric")?.passed) { + score += 12; + } else { + score -= 10; + } + } + if (sectionName === "Constraints") { + if (checklistMap.get("constraints-signal")?.passed) { + score += 8; + } else { + score -= 10; + } + } + + score = clampScore(score); + return { + section: sectionName, + score, + level: scoreToLevel(score), + summary, + notes: sectionIssues.map((issue) => issue.message).slice(0, 2), + } satisfies DraftSectionConfidence; + }); + + const averageSectionScore = + sections.length === 0 + ? 0 + : Math.round(sections.reduce((sum, section) => sum + section.score, 0) / sections.length); + const issuePenalty = quality.summary.critical * 12 + quality.summary.warning * 4; + const overallScore = clampScore( + Math.round(averageSectionScore * 0.6 + quality.score * 0.4 - issuePenalty) + ); + + return { + overallScore, + overallLevel: scoreToOverallLevel(overallScore), + checklistPassed, + checklistTotal, + summary: quality.summary, + sections, + topIssues: quality.issues + .slice() + .sort((a, b) => severityRank(a.severity) - severityRank(b.severity)) + .slice(0, 6), + }; +} + function normalizeTitle(value: string): string { return value.trim().toLowerCase(); } @@ -241,3 +357,33 @@ function isHttpUrl(value: string): boolean { return false; } } + +function scoreToLevel(score: number): IntakeConfidenceLevel { + if (score <= 0) return "missing"; + if (score >= 80) return "strong"; + if (score >= 55) return "medium"; + return "weak"; +} + +function scoreToOverallLevel(score: number): Exclude { + if (score >= 75) return "strong"; + if (score >= 50) return "medium"; + return "weak"; +} + +function severityRank(severity: QualitySeverity): number { + switch (severity) { + case "critical": + return 0; + case "warning": + return 1; + case "info": + return 2; + default: + return 3; + } +} + +function clampScore(value: number): number { + return Math.max(0, Math.min(100, value)); +} From b52728ebd4de8e1688f258d568096fc5cee4fcca Mon Sep 17 00:00:00 2001 From: Dmitry Ginzburg <33052194+Ultraivanov@users.noreply.github.com> Date: Fri, 17 Apr 2026 00:09:05 +0300 Subject: [PATCH 10/46] feat(cms-ai): harden runtime artifact auto-mapper merge --- .codex/blocks/R-02.md | 32 ++- src/app/admin/page.tsx | 97 ++++++++- .../__tests__/github-case-extractor.test.ts | 200 ++++++++---------- src/lib/github-case-extractor.ts | 42 +++- 4 files changed, 242 insertions(+), 129 deletions(-) diff --git a/.codex/blocks/R-02.md b/.codex/blocks/R-02.md index be13131..469638b 100644 --- a/.codex/blocks/R-02.md +++ b/.codex/blocks/R-02.md @@ -22,6 +22,8 @@ Active CMS+AI sprint tasks are executed through approved Change Plans, verified | R-02-T1 | Sync `.codex` workflow state to active block | done | `PHASES.md` points to `.codex/blocks/R-02.md`, active task is explicit, and snapshot records sync status | | R-02-T2 | Finalize and commit Sprint S1 changes via approval gates | done | User approves commit after review; commit includes snapshot + sprint plan + S1 implementation files | | R-02-T3 | Implement S2 intake confidence signals | done | `/api/intake/github` returns typed confidence summary and admin displays it in AI intake panel | +| R-02-T4 | Harden artifact-to-block auto-mapper (S3) | done | Runtime import does not produce uncontrolled duplicates; Visual Artifacts merge is deterministic; tests cover repeated import and route collisions | +| R-02-T5 | Rule-based consistency QA bot MVP (S4) | pending | API + admin expose rule-based consistency checks (tone/order/evidence) with tests for core rules | > New tasks are added here as the block progresses via `init-task`. @@ -31,10 +33,10 @@ Active CMS+AI sprint tasks are executed through approved Change Plans, verified | Field | Value | |-----------|-------| -| Task ID | R-02-T3 | -| Title | Implement S2 intake confidence signals | -| Status | done | -| Done When | `/api/intake/github` returns typed confidence summary and admin displays it in AI intake panel | +| Task ID | R-02-T5 | +| Title | Rule-based consistency QA bot MVP (S4) | +| Status | pending | +| Done When | API + admin expose rule-based consistency checks (tone/order/evidence) with tests for core rules | --- @@ -98,6 +100,25 @@ Build confidence from existing draft-quality signals in one shared lib function, **Risks:** Overly noisy confidence output can reduce clarity; mitigate with concise section summaries and capped detail display. +### R-02-T4 — Harden artifact-to-block auto-mapper (S3) + +**Files to modify:** +- `.codex/blocks/R-02.md` — task status and change-plan log. +- `src/lib/github-case-extractor.ts` — deterministic route-key merge and dedupe protection. +- `src/app/admin/page.tsx` — align runtime import merge with dedupe + deterministic append behavior. + +**Files to create:** +- `src/lib/__tests__/github-case-extractor.test.ts` — route-collision and repeated-import coverage. + +**Files NOT touched:** +- duplicate `* 2.*` artifacts and unrelated CMS/upload files. + +**Approach:** +Normalize route keys, dedupe imported artifacts by route (last import wins), upsert existing visual media deterministically, and append only missing media/link pairs in stable order. Mirror the same behavior in admin runtime-import reconciliation. + +**Risks:** +Over-normalization may collapse distinct routes unexpectedly; mitigated by exact normalized-key matching tests. + --- ## Refactor Backlog @@ -118,6 +139,9 @@ Overly noisy confidence output can reduce clarity; mitigate with concise section | 2026-04-16 | R-02-T2 | done | Sprint S1 changes committed and pushed with explicit approval gates. | | 2026-04-16 | R-02-T3 | in-progress | Started S2 confidence signal implementation after sync-check and approved plan. | | 2026-04-16 | R-02-T3 | done | Added typed confidence summary in API and AI Intake admin panel, with tests. | +| 2026-04-16 | R-02-T4 | pending | Task accepted by user approval and queued for change-plan review. | +| 2026-04-16 | R-02-T4 | in-progress | Started auto-mapper hardening with approved Change Plan. | +| 2026-04-16 | R-02-T4 | done | Added deterministic dedupe/merge for runtime artifacts and route-collision test coverage. | --- diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 56a3c13..08e1d0b 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -138,6 +138,7 @@ interface RuntimeImportApiResponse { pageUrl: string; src: string; bytes: number; + reason?: string; }>; failed?: Array<{ route: string; @@ -946,7 +947,10 @@ export default function AdminPage() { return; } - const byRoute = new Map(imported.map((item) => [item.route, item.src])); + const dedupedImported = dedupeRuntimeImportedArtifacts(imported); + const byRoute = new Map( + dedupedImported.map((item) => [normalizeRuntimeRouteKey(item.route), item] as const) + ); const nextSections = caseData.sections.map((section) => { if (section.title !== "Visual Artifacts") { return section; @@ -959,14 +963,13 @@ export default function AdminPage() { return block; } - const routeMatch = (block.value.alt || "").match(/runtime screenshot\s+(.+)$/i); - const route = routeMatch?.[1]?.trim(); + const route = extractRuntimeRouteFromAlt(block.value.alt); if (!route) { return block; } - const src = byRoute.get(route); - if (!src) { + const importedArtifact = byRoute.get(normalizeRuntimeRouteKey(route)); + if (!importedArtifact) { return block; } @@ -974,15 +977,51 @@ export default function AdminPage() { ...block, value: { ...block.value, - src, - caption: `Runtime screenshot ${route} (imported)`, + src: importedArtifact.src, + caption: + importedArtifact.reason || `Runtime screenshot ${importedArtifact.route} (imported)`, }, }; }), }; }); - updateField("sections", nextSections); + const missingBlocks: Block[] = dedupedImported + .filter((item) => !hasRuntimeMediaForRoute(nextSections, item.route)) + .sort((a, b) => + normalizeRuntimeRouteKey(a.route).localeCompare(normalizeRuntimeRouteKey(b.route)) + ) + .flatMap((item) => [ + { + discriminant: "media" as const, + value: { + src: item.src, + alt: `${caseData.title} runtime screenshot ${item.route}`, + caption: item.reason || `Runtime screenshot ${item.route} (imported)`, + }, + }, + { + discriminant: "link" as const, + value: { + label: `Open route ${item.route}`, + href: item.pageUrl, + }, + }, + ]); + + const sectionIndex = nextSections.findIndex((section) => section.title === "Visual Artifacts"); + const finalSections = + missingBlocks.length === 0 + ? nextSections + : sectionIndex >= 0 + ? nextSections.map((section, index) => + index === sectionIndex + ? { ...section, blocks: [...section.blocks, ...missingBlocks] } + : section + ) + : [...nextSections, { title: "Visual Artifacts", blocks: missingBlocks }]; + + updateField("sections", finalSections); setMessage( `✅ Imported ${imported.length} runtime screenshots${ failed.length ? ` (${failed.length} failed)` : "" @@ -2046,3 +2085,45 @@ function confidenceLevelColor(level: DraftIntakeConfidence["overallLevel"] | "mi return "var(--color-text-primary)"; } } + +function dedupeRuntimeImportedArtifacts( + imported: Array<{ route: string; pageUrl: string; src: string; bytes: number; reason?: string }> +): Array<{ route: string; pageUrl: string; src: string; bytes: number; reason?: string }> { + const deduped = new Map< + string, + { route: string; pageUrl: string; src: string; bytes: number; reason?: string } + >(); + for (const item of imported) { + deduped.set(normalizeRuntimeRouteKey(item.route), item); + } + return [...deduped.values()]; +} + +function hasRuntimeMediaForRoute(sections: Section[], route: string): boolean { + const targetKey = normalizeRuntimeRouteKey(route); + return sections.some( + (section) => + section.title === "Visual Artifacts" && + section.blocks.some( + (block) => + block.discriminant === "media" && + normalizeRuntimeRouteKey(extractRuntimeRouteFromAlt(block.value.alt) || "") === targetKey + ) + ); +} + +function extractRuntimeRouteFromAlt(alt: string | undefined): string | null { + if (typeof alt !== "string") return null; + const routeMatch = alt.match(/runtime screenshot\s+(.+)$/i); + return routeMatch?.[1]?.trim() || null; +} + +function normalizeRuntimeRouteKey(route: string): string { + const trimmed = route.trim(); + if (!trimmed) return ""; + const withSingleSlashes = trimmed.replace(/\/{2,}/g, "/"); + const normalizedPrefix = withSingleSlashes.startsWith("/") + ? withSingleSlashes + : `/${withSingleSlashes}`; + return normalizedPrefix.replace(/\/+$/g, "").toLowerCase(); +} diff --git a/src/lib/__tests__/github-case-extractor.test.ts b/src/lib/__tests__/github-case-extractor.test.ts index 64c184c..3cd7cac 100644 --- a/src/lib/__tests__/github-case-extractor.test.ts +++ b/src/lib/__tests__/github-case-extractor.test.ts @@ -1,146 +1,128 @@ import { applyImportedArtifactsToDraft, - normalizeExtractorCommands, type ImportedArtifact, } from "@/lib/github-case-extractor"; import type { CaseDraft } from "@/lib/github-case-intake"; -describe("normalizeExtractorCommands", () => { - it("keeps only valid import_runtime_screenshot commands", () => { - const commands = normalizeExtractorCommands([ +function createDraft(): CaseDraft { + return { + slug: "demo-case", + title: "Demo Case", + subtitle: "Demo subtitle", + coverSrc: "/cases/demo/cover.png", + coverAlt: "Demo cover", + facts: [{ label: "role", value: "Designer" }], + sections: [ { - type: "import_runtime_screenshot", - route: "/work", - pageUrl: "https://example.com/work", - screenshotUrl: "https://img.example.com/work.png", - reason: "Primary flow", + title: "Context", + blocks: [{ discriminant: "paragraph", value: { text: "Context text" } }], }, { - type: "import_runtime_screenshot", - route: "", - pageUrl: "https://example.com", - screenshotUrl: "https://img.example.com/a.png", + title: "Visual Artifacts", + blocks: [ + { + discriminant: "media", + value: { + src: "/cases/demo/original-checkout-details.png", + alt: "Demo Case runtime screenshot /checkout/details", + caption: "Old caption", + }, + }, + ], }, + ], + }; +} + +describe("applyImportedArtifactsToDraft", () => { + it("deduplicates repeated route imports (last import wins) and appends deterministically", () => { + const draft = createDraft(); + const imported: ImportedArtifact[] = [ { type: "import_runtime_screenshot", - route: "/bad", - pageUrl: "javascript:alert(1)", - screenshotUrl: "https://img.example.com/b.png", + route: "/z-route", + pageUrl: "https://example.com/z-route", + src: "/cases/demo/runtime-z-old.png", + bytes: 1200, }, - { - type: "unsupported", - route: "/ignored", - pageUrl: "https://example.com", - screenshotUrl: "https://img.example.com/c.png", - }, - ]); - - expect(commands).toEqual([ { type: "import_runtime_screenshot", - route: "/work", - pageUrl: "https://example.com/work", - screenshotUrl: "https://img.example.com/work.png", - reason: "Primary flow", + route: "/a-route", + pageUrl: "https://example.com/a-route", + src: "/cases/demo/runtime-a.png", + bytes: 1300, }, - ]); - }); -}); - -describe("applyImportedArtifactsToDraft", () => { - it("updates existing visual artifact blocks by runtime route", () => { - const draft: CaseDraft = { - slug: "demo", - title: "Demo", - subtitle: "Case", - coverSrc: "/cases/demo/cover.png", - coverAlt: "Demo cover", - facts: [], - sections: [ - { - title: "Visual Artifacts", - blocks: [ - { - discriminant: "media", - value: { - src: "https://img.example.com/old.png", - alt: "Demo runtime screenshot /work", - caption: "old", - }, - }, - ], - }, - ], - }; - - const imported: ImportedArtifact[] = [ { type: "import_runtime_screenshot", - route: "/work", - pageUrl: "https://example.com/work", - src: "/cases/demo/runtime-1.png", - bytes: 128, - reason: "Core UX flow", + route: "/z-route", + pageUrl: "https://example.com/z-route", + src: "/cases/demo/runtime-z-new.png", + bytes: 1400, }, ]; const updated = applyImportedArtifactsToDraft(draft, imported); - const mediaBlock = updated.sections[0].blocks[0]; + const visual = updated.sections.find((section) => section.title === "Visual Artifacts"); + expect(visual).toBeDefined(); + + const mediaBlocks = visual?.blocks.filter((block) => block.discriminant === "media") || []; + const linkBlocks = visual?.blocks.filter((block) => block.discriminant === "link") || []; - expect(mediaBlock.discriminant).toBe("media"); - if (mediaBlock.discriminant !== "media") { - throw new Error("Expected media block"); + const zRouteMedia = mediaBlocks.filter( + (block) => + block.discriminant === "media" && + block.value.alt?.toLowerCase().includes("runtime screenshot /z-route") + ); + expect(zRouteMedia).toHaveLength(1); + expect(zRouteMedia[0].discriminant).toBe("media"); + if (zRouteMedia[0].discriminant === "media") { + expect(zRouteMedia[0].value.src).toBe("/cases/demo/runtime-z-new.png"); } - expect(mediaBlock.value.src).toBe("/cases/demo/runtime-1.png"); - expect(mediaBlock.value.caption).toBe("Core UX flow"); + const runtimeRouteMedia = mediaBlocks + .filter((block) => block.discriminant === "media") + .map((block) => (block.discriminant === "media" ? block.value.alt || "" : "")); + const appendedRouteOrder = runtimeRouteMedia.filter((alt) => + alt.toLowerCase().includes("runtime screenshot /") + ); + expect(appendedRouteOrder[1]).toContain("/a-route"); + expect(appendedRouteOrder[2]).toContain("/z-route"); + expect(linkBlocks.length).toBeGreaterThanOrEqual(2); }); - it("creates Visual Artifacts section when missing", () => { - const draft: CaseDraft = { - slug: "demo", - title: "Demo", - subtitle: "Case", - coverSrc: "/cases/demo/cover.png", - coverAlt: "Demo cover", - facts: [], - sections: [ - { - title: "Context", - blocks: [{ discriminant: "paragraph", value: { text: "text" } }], - }, - ], - }; - + it("does not treat /checkout as existing when only /checkout/details exists", () => { + const draft = createDraft(); const imported: ImportedArtifact[] = [ { type: "import_runtime_screenshot", - route: "/", - pageUrl: "https://example.com/", - src: "/cases/demo/runtime-home.png", - bytes: 256, + route: "/checkout", + pageUrl: "https://example.com/checkout", + src: "/cases/demo/runtime-checkout.png", + bytes: 1100, }, ]; const updated = applyImportedArtifactsToDraft(draft, imported); - const visualArtifacts = updated.sections.find((section) => section.title === "Visual Artifacts"); + const visual = updated.sections.find((section) => section.title === "Visual Artifacts"); + const mediaBlocks = visual?.blocks.filter((block) => block.discriminant === "media") || []; + const checkoutMedia = mediaBlocks.filter( + (block) => + block.discriminant === "media" && + (block.value.alt || "").toLowerCase().includes("runtime screenshot /checkout") + ); - expect(visualArtifacts).toBeDefined(); - expect(visualArtifacts?.blocks).toHaveLength(2); - expect(visualArtifacts?.blocks[0]).toEqual({ - discriminant: "media", - value: { - src: "/cases/demo/runtime-home.png", - alt: "Demo runtime screenshot /", - caption: "Runtime screenshot / (imported)", - }, - }); - expect(visualArtifacts?.blocks[1]).toEqual({ - discriminant: "link", - value: { - label: "Open route /", - href: "https://example.com/", - }, - }); + expect(checkoutMedia).toHaveLength(2); + const hasDetailsMedia = checkoutMedia.some( + (block) => + block.discriminant === "media" && + (block.value.alt || "").toLowerCase().includes("/checkout/details") + ); + const hasCheckoutMedia = checkoutMedia.some( + (block) => + block.discriminant === "media" && + (block.value.alt || "").toLowerCase().includes("runtime screenshot /checkout") + ); + expect(hasDetailsMedia).toBe(true); + expect(hasCheckoutMedia).toBe(true); }); }); diff --git a/src/lib/github-case-extractor.ts b/src/lib/github-case-extractor.ts index d9933f2..7b553de 100644 --- a/src/lib/github-case-extractor.ts +++ b/src/lib/github-case-extractor.ts @@ -150,7 +150,10 @@ export function applyImportedArtifactsToDraft( return draft; } - const importedByRoute = new Map(imported.map((row) => [row.route, row])); + const dedupedImported = dedupeImportedArtifactsByRoute(imported); + const importedByRoute = new Map( + dedupedImported.map((row) => [normalizeRouteKey(row.route), row] as const) + ); const nextSections = draft.sections.map((section) => { if (section.title !== "Visual Artifacts") { return section; @@ -161,11 +164,10 @@ export function applyImportedArtifactsToDraft( return block; } - const routeMatch = (block.value.alt || "").match(/runtime screenshot\s+(.+)$/i); - const route = routeMatch?.[1]?.trim(); + const route = extractRouteFromAlt(block.value.alt); if (!route) return block; - const importedRow = importedByRoute.get(route); + const importedRow = importedByRoute.get(normalizeRouteKey(route)); if (!importedRow) return block; return { @@ -184,8 +186,9 @@ export function applyImportedArtifactsToDraft( }; }); - const missingMediaBlocks: CaseBlock[] = imported + const missingMediaBlocks: CaseBlock[] = dedupedImported .filter((row) => !hasMediaForRoute(nextSections, row.route)) + .sort((a, b) => normalizeRouteKey(a.route).localeCompare(normalizeRouteKey(b.route))) .flatMap((row) => [ { discriminant: "media", @@ -237,18 +240,42 @@ export function applyImportedArtifactsToDraft( } function hasMediaForRoute(sections: CaseDraft["sections"], route: string): boolean { + const targetKey = normalizeRouteKey(route); return sections.some( (section) => section.title === "Visual Artifacts" && section.blocks.some( (block) => block.discriminant === "media" && - typeof block.value.alt === "string" && - block.value.alt.toLowerCase().includes(`runtime screenshot ${route}`.toLowerCase()) + normalizeRouteKey(extractRouteFromAlt(block.value.alt) || "") === targetKey ) ); } +function dedupeImportedArtifactsByRoute(imported: ImportedArtifact[]): ImportedArtifact[] { + const deduped = new Map(); + for (const row of imported) { + deduped.set(normalizeRouteKey(row.route), row); + } + return [...deduped.values()]; +} + +function extractRouteFromAlt(alt: string | undefined): string | null { + if (typeof alt !== "string") return null; + const routeMatch = alt.match(/runtime screenshot\s+(.+)$/i); + return routeMatch?.[1]?.trim() || null; +} + +function normalizeRouteKey(route: string): string { + const trimmed = route.trim(); + if (!trimmed) return ""; + const withSingleSlashes = trimmed.replace(/\/{2,}/g, "/"); + const normalizedPrefix = withSingleSlashes.startsWith("/") + ? withSingleSlashes + : `/${withSingleSlashes}`; + return normalizedPrefix.replace(/\/+$/g, "").toLowerCase(); +} + async function fetchImageBuffer(url: string): Promise { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); @@ -304,4 +331,3 @@ function isHttpUrl(value: string): boolean { return false; } } - From cc00f9909cea162deb9137b8b6fc5915548fe680 Mon Sep 17 00:00:00 2001 From: Dmitry Ginzburg <33052194+Ultraivanov@users.noreply.github.com> Date: Fri, 17 Apr 2026 10:00:30 +0300 Subject: [PATCH 11/46] feat(cms-ai): add rule-based consistency QA report --- .codex/blocks/R-02.md | 27 ++- src/app/admin/page.tsx | 80 +++++++ src/app/api/intake/github/route.ts | 7 +- src/lib/__tests__/case-draft-quality.test.ts | 36 +++ src/lib/case-draft-quality.ts | 238 +++++++++++++++++++ 5 files changed, 385 insertions(+), 3 deletions(-) diff --git a/.codex/blocks/R-02.md b/.codex/blocks/R-02.md index 469638b..9154c00 100644 --- a/.codex/blocks/R-02.md +++ b/.codex/blocks/R-02.md @@ -23,7 +23,7 @@ Active CMS+AI sprint tasks are executed through approved Change Plans, verified | R-02-T2 | Finalize and commit Sprint S1 changes via approval gates | done | User approves commit after review; commit includes snapshot + sprint plan + S1 implementation files | | R-02-T3 | Implement S2 intake confidence signals | done | `/api/intake/github` returns typed confidence summary and admin displays it in AI intake panel | | R-02-T4 | Harden artifact-to-block auto-mapper (S3) | done | Runtime import does not produce uncontrolled duplicates; Visual Artifacts merge is deterministic; tests cover repeated import and route collisions | -| R-02-T5 | Rule-based consistency QA bot MVP (S4) | pending | API + admin expose rule-based consistency checks (tone/order/evidence) with tests for core rules | +| R-02-T5 | Rule-based consistency QA bot MVP (S4) | done | API + admin expose rule-based consistency checks (tone/order/evidence) with tests for core rules | > New tasks are added here as the block progresses via `init-task`. @@ -35,7 +35,7 @@ Active CMS+AI sprint tasks are executed through approved Change Plans, verified |-----------|-------| | Task ID | R-02-T5 | | Title | Rule-based consistency QA bot MVP (S4) | -| Status | pending | +| Status | done | | Done When | API + admin expose rule-based consistency checks (tone/order/evidence) with tests for core rules | --- @@ -119,6 +119,27 @@ Normalize route keys, dedupe imported artifacts by route (last import wins), ups **Risks:** Over-normalization may collapse distinct routes unexpectedly; mitigated by exact normalized-key matching tests. +### R-02-T5 — Rule-based consistency QA bot MVP (S4) + +**Files to modify:** +- `.codex/blocks/R-02.md` — task status and session tracking. +- `src/lib/case-draft-quality.ts` — add rule-based consistency report generator. +- `src/lib/__tests__/case-draft-quality.test.ts` — add core rule tests. +- `src/app/api/intake/github/route.ts` — include consistency in intake payload. +- `src/app/admin/page.tsx` — render consistency summary and top findings in AI Intake panel. + +**Files to create:** +- none. + +**Files NOT touched:** +- runtime extractor dedupe logic and duplicate `* 2.*` artifacts. + +**Approach:** +Introduce deterministic rule checks for section order, narrative tone/verbosity, and evidence-backed claims; expose typed report from API and show compact diagnostics in admin. + +**Risks:** +Rule sensitivity may produce noisy warnings; mitigated by conservative thresholds and capped findings list. + --- ## Refactor Backlog @@ -142,6 +163,8 @@ Over-normalization may collapse distinct routes unexpectedly; mitigated by exact | 2026-04-16 | R-02-T4 | pending | Task accepted by user approval and queued for change-plan review. | | 2026-04-16 | R-02-T4 | in-progress | Started auto-mapper hardening with approved Change Plan. | | 2026-04-16 | R-02-T4 | done | Added deterministic dedupe/merge for runtime artifacts and route-collision test coverage. | +| 2026-04-17 | R-02-T5 | in-progress | Started rule-based consistency QA implementation after approved Change Plan. | +| 2026-04-17 | R-02-T5 | done | Added rule-based consistency report in lib/API/admin with tests for order/tone/evidence checks. | --- diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 08e1d0b..925c2d0 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from "react"; import { + type DraftConsistencyReport, analyzeCaseDraftQuality, type DraftIntakeConfidence, type DraftQualityIssue, @@ -109,6 +110,7 @@ interface GitHubIntakeApiResponse { commandCount?: number; } | null; confidence?: DraftIntakeConfidence | null; + consistency?: DraftConsistencyReport | null; extractor?: { requested?: boolean; executed?: boolean; @@ -246,6 +248,7 @@ export default function AdminPage() { const [importingRuntimeScreenshots, setImportingRuntimeScreenshots] = useState(false); const [githubLlmInfo, setGitHubLlmInfo] = useState(null); const [githubConfidence, setGitHubConfidence] = useState(null); + const [githubConsistency, setGitHubConsistency] = useState(null); const draftQualityReport: DraftQualityReport | null = useMemo(() => { if (!caseData) return null; return analyzeCaseDraftQuality(caseData, { evidenceLinks: githubEvidence }); @@ -841,6 +844,7 @@ export default function AdminPage() { setGeneratingGitHubDraft(true); setMessage(""); setGitHubConfidence(null); + setGitHubConsistency(null); try { const response = await fetch("/api/intake/github", { method: "POST", @@ -858,6 +862,7 @@ export default function AdminPage() { const payload = (await response.json()) as GitHubIntakeApiResponse; if (!response.ok || !payload.draft) { setGitHubConfidence(null); + setGitHubConsistency(null); setMessage(`❌ Draft generation failed: ${getApiErrorMessage(payload)}`); return; } @@ -871,6 +876,7 @@ export default function AdminPage() { ); setGitHubLlmInfo(payload.llm ?? null); setGitHubConfidence(payload.confidence ?? null); + setGitHubConsistency(payload.consistency ?? null); const shouldApply = window.confirm( "Replace current case form with generated draft? Local draft is still available via browser storage." @@ -903,6 +909,7 @@ export default function AdminPage() { ); } catch (error) { setGitHubConfidence(null); + setGitHubConsistency(null); setMessage( `❌ Draft generation failed: ${ error instanceof Error ? error.message : "Unknown error" @@ -1357,6 +1364,53 @@ export default function AdminPage() {
) : null} + {githubConsistency ? ( +
+

+ Consistency:{" "} + + {githubConsistency.overall} + + {" • "} + critical {githubConsistency.summary.critical} + {" • "} + warnings {githubConsistency.summary.warning} + {" • "} + checks:{" "} + {githubConsistency.checks.sectionOrder ? "order✓" : "order✕"} /{" "} + {githubConsistency.checks.tone ? "tone✓" : "tone✕"} /{" "} + {githubConsistency.checks.verbosity ? "verbosity✓" : "verbosity✕"} /{" "} + {githubConsistency.checks.evidence ? "evidence✓" : "evidence✕"} +

+ {githubConsistency.findings.length > 0 ? ( +
+ + Top findings ({githubConsistency.findings.length}) + +
    + {githubConsistency.findings.map((finding) => ( +
  • + + {finding.severity.toUpperCase()} + {" "} + [{finding.rule}] {finding.message} +
  • + ))} +
+
+ ) : ( +

No consistency findings.

+ )} +
+ ) : null} {githubEvidence.length > 0 ? (
@@ -2086,6 +2140,32 @@ function confidenceLevelColor(level: DraftIntakeConfidence["overallLevel"] | "mi } } +function consistencyOverallColor(level: DraftConsistencyReport["overall"]): string { + switch (level) { + case "pass": + return "#16a34a"; + case "warn": + return "#ca8a04"; + case "fail": + return "#dc2626"; + default: + return "var(--color-text-primary)"; + } +} + +function consistencySeverityColor(level: DraftQualityIssue["severity"]): string { + switch (level) { + case "critical": + return "#dc2626"; + case "warning": + return "#ca8a04"; + case "info": + return "#2563eb"; + default: + return "var(--color-text-primary)"; + } +} + function dedupeRuntimeImportedArtifacts( imported: Array<{ route: string; pageUrl: string; src: string; bytes: number; reason?: string }> ): Array<{ route: string; pageUrl: string; src: string; bytes: number; reason?: string }> { diff --git a/src/app/api/intake/github/route.ts b/src/app/api/intake/github/route.ts index 7e6c17c..34ba5d8 100644 --- a/src/app/api/intake/github/route.ts +++ b/src/app/api/intake/github/route.ts @@ -10,7 +10,10 @@ import { executeExtractorCommands, } from "@/lib/github-case-extractor"; import { synthesizeCaseDraftWithLlm } from "@/lib/github-case-intake-llm"; -import { buildDraftIntakeConfidence } from "@/lib/case-draft-quality"; +import { + buildDraftIntakeConfidence, + buildDraftConsistencyReport, +} from "@/lib/case-draft-quality"; type GitHubIntakePayload = { repoUrl?: unknown; @@ -128,11 +131,13 @@ export async function POST(request: Request) { } const confidence = buildDraftIntakeConfidence(draft, { evidenceLinks: evidence }); + const consistency = buildDraftConsistencyReport(draft, { evidenceLinks: evidence }); return apiSuccess({ draft, evidence, confidence, + consistency, source: { owner: repoRef.owner, repo: repoRef.repo, diff --git a/src/lib/__tests__/case-draft-quality.test.ts b/src/lib/__tests__/case-draft-quality.test.ts index c1cc67a..28ba685 100644 --- a/src/lib/__tests__/case-draft-quality.test.ts +++ b/src/lib/__tests__/case-draft-quality.test.ts @@ -1,5 +1,6 @@ import { analyzeCaseDraftQuality, + buildDraftConsistencyReport, buildDraftIntakeConfidence, REQUIRED_CASE_SECTIONS, type CaseDraftLike, @@ -166,3 +167,38 @@ describe("buildDraftIntakeConfidence", () => { expect(confidence.summary.critical).toBeGreaterThan(0); }); }); + +describe("buildDraftConsistencyReport", () => { + it("returns pass for ordered draft with evidence", () => { + const report = buildDraftConsistencyReport(createBaseDraft(), { + evidenceLinks: ["https://github.com/example/repo/pull/12"], + }); + + expect(report.overall).toBe("pass"); + expect(report.summary.critical).toBe(0); + expect(report.checks.sectionOrder).toBe(true); + expect(report.checks.evidence).toBe(true); + }); + + it("flags out-of-order required sections", () => { + const draft = createBaseDraft(); + draft.sections = [draft.sections[1], draft.sections[0], ...draft.sections.slice(2)]; + + const report = buildDraftConsistencyReport(draft, { + evidenceLinks: ["https://github.com/example/repo/issues/22"], + }); + + expect(report.overall).toBe("warn"); + expect(report.findings.some((finding) => finding.rule === "section-order")).toBe(true); + }); + + it("fails when quantitative claims have no evidence", () => { + const report = buildDraftConsistencyReport(createBaseDraft(), { + evidenceLinks: [], + }); + + expect(report.overall).toBe("fail"); + expect(report.summary.critical).toBeGreaterThan(0); + expect(report.findings.some((finding) => finding.rule === "evidence")).toBe(true); + }); +}); diff --git a/src/lib/case-draft-quality.ts b/src/lib/case-draft-quality.ts index 4f70c2e..284d83f 100644 --- a/src/lib/case-draft-quality.ts +++ b/src/lib/case-draft-quality.ts @@ -83,6 +83,36 @@ export type DraftIntakeConfidence = { topIssues: DraftQualityIssue[]; }; +export type DraftConsistencyRule = + | "section-order" + | "tone" + | "verbosity" + | "evidence"; + +export type DraftConsistencyFinding = { + id: string; + severity: QualitySeverity; + rule: DraftConsistencyRule; + message: string; + section?: string; +}; + +export type DraftConsistencyReport = { + overall: "pass" | "warn" | "fail"; + summary: { + critical: number; + warning: number; + info: number; + }; + checks: { + sectionOrder: boolean; + tone: boolean; + verbosity: boolean; + evidence: boolean; + }; + findings: DraftConsistencyFinding[]; +}; + export function analyzeCaseDraftQuality( draft: CaseDraftLike, options?: { evidenceLinks?: string[] } @@ -295,6 +325,102 @@ export function buildDraftIntakeConfidence( }; } +export function buildDraftConsistencyReport( + draft: CaseDraftLike, + options?: { evidenceLinks?: string[] } +): DraftConsistencyReport { + const findings: DraftConsistencyFinding[] = []; + const textSignals = collectSectionTexts(draft.sections); + const evidenceLinks = collectEvidenceLinks(draft, options?.evidenceLinks || []); + + const orderCheck = evaluateSectionOrder(draft.sections); + if (!orderCheck.passed) { + findings.push({ + id: "section-order", + severity: "warning", + rule: "section-order", + message: orderCheck.message, + }); + } + + const duplicateSectionTitles = findDuplicateRequiredSectionTitles(draft.sections); + for (const title of duplicateSectionTitles) { + findings.push({ + id: `duplicate-section-${normalizeTitle(title)}`, + severity: "warning", + rule: "section-order", + section: title, + message: `Section "${title}" appears more than once.`, + }); + } + + for (const signal of textSignals) { + if (containsPromotionalTone(signal.text)) { + findings.push({ + id: `tone-${normalizeTitle(signal.section)}-${hashSnippet(signal.text)}`, + severity: "warning", + rule: "tone", + section: signal.section, + message: `Section "${signal.section}" contains marketing-style language. Prefer evidence-grounded wording.`, + }); + break; + } + } + + for (const signal of textSignals) { + if (signal.text.length > 560) { + findings.push({ + id: `verbosity-${normalizeTitle(signal.section)}-${hashSnippet(signal.text)}`, + severity: "warning", + rule: "verbosity", + section: signal.section, + message: `Section "${signal.section}" has an overly long paragraph (${signal.text.length} chars).`, + }); + } + } + + const hasMetricClaims = draft.sections.some((section) => hasMetricSignal(section)); + const hasNonNumericClaims = textSignals.some((signal) => CLAIM_WORDS_REGEX.test(signal.text)); + const hasEvidence = evidenceLinks.length > 0; + + if (hasMetricClaims && !hasEvidence) { + findings.push({ + id: "evidence-metric-claim-missing", + severity: "critical", + rule: "evidence", + message: "Draft includes quantitative claims without evidence links.", + }); + } else if (hasNonNumericClaims && !hasEvidence) { + findings.push({ + id: "evidence-claim-missing", + severity: "warning", + rule: "evidence", + message: "Draft includes outcome claims without supporting evidence links.", + }); + } + + const summary = { + critical: findings.filter((item) => item.severity === "critical").length, + warning: findings.filter((item) => item.severity === "warning").length, + info: findings.filter((item) => item.severity === "info").length, + }; + + return { + overall: summary.critical > 0 ? "fail" : summary.warning > 0 ? "warn" : "pass", + summary, + checks: { + sectionOrder: !findings.some((item) => item.rule === "section-order"), + tone: !findings.some((item) => item.rule === "tone"), + verbosity: !findings.some((item) => item.rule === "verbosity"), + evidence: !findings.some((item) => item.rule === "evidence"), + }, + findings: findings + .slice() + .sort((a, b) => severityRank(a.severity) - severityRank(b.severity)) + .slice(0, 8), + }; +} + function normalizeTitle(value: string): string { return value.trim().toLowerCase(); } @@ -349,6 +475,118 @@ function hasMetricSignal(section: CaseSection): boolean { ); } +const CLAIM_WORDS_REGEX = + /\b(improved|increase(?:d)?|reduced?|decreased?|boosted?|grew|drop(?:ped)?|faster|higher|lower)\b/i; + +const PROMOTIONAL_PHRASES = [ + "best-in-class", + "world-class", + "game-changing", + "revolutionary", + "seamless experience", + "cutting-edge", +]; + +function collectSectionTexts( + sections: CaseSection[] +): Array<{ section: string; text: string }> { + const signals: Array<{ section: string; text: string }> = []; + for (const section of sections) { + for (const block of section.blocks) { + if (block.discriminant === "paragraph" && typeof block.value.text === "string") { + const text = block.value.text.trim(); + if (text) { + signals.push({ section: section.title, text }); + } + } + if (block.discriminant === "list" && Array.isArray(block.value.items)) { + for (const item of block.value.items) { + const text = item.trim(); + if (text) { + signals.push({ section: section.title, text }); + } + } + } + } + } + return signals; +} + +function evaluateSectionOrder(sections: CaseSection[]): { passed: boolean; message: string } { + const indexByTitle = new Map(); + for (let i = 0; i < sections.length; i += 1) { + const key = normalizeTitle(sections[i].title); + if (!indexByTitle.has(key)) { + indexByTitle.set(key, i); + } + } + + let previous = -1; + for (const expected of REQUIRED_CASE_SECTIONS) { + const idx = indexByTitle.get(normalizeTitle(expected)); + if (idx === undefined) continue; + if (idx < previous) { + return { + passed: false, + message: `Required sections are out of order. Expected "${expected}" after previous required sections.`, + }; + } + previous = idx; + } + return { passed: true, message: "Required sections are in expected order." }; +} + +function findDuplicateRequiredSectionTitles(sections: CaseSection[]): string[] { + const counts = new Map(); + for (const section of sections) { + const key = normalizeTitle(section.title); + if (!REQUIRED_CASE_SECTIONS.map(normalizeTitle).includes(key)) continue; + counts.set(key, (counts.get(key) || 0) + 1); + } + + const duplicates: string[] = []; + for (const required of REQUIRED_CASE_SECTIONS) { + const key = normalizeTitle(required); + if ((counts.get(key) || 0) > 1) { + duplicates.push(required); + } + } + return duplicates; +} + +function containsPromotionalTone(text: string): boolean { + if (PROMOTIONAL_PHRASES.some((phrase) => text.toLowerCase().includes(phrase))) { + return true; + } + if ((text.match(/!/g) || []).length >= 2) { + return true; + } + return false; +} + +function collectEvidenceLinks(draft: CaseDraftLike, inputLinks: string[]): string[] { + const links = new Set(); + for (const href of inputLinks) { + if (isHttpUrl(href)) { + links.add(href); + } + } + for (const section of draft.sections) { + for (const block of section.blocks) { + if (block.discriminant === "link" && typeof block.value.href === "string") { + if (isHttpUrl(block.value.href)) { + links.add(block.value.href); + } + } + } + } + return [...links]; +} + +function hashSnippet(value: string): string { + return value.trim().toLowerCase().slice(0, 24).replace(/[^a-z0-9]+/g, "-"); +} + function isHttpUrl(value: string): boolean { try { const parsed = new URL(value); From b39b7a7ad0d13bfd3ecde58523246c527b829df3 Mon Sep 17 00:00:00 2001 From: Dmitry Ginzburg <33052194+Ultraivanov@users.noreply.github.com> Date: Fri, 17 Apr 2026 10:55:29 +0300 Subject: [PATCH 12/46] feat(cms-ai): add one-click case starter apply flow --- .codex/blocks/R-02.md | 31 +++- src/app/admin/page.tsx | 196 +++++++++++++++++++++-- src/app/api/intake/github/route.ts | 7 + src/lib/__tests__/case-starter.test.ts | 78 +++++++++ src/lib/case-starter.ts | 209 +++++++++++++++++++++++++ 5 files changed, 505 insertions(+), 16 deletions(-) create mode 100644 src/lib/__tests__/case-starter.test.ts create mode 100644 src/lib/case-starter.ts diff --git a/.codex/blocks/R-02.md b/.codex/blocks/R-02.md index 9154c00..fce6c80 100644 --- a/.codex/blocks/R-02.md +++ b/.codex/blocks/R-02.md @@ -24,6 +24,7 @@ Active CMS+AI sprint tasks are executed through approved Change Plans, verified | R-02-T3 | Implement S2 intake confidence signals | done | `/api/intake/github` returns typed confidence summary and admin displays it in AI intake panel | | R-02-T4 | Harden artifact-to-block auto-mapper (S3) | done | Runtime import does not produce uncontrolled duplicates; Visual Artifacts merge is deterministic; tests cover repeated import and route collisions | | R-02-T5 | Rule-based consistency QA bot MVP (S4) | done | API + admin expose rule-based consistency checks (tone/order/evidence) with tests for core rules | +| R-02-T6 | One-click case starter MVP shell (S5) | done | API returns starter title/subtitle variants; admin requires explicit Apply action before replacing current form | > New tasks are added here as the block progresses via `init-task`. @@ -33,10 +34,10 @@ Active CMS+AI sprint tasks are executed through approved Change Plans, verified | Field | Value | |-----------|-------| -| Task ID | R-02-T5 | -| Title | Rule-based consistency QA bot MVP (S4) | +| Task ID | R-02-T6 | +| Title | One-click case starter MVP shell (S5) | | Status | done | -| Done When | API + admin expose rule-based consistency checks (tone/order/evidence) with tests for core rules | +| Done When | API returns starter title/subtitle variants; admin requires explicit Apply action before replacing current form | --- @@ -140,6 +141,26 @@ Introduce deterministic rule checks for section order, narrative tone/verbosity, **Risks:** Rule sensitivity may produce noisy warnings; mitigated by conservative thresholds and capped findings list. +### R-02-T6 — One-click case starter MVP shell (S5) + +**Files to modify:** +- `.codex/blocks/R-02.md` — task status and session tracking. +- `src/app/api/intake/github/route.ts` — include starter variants in intake payload. +- `src/app/admin/page.tsx` — render starter variants and explicit Apply action. + +**Files to create:** +- `src/lib/case-starter.ts` — deterministic title/subtitle variant generator. +- `src/lib/__tests__/case-starter.test.ts` — starter variant generation tests. + +**Files NOT touched:** +- extractor runtime import route and duplicate `* 2.*` artifacts. + +**Approach:** +Generate deterministic starter variants from draft + intake context in API response, then require explicit user Apply action in admin before replacing current form values. Keep save/publish unchanged. + +**Risks:** +Variant text quality may be generic on sparse repos; mitigated by conservative fallbacks and deterministic formatting. + --- ## Refactor Backlog @@ -165,7 +186,9 @@ Rule sensitivity may produce noisy warnings; mitigated by conservative threshold | 2026-04-16 | R-02-T4 | done | Added deterministic dedupe/merge for runtime artifacts and route-collision test coverage. | | 2026-04-17 | R-02-T5 | in-progress | Started rule-based consistency QA implementation after approved Change Plan. | | 2026-04-17 | R-02-T5 | done | Added rule-based consistency report in lib/API/admin with tests for order/tone/evidence checks. | +| 2026-04-17 | R-02-T6 | in-progress | Started S5 one-click starter implementation with explicit Apply confirmation flow. | +| 2026-04-17 | R-02-T6 | done | Added starter variants in API and explicit Apply Starter Draft flow in admin, with tests. | --- -_Last updated: 2026-04-16_ +_Last updated: 2026-04-17_ diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 925c2d0..e1f971a 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -8,6 +8,7 @@ import { type DraftQualityIssue, type DraftQualityReport, } from "@/lib/case-draft-quality"; +import type { StarterVariant } from "@/lib/case-starter"; interface Fact { label: string; @@ -111,6 +112,7 @@ interface GitHubIntakeApiResponse { } | null; confidence?: DraftIntakeConfidence | null; consistency?: DraftConsistencyReport | null; + starterVariants?: StarterVariant[]; extractor?: { requested?: boolean; executed?: boolean; @@ -249,6 +251,10 @@ export default function AdminPage() { const [githubLlmInfo, setGitHubLlmInfo] = useState(null); const [githubConfidence, setGitHubConfidence] = useState(null); const [githubConsistency, setGitHubConsistency] = useState(null); + const [githubStarterDraft, setGitHubStarterDraft] = useState(null); + const [githubStarterVariants, setGitHubStarterVariants] = useState([]); + const [selectedStarterVariantId, setSelectedStarterVariantId] = useState(""); + const [githubExtractorSummary, setGitHubExtractorSummary] = useState(""); const draftQualityReport: DraftQualityReport | null = useMemo(() => { if (!caseData) return null; return analyzeCaseDraftQuality(caseData, { evidenceLinks: githubEvidence }); @@ -304,6 +310,10 @@ export default function AdminPage() { setMediaUploadFeedbackByBlock({}); setAvailableDraft(null); setDraftSavedAt(null); + setGitHubStarterDraft(null); + setGitHubStarterVariants([]); + setSelectedStarterVariantId(""); + setGitHubExtractorSummary(""); void loadCaseContent(selectedCase); }, [selectedCase]); @@ -845,6 +855,10 @@ export default function AdminPage() { setMessage(""); setGitHubConfidence(null); setGitHubConsistency(null); + setGitHubStarterDraft(null); + setGitHubStarterVariants([]); + setSelectedStarterVariantId(""); + setGitHubExtractorSummary(""); try { const response = await fetch("/api/intake/github", { method: "POST", @@ -877,17 +891,10 @@ export default function AdminPage() { setGitHubLlmInfo(payload.llm ?? null); setGitHubConfidence(payload.confidence ?? null); setGitHubConsistency(payload.consistency ?? null); - - const shouldApply = window.confirm( - "Replace current case form with generated draft? Local draft is still available via browser storage." - ); - - if (!shouldApply) { - setMessage("ℹ️ Draft generated. Apply cancelled."); - return; - } - - applyGeneratedDraft(payload.draft); + setGitHubStarterDraft(payload.draft); + const starterVariants = normalizeStarterVariants(payload.starterVariants, payload.draft); + setGitHubStarterVariants(starterVariants); + setSelectedStarterVariantId(starterVariants[0]?.id ?? ""); const extractorImportedCount = Array.isArray(payload.extractor?.imported) ? payload.extractor?.imported.length : 0; @@ -903,13 +910,18 @@ export default function AdminPage() { ? ` Extractor skipped: ${payload.extractor.skippedReason}` : "" : ""; + setGitHubExtractorSummary(extractorStatus.trim()); setMessage( - `✅ GitHub draft generated and applied. Review sections, then save.${extractorStatus}` + `✅ GitHub draft generated. Choose starter variant and click Apply Starter Draft before save.${extractorStatus}` ); } catch (error) { setGitHubConfidence(null); setGitHubConsistency(null); + setGitHubStarterDraft(null); + setGitHubStarterVariants([]); + setSelectedStarterVariantId(""); + setGitHubExtractorSummary(""); setMessage( `❌ Draft generation failed: ${ error instanceof Error ? error.message : "Unknown error" @@ -920,6 +932,48 @@ export default function AdminPage() { } }; + const handleApplyStarterDraft = () => { + if (!githubStarterDraft) { + setMessage("❌ Generate a draft first."); + return; + } + + const shouldApply = window.confirm( + "Apply starter draft and replace current form values? Local browser draft stays available." + ); + if (!shouldApply) { + setMessage("ℹ️ Starter draft apply cancelled."); + return; + } + + const selectedVariant = + githubStarterVariants.find((variant) => variant.id === selectedStarterVariantId) ?? + githubStarterVariants[0]; + const nextDraft: CaseStudy = selectedVariant + ? { + ...githubStarterDraft, + title: selectedVariant.title, + subtitle: selectedVariant.subtitle, + coverAlt: githubStarterDraft.coverAlt || `${selectedVariant.title} cover`, + seo: { + ...githubStarterDraft.seo, + metaTitle: + githubStarterDraft.seo?.metaTitle || + `${selectedVariant.title} | Case Study`, + metaDescription: + githubStarterDraft.seo?.metaDescription || selectedVariant.subtitle, + }, + } + : githubStarterDraft; + + applyGeneratedDraft(nextDraft); + setMessage( + `✅ Starter draft applied. Review sections, then save.${ + githubExtractorSummary ? ` ${githubExtractorSummary}` : "" + }` + ); + }; + const handleImportRuntimeScreenshots = async () => { if (!caseData || githubRuntimeScreenshots.length === 0) { setMessage("❌ No runtime screenshots to import."); @@ -1324,6 +1378,65 @@ export default function AdminPage() { : ""}

) : null} + {githubStarterDraft ? ( +
+

+ Starter draft ready. Select title/subtitle variant, then apply to replace current form. +

+
+ {githubStarterVariants.map((variant) => ( + + ))} +
+ +
+ ) : null} {githubConfidence ? (
(); + + value.forEach((row, index) => { + if (!isRecord(row)) { + return; + } + + const id = typeof row.id === "string" && row.id.trim() ? row.id.trim() : `variant-${index + 1}`; + const title = + typeof row.title === "string" && row.title.trim() ? row.title.trim() : fallbackTitle; + const subtitle = + typeof row.subtitle === "string" && row.subtitle.trim() + ? row.subtitle.trim() + : fallbackSubtitle; + const reason = + typeof row.reason === "string" && row.reason.trim() + ? row.reason.trim() + : "Generated starter variant."; + const dedupeKey = `${title.toLowerCase()}::${subtitle.toLowerCase()}`; + if (unique.has(dedupeKey)) { + return; + } + + unique.set(dedupeKey, { id, title, subtitle, reason }); + }); + + if (unique.size === 0) { + return [ + { + id: "baseline", + title: fallbackTitle, + subtitle: fallbackSubtitle, + reason: "Generated baseline variant.", + }, + ]; + } + + return [...unique.values()]; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + function confidenceLevelColor(level: DraftIntakeConfidence["overallLevel"] | "missing"): string { switch (level) { case "strong": diff --git a/src/app/api/intake/github/route.ts b/src/app/api/intake/github/route.ts index 34ba5d8..bc078ff 100644 --- a/src/app/api/intake/github/route.ts +++ b/src/app/api/intake/github/route.ts @@ -14,6 +14,7 @@ import { buildDraftIntakeConfidence, buildDraftConsistencyReport, } from "@/lib/case-draft-quality"; +import { buildStarterVariants } from "@/lib/case-starter"; type GitHubIntakePayload = { repoUrl?: unknown; @@ -132,12 +133,18 @@ export async function POST(request: Request) { const confidence = buildDraftIntakeConfidence(draft, { evidenceLinks: evidence }); const consistency = buildDraftConsistencyReport(draft, { evidenceLinks: evidence }); + const starterVariants = buildStarterVariants({ + draft, + repoFullName: `${repoRef.owner}/${repoRef.repo}`, + focus, + }); return apiSuccess({ draft, evidence, confidence, consistency, + starterVariants, source: { owner: repoRef.owner, repo: repoRef.repo, diff --git a/src/lib/__tests__/case-starter.test.ts b/src/lib/__tests__/case-starter.test.ts new file mode 100644 index 0000000..191dc9b --- /dev/null +++ b/src/lib/__tests__/case-starter.test.ts @@ -0,0 +1,78 @@ +import { buildStarterVariants, type StarterDraftLike } from "@/lib/case-starter"; + +function createDraft(): StarterDraftLike { + return { + title: "Relationship Simulator", + subtitle: "Designing a deterministic AI onboarding experience.", + sections: [ + { + title: "Approach", + blocks: [ + { + discriminant: "paragraph", + value: { + text: "Mapped key failure modes from support issues and merged PR decisions.", + }, + }, + ], + }, + { + title: "Outcome", + blocks: [ + { + discriminant: "paragraph", + value: { + text: "Activation improved by 17% and first-session churn dropped in two releases.", + }, + }, + ], + }, + ], + }; +} + +describe("buildStarterVariants", () => { + it("returns deterministic starter variants with baseline first", () => { + const first = buildStarterVariants({ + draft: createDraft(), + repoFullName: "Ultraivanov/portfolio", + focus: "ux-driven", + }); + const second = buildStarterVariants({ + draft: createDraft(), + repoFullName: "Ultraivanov/portfolio", + focus: "ux-driven", + }); + + expect(first).toEqual(second); + expect(first).toHaveLength(3); + expect(first[0].id).toBe("baseline"); + expect(first[0].title).toBe("Relationship Simulator"); + expect(first[0].subtitle).toBe("Designing a deterministic AI onboarding experience."); + }); + + it("builds safe fallback title/subtitle when draft is sparse", () => { + const variants = buildStarterVariants({ + draft: { title: "", subtitle: "", sections: [] }, + repoFullName: "Ultraivanov/my-product-case", + focus: "agentic-flow", + }); + + expect(variants.length).toBeGreaterThanOrEqual(1); + expect(variants[0].title).toBe("My Product Case"); + expect(variants[0].subtitle.length).toBeGreaterThan(0); + expect(variants.every((variant) => variant.title.trim().length > 0)).toBe(true); + expect(variants.every((variant) => variant.subtitle.trim().length > 0)).toBe(true); + }); + + it("respects explicit limit", () => { + const variants = buildStarterVariants({ + draft: createDraft(), + repoFullName: "Ultraivanov/portfolio", + focus: "behavioral-model", + limit: 2, + }); + + expect(variants).toHaveLength(2); + }); +}); diff --git a/src/lib/case-starter.ts b/src/lib/case-starter.ts new file mode 100644 index 0000000..2a9c397 --- /dev/null +++ b/src/lib/case-starter.ts @@ -0,0 +1,209 @@ +import type { IntakeFocus } from "@/lib/github-case-intake"; + +export type StarterVariant = { + id: string; + title: string; + subtitle: string; + reason: string; +}; + +export type StarterDraftLike = { + title?: string; + subtitle?: string; + sections?: Array<{ + title: string; + blocks: Array<{ + discriminant: "paragraph" | "list" | "link" | "media"; + value: { + text?: string; + items?: string[]; + [key: string]: unknown; + }; + }>; + }>; +}; + +const FOCUS_LABEL: Record = { + "ux-driven": "UX flow clarity", + "behavioral-model": "behavioral decision model", + "agentic-flow": "agentic runtime flow", +}; + +export function buildStarterVariants(params: { + draft: StarterDraftLike; + repoFullName?: string; + focus?: IntakeFocus; + limit?: number; +}): StarterVariant[] { + const baseTitle = normalizeTitle(params.draft.title, params.repoFullName); + const baseSubtitle = normalizeSubtitle( + params.draft.subtitle, + "Structured case draft grounded in repository evidence." + ); + const repoLabel = normalizeRepoLabel(params.repoFullName); + const focusLabel = params.focus ? FOCUS_LABEL[params.focus] : "product delivery"; + + const outcomeSignal = readSectionSignal(params.draft.sections, "Outcome"); + const approachSignal = readSectionSignal(params.draft.sections, "Approach"); + + const candidates: StarterVariant[] = [ + { + id: "baseline", + title: baseTitle, + subtitle: baseSubtitle, + reason: "Preserves the generated draft wording.", + }, + { + id: "focus", + title: clamp(`${baseTitle}: ${toTitleSuffix(focusLabel)}`, 88), + subtitle: clamp( + `Case from ${repoLabel} with explicit context, constraints, solution, and outcome mapping.`, + 180 + ), + reason: "Highlights the selected intake focus.", + }, + { + id: "evidence", + title: clamp(`${baseTitle}: Evidence-backed case`, 88), + subtitle: clamp( + outcomeSignal + ? `Outcome signal: ${outcomeSignal}` + : approachSignal + ? `Approach signal: ${approachSignal}` + : "Evidence sourced from README, merged PRs, and closed issues.", + 180 + ), + reason: "Makes evidence grounding explicit for quick review.", + }, + ]; + + const unique: StarterVariant[] = []; + const seen = new Set(); + + for (const candidate of candidates) { + const title = normalizeTitle(candidate.title, params.repoFullName); + const subtitle = normalizeSubtitle( + candidate.subtitle, + "Structured case draft grounded in repository evidence." + ); + const key = `${title.toLowerCase()}::${subtitle.toLowerCase()}`; + + if (seen.has(key)) { + continue; + } + + seen.add(key); + unique.push({ + ...candidate, + title, + subtitle, + }); + } + + const limit = + typeof params.limit === "number" && Number.isFinite(params.limit) + ? Math.max(1, Math.floor(params.limit)) + : 3; + return unique.slice(0, limit); +} + +function normalizeTitle(value: string | undefined, repoFullName: string | undefined): string { + const trimmed = (value || "").trim(); + if (trimmed.length > 0) { + return clamp(trimmed, 88); + } + + const repoName = normalizeRepoLabel(repoFullName); + return clamp(toStartCase(repoName), 88); +} + +function normalizeSubtitle(value: string | undefined, fallback: string): string { + const trimmed = (value || "").trim(); + if (trimmed.length > 0) { + return clamp(trimmed, 180); + } + return clamp(fallback, 180); +} + +function normalizeRepoLabel(repoFullName: string | undefined): string { + const raw = (repoFullName || "repository").trim(); + if (!raw) { + return "repository"; + } + + const repoName = raw.includes("/") ? raw.split("/").pop() || raw : raw; + return repoName.replace(/[-_]+/g, " ").trim() || "repository"; +} + +function toStartCase(value: string): string { + return value + .split(/\s+/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +function toTitleSuffix(value: string): string { + const trimmed = value.trim(); + if (!trimmed) { + return "Product workflow"; + } + return trimmed.charAt(0).toUpperCase() + trimmed.slice(1); +} + +function readSectionSignal( + sections: StarterDraftLike["sections"], + sectionTitle: string +): string | null { + if (!sections || sections.length === 0) { + return null; + } + + const section = sections.find( + (item) => normalizeKey(item.title) === normalizeKey(sectionTitle) + ); + + if (!section) { + return null; + } + + for (const block of section.blocks) { + if (block.discriminant === "paragraph") { + const text = cleanSignal(block.value.text); + if (text) { + return text; + } + } + + if (block.discriminant === "list") { + const first = Array.isArray(block.value.items) ? block.value.items[0] : ""; + const text = cleanSignal(first); + if (text) { + return text; + } + } + } + + return null; +} + +function cleanSignal(value: string | undefined): string | null { + const normalized = (value || "").replace(/\s+/g, " ").trim(); + if (!normalized) { + return null; + } + + return clamp(normalized, 140); +} + +function normalizeKey(value: string): string { + return value.trim().toLowerCase(); +} + +function clamp(value: string, max: number): string { + if (value.length <= max) { + return value; + } + + return `${value.slice(0, max - 3).trimEnd()}...`; +} From 2f3fa9e48ecc86753e2fee40cda34e30a76f626a Mon Sep 17 00:00:00 2001 From: Dmitry Ginzburg <33052194+Ultraivanov@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:08:00 +0300 Subject: [PATCH 13/46] feat(cms-ai): add section-level evidence coverage mapping --- .codex/blocks/R-02.md | 29 ++- src/app/admin/page.tsx | 86 ++++++ src/app/api/intake/github/route.ts | 5 + .../__tests__/case-section-evidence.test.ts | 101 ++++++++ src/lib/case-section-evidence.ts | 244 ++++++++++++++++++ 5 files changed, 462 insertions(+), 3 deletions(-) create mode 100644 src/lib/__tests__/case-section-evidence.test.ts create mode 100644 src/lib/case-section-evidence.ts diff --git a/.codex/blocks/R-02.md b/.codex/blocks/R-02.md index fce6c80..f4e3379 100644 --- a/.codex/blocks/R-02.md +++ b/.codex/blocks/R-02.md @@ -25,6 +25,7 @@ Active CMS+AI sprint tasks are executed through approved Change Plans, verified | R-02-T4 | Harden artifact-to-block auto-mapper (S3) | done | Runtime import does not produce uncontrolled duplicates; Visual Artifacts merge is deterministic; tests cover repeated import and route collisions | | R-02-T5 | Rule-based consistency QA bot MVP (S4) | done | API + admin expose rule-based consistency checks (tone/order/evidence) with tests for core rules | | R-02-T6 | One-click case starter MVP shell (S5) | done | API returns starter title/subtitle variants; admin requires explicit Apply action before replacing current form | +| R-02-T7 | Section-level evidence coverage for intake drafts | done | `/api/intake/github` returns `evidenceBySection` and admin shows section coverage summary/details | > New tasks are added here as the block progresses via `init-task`. @@ -34,10 +35,10 @@ Active CMS+AI sprint tasks are executed through approved Change Plans, verified | Field | Value | |-----------|-------| -| Task ID | R-02-T6 | -| Title | One-click case starter MVP shell (S5) | +| Task ID | R-02-T7 | +| Title | Section-level evidence coverage for intake drafts | | Status | done | -| Done When | API returns starter title/subtitle variants; admin requires explicit Apply action before replacing current form | +| Done When | `/api/intake/github` returns `evidenceBySection` and admin shows section coverage summary/details | --- @@ -161,6 +162,26 @@ Generate deterministic starter variants from draft + intake context in API respo **Risks:** Variant text quality may be generic on sparse repos; mitigated by conservative fallbacks and deterministic formatting. +### R-02-T7 — Section-level evidence coverage for intake drafts + +**Files to modify:** +- `.codex/blocks/R-02.md` — task status and session tracking. +- `src/app/api/intake/github/route.ts` — include `evidenceBySection` in intake payload. +- `src/app/admin/page.tsx` — render section-level evidence coverage summary/details in AI Intake panel. + +**Files to create:** +- `src/lib/case-section-evidence.ts` — deterministic section evidence mapper. +- `src/lib/__tests__/case-section-evidence.test.ts` — evidence mapping tests. + +**Files NOT touched:** +- runtime extractor dedupe flow and duplicate `* 2.*` artifacts. + +**Approach:** +Build a deterministic mapper that combines provided evidence links with direct section links and infers section-level coverage by source type. Expose this in API and show compact coverage in admin. + +**Risks:** +Heuristic mapping may under-link sparse repositories; mitigated by using direct section links first and conservative type fallback rules. + --- ## Refactor Backlog @@ -188,6 +209,8 @@ Variant text quality may be generic on sparse repos; mitigated by conservative f | 2026-04-17 | R-02-T5 | done | Added rule-based consistency report in lib/API/admin with tests for order/tone/evidence checks. | | 2026-04-17 | R-02-T6 | in-progress | Started S5 one-click starter implementation with explicit Apply confirmation flow. | | 2026-04-17 | R-02-T6 | done | Added starter variants in API and explicit Apply Starter Draft flow in admin, with tests. | +| 2026-04-17 | R-02-T7 | in-progress | Started section-level evidence coverage implementation for GitHub intake API/admin. | +| 2026-04-17 | R-02-T7 | done | Added deterministic section evidence mapping in API and section-coverage view in admin with tests. | --- diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index e1f971a..424b20e 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -9,6 +9,7 @@ import { type DraftQualityReport, } from "@/lib/case-draft-quality"; import type { StarterVariant } from "@/lib/case-starter"; +import type { SectionEvidenceReport } from "@/lib/case-section-evidence"; interface Fact { label: string; @@ -112,6 +113,7 @@ interface GitHubIntakeApiResponse { } | null; confidence?: DraftIntakeConfidence | null; consistency?: DraftConsistencyReport | null; + evidenceBySection?: SectionEvidenceReport | null; starterVariants?: StarterVariant[]; extractor?: { requested?: boolean; @@ -251,6 +253,8 @@ export default function AdminPage() { const [githubLlmInfo, setGitHubLlmInfo] = useState(null); const [githubConfidence, setGitHubConfidence] = useState(null); const [githubConsistency, setGitHubConsistency] = useState(null); + const [githubEvidenceBySection, setGitHubEvidenceBySection] = + useState(null); const [githubStarterDraft, setGitHubStarterDraft] = useState(null); const [githubStarterVariants, setGitHubStarterVariants] = useState([]); const [selectedStarterVariantId, setSelectedStarterVariantId] = useState(""); @@ -314,6 +318,7 @@ export default function AdminPage() { setGitHubStarterVariants([]); setSelectedStarterVariantId(""); setGitHubExtractorSummary(""); + setGitHubEvidenceBySection(null); void loadCaseContent(selectedCase); }, [selectedCase]); @@ -855,6 +860,7 @@ export default function AdminPage() { setMessage(""); setGitHubConfidence(null); setGitHubConsistency(null); + setGitHubEvidenceBySection(null); setGitHubStarterDraft(null); setGitHubStarterVariants([]); setSelectedStarterVariantId(""); @@ -891,6 +897,7 @@ export default function AdminPage() { setGitHubLlmInfo(payload.llm ?? null); setGitHubConfidence(payload.confidence ?? null); setGitHubConsistency(payload.consistency ?? null); + setGitHubEvidenceBySection(payload.evidenceBySection ?? null); setGitHubStarterDraft(payload.draft); const starterVariants = normalizeStarterVariants(payload.starterVariants, payload.draft); setGitHubStarterVariants(starterVariants); @@ -918,6 +925,7 @@ export default function AdminPage() { } catch (error) { setGitHubConfidence(null); setGitHubConsistency(null); + setGitHubEvidenceBySection(null); setGitHubStarterDraft(null); setGitHubStarterVariants([]); setSelectedStarterVariantId(""); @@ -1524,6 +1532,65 @@ export default function AdminPage() { )}
) : null} + {githubEvidenceBySection ? ( +
+

+ Section evidence coverage:{" "} + + {githubEvidenceBySection.coveredSections}/{githubEvidenceBySection.totalSections} + +

+
+ + Coverage by section ({githubEvidenceBySection.sections.length}) + +
    + {githubEvidenceBySection.sections.map((section) => ( +
  • + {section.section}:{" "} + + {section.coverage} + + {section.links.length > 0 + ? ` • ${section.links.length} link(s) • ${section.sourceTypes.join(", ")}` + : ""} +
  • + ))} +
+
+ {githubEvidenceBySection.unassignedLinks.length > 0 ? ( +
+ + Unassigned links ({githubEvidenceBySection.unassignedLinks.length}) + +
    + {githubEvidenceBySection.unassignedLinks.slice(0, 8).map((href) => ( +
  • + + {href} + +
  • + ))} +
+
+ ) : null} +
+ ) : null} {githubEvidence.length > 0 ? (
@@ -2297,6 +2364,25 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } +function evidenceCoverageColor(covered: number, total: number): string { + if (total <= 0) { + return "var(--color-text-primary)"; + } + + const ratio = covered / total; + if (ratio >= 0.85) { + return "#16a34a"; + } + if (ratio >= 0.5) { + return "#ca8a04"; + } + return "#dc2626"; +} + +function sectionEvidenceStatusColor(status: "present" | "missing"): string { + return status === "present" ? "#16a34a" : "#dc2626"; +} + function confidenceLevelColor(level: DraftIntakeConfidence["overallLevel"] | "missing"): string { switch (level) { case "strong": diff --git a/src/app/api/intake/github/route.ts b/src/app/api/intake/github/route.ts index bc078ff..799049b 100644 --- a/src/app/api/intake/github/route.ts +++ b/src/app/api/intake/github/route.ts @@ -15,6 +15,7 @@ import { buildDraftConsistencyReport, } from "@/lib/case-draft-quality"; import { buildStarterVariants } from "@/lib/case-starter"; +import { buildEvidenceBySection } from "@/lib/case-section-evidence"; type GitHubIntakePayload = { repoUrl?: unknown; @@ -133,6 +134,9 @@ export async function POST(request: Request) { const confidence = buildDraftIntakeConfidence(draft, { evidenceLinks: evidence }); const consistency = buildDraftConsistencyReport(draft, { evidenceLinks: evidence }); + const evidenceBySection = buildEvidenceBySection(draft, { + evidenceLinks: evidence, + }); const starterVariants = buildStarterVariants({ draft, repoFullName: `${repoRef.owner}/${repoRef.repo}`, @@ -142,6 +146,7 @@ export async function POST(request: Request) { return apiSuccess({ draft, evidence, + evidenceBySection, confidence, consistency, starterVariants, diff --git a/src/lib/__tests__/case-section-evidence.test.ts b/src/lib/__tests__/case-section-evidence.test.ts new file mode 100644 index 0000000..7c3c15e --- /dev/null +++ b/src/lib/__tests__/case-section-evidence.test.ts @@ -0,0 +1,101 @@ +import { type CaseDraftLike } from "@/lib/case-draft-quality"; +import { buildEvidenceBySection } from "@/lib/case-section-evidence"; + +function createDraft(): CaseDraftLike { + return { + title: "Agent Workbench", + subtitle: "Evidence-backed UX case", + facts: [{ label: "role", value: "Product Designer" }], + sections: [ + { + title: "Context", + blocks: [{ discriminant: "paragraph", value: { text: "Context text" } }], + }, + { + title: "Problem", + blocks: [{ discriminant: "paragraph", value: { text: "Problem text" } }], + }, + { + title: "Constraints", + blocks: [{ discriminant: "paragraph", value: { text: "Constraints text" } }], + }, + { + title: "Role", + blocks: [{ discriminant: "paragraph", value: { text: "Role text" } }], + }, + { + title: "Approach", + blocks: [{ discriminant: "paragraph", value: { text: "Approach text" } }], + }, + { + title: "Solution", + blocks: [{ discriminant: "paragraph", value: { text: "Solution text" } }], + }, + { + title: "Outcome", + blocks: [{ discriminant: "paragraph", value: { text: "Outcome text" } }], + }, + ], + }; +} + +describe("buildEvidenceBySection", () => { + it("maps repository evidence to required sections with deterministic coverage", () => { + const report = buildEvidenceBySection(createDraft(), { + evidenceLinks: [ + "https://github.com/acme/agent-workbench", + "https://github.com/acme/agent-workbench/pull/10", + "https://github.com/acme/agent-workbench/issues/22", + "https://acme.app/workbench", + ], + }); + + expect(report.totalSections).toBe(7); + expect(report.coveredSections).toBeGreaterThanOrEqual(5); + + const problem = report.sections.find((item) => item.section === "Problem"); + const approach = report.sections.find((item) => item.section === "Approach"); + const context = report.sections.find((item) => item.section === "Context"); + + expect(problem?.links.some((link) => link.includes("/issues/22"))).toBe(true); + expect(approach?.links.some((link) => link.includes("/pull/10"))).toBe(true); + expect(context?.links.some((link) => link === "https://github.com/acme/agent-workbench")).toBe( + true + ); + }); + + it("prioritizes direct section links from draft blocks", () => { + const draft = createDraft(); + const solution = draft.sections.find((section) => section.title === "Solution"); + if (!solution) { + throw new Error("Expected Solution section in test setup."); + } + + solution.blocks = [ + { + discriminant: "link", + value: { + label: "Design doc", + href: "https://github.com/acme/agent-workbench/blob/main/docs/solution.md", + }, + }, + ]; + + const report = buildEvidenceBySection(draft, { + evidenceLinks: ["https://github.com/acme/agent-workbench/pull/10"], + }); + + const solutionCoverage = report.sections.find((item) => item.section === "Solution"); + expect(solutionCoverage?.links[0]).toBe( + "https://github.com/acme/agent-workbench/blob/main/docs/solution.md" + ); + }); + + it("returns missing coverage when no evidence links are available", () => { + const report = buildEvidenceBySection(createDraft(), { evidenceLinks: [] }); + + expect(report.coveredSections).toBe(0); + expect(report.sections.every((item) => item.coverage === "missing")).toBe(true); + expect(report.unassignedLinks).toHaveLength(0); + }); +}); diff --git a/src/lib/case-section-evidence.ts b/src/lib/case-section-evidence.ts new file mode 100644 index 0000000..c3984e9 --- /dev/null +++ b/src/lib/case-section-evidence.ts @@ -0,0 +1,244 @@ +import { + REQUIRED_CASE_SECTIONS, + type CaseDraftLike, +} from "@/lib/case-draft-quality"; + +export type EvidenceSourceType = + | "repo" + | "pull" + | "issue" + | "github-doc" + | "runtime-page" + | "runtime-screenshot" + | "other"; + +export type SectionEvidenceItem = { + section: (typeof REQUIRED_CASE_SECTIONS)[number]; + coverage: "present" | "missing"; + links: string[]; + sourceTypes: EvidenceSourceType[]; +}; + +export type SectionEvidenceReport = { + coveredSections: number; + totalSections: number; + sections: SectionEvidenceItem[]; + unassignedLinks: string[]; +}; + +const SECTION_SOURCE_PRIORITY: Record< + (typeof REQUIRED_CASE_SECTIONS)[number], + EvidenceSourceType[] +> = { + Context: ["repo", "github-doc", "runtime-page", "other"], + Problem: ["issue", "pull", "github-doc"], + Constraints: ["issue", "github-doc", "pull"], + Role: ["repo", "github-doc", "other"], + Approach: ["pull", "github-doc", "runtime-page"], + Solution: ["pull", "runtime-page", "runtime-screenshot", "github-doc"], + Outcome: ["pull", "issue", "github-doc", "runtime-page"], +}; + +const MAX_LINKS_PER_SECTION = 4; + +export function buildEvidenceBySection( + draft: CaseDraftLike, + options?: { evidenceLinks?: string[] } +): SectionEvidenceReport { + const globalEvidence = uniqueHttpLinks(options?.evidenceLinks || []); + const directBySection = collectDirectSectionLinks(draft); + const allEvidence = uniqueHttpLinks([ + ...globalEvidence, + ...REQUIRED_CASE_SECTIONS.flatMap((section) => directBySection.get(section) || []), + ]); + + const byType = new Map(); + for (const link of allEvidence) { + const type = classifyEvidenceLink(link); + const existing = byType.get(type) || []; + existing.push(link); + byType.set(type, existing); + } + + const sections: SectionEvidenceItem[] = REQUIRED_CASE_SECTIONS.map((sectionName) => { + const chosen = selectSectionLinks(sectionName, { + allEvidence, + byType, + directLinks: directBySection.get(sectionName) || [], + }); + + return { + section: sectionName, + coverage: chosen.length > 0 ? "present" : "missing", + links: chosen, + sourceTypes: uniqueSourceTypes(chosen.map((link) => classifyEvidenceLink(link))), + }; + }); + + const assigned = new Set(sections.flatMap((section) => section.links)); + const coveredSections = sections.filter((section) => section.coverage === "present").length; + + return { + coveredSections, + totalSections: sections.length, + sections, + unassignedLinks: allEvidence.filter((link) => !assigned.has(link)), + }; +} + +function selectSectionLinks( + sectionName: (typeof REQUIRED_CASE_SECTIONS)[number], + input: { + allEvidence: string[]; + byType: Map; + directLinks: string[]; + } +): string[] { + const picked: string[] = []; + + const append = (link: string) => { + if (!link || picked.includes(link)) { + return; + } + picked.push(link); + }; + + for (const link of input.directLinks) { + append(link); + if (picked.length >= MAX_LINKS_PER_SECTION) { + return picked; + } + } + + for (const sourceType of SECTION_SOURCE_PRIORITY[sectionName]) { + for (const link of input.byType.get(sourceType) || []) { + append(link); + if (picked.length >= MAX_LINKS_PER_SECTION) { + return picked; + } + } + } + + for (const link of input.byType.get("repo") || []) { + append(link); + if (picked.length >= MAX_LINKS_PER_SECTION) { + return picked; + } + } + + return picked; +} + +function collectDirectSectionLinks( + draft: CaseDraftLike +): Map<(typeof REQUIRED_CASE_SECTIONS)[number], string[]> { + const bySection = new Map<(typeof REQUIRED_CASE_SECTIONS)[number], string[]>(); + + for (const sectionName of REQUIRED_CASE_SECTIONS) { + bySection.set(sectionName, []); + } + + for (const section of draft.sections) { + const sectionName = REQUIRED_CASE_SECTIONS.find( + (required) => normalize(required) === normalize(section.title) + ); + if (!sectionName) { + continue; + } + + const links = bySection.get(sectionName); + if (!links) { + continue; + } + + for (const block of section.blocks) { + if (block.discriminant !== "link") { + continue; + } + if (typeof block.value.href !== "string") { + continue; + } + const href = block.value.href.trim(); + if (isHttpUrl(href) && !links.includes(href)) { + links.push(href); + } + } + } + + return bySection; +} + +function uniqueHttpLinks(links: string[]): string[] { + const deduped = new Set(); + for (const rawLink of links) { + const link = rawLink.trim(); + if (!isHttpUrl(link)) { + continue; + } + deduped.add(link); + } + return [...deduped.values()]; +} + +function uniqueSourceTypes(types: EvidenceSourceType[]): EvidenceSourceType[] { + const unique = new Set(); + for (const type of types) { + unique.add(type); + } + return [...unique.values()]; +} + +function isHttpUrl(value: string): boolean { + if (!value) { + return false; + } + + try { + const parsed = new URL(value); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + return false; + } +} + +function normalize(value: string): string { + return value.trim().toLowerCase(); +} + +function classifyEvidenceLink(href: string): EvidenceSourceType { + try { + const parsed = new URL(href); + const hostname = parsed.hostname.toLowerCase(); + const pathname = parsed.pathname.toLowerCase(); + + if (isImagePath(pathname) || hostname.includes("image.thum.io")) { + return "runtime-screenshot"; + } + + if (hostname === "github.com") { + const segments = pathname.split("/").filter(Boolean); + if (segments.length >= 2 && segments.length <= 2) { + return "repo"; + } + if (pathname.includes("/pull/")) { + return "pull"; + } + if (pathname.includes("/issues/")) { + return "issue"; + } + return "github-doc"; + } + + if (hostname.endsWith("githubusercontent.com")) { + return "github-doc"; + } + + return "runtime-page"; + } catch { + return "other"; + } +} + +function isImagePath(pathname: string): boolean { + return /\.(png|jpe?g|webp|gif|svg)$/i.test(pathname); +} From 2bc6e24aed6c89e8fa384d221c6d1bbfa6016372 Mon Sep 17 00:00:00 2001 From: Dmitry Ginzburg <33052194+Ultraivanov@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:18:24 +0300 Subject: [PATCH 14/46] feat(cms-ai): add blueprint cover candidate flow --- .codex/blocks/R-02.md | 31 +++- src/app/admin/page.tsx | 89 +++++++++- src/app/api/cover/blueprint/route.test.ts | 34 ++++ src/app/api/cover/blueprint/route.ts | 63 +++++++ src/app/api/intake/github/route.ts | 7 + .../blueprint-cover-candidate.test.ts | 56 ++++++ src/lib/blueprint-cover-candidate.ts | 162 ++++++++++++++++++ 7 files changed, 438 insertions(+), 4 deletions(-) create mode 100644 src/app/api/cover/blueprint/route.test.ts create mode 100644 src/app/api/cover/blueprint/route.ts create mode 100644 src/lib/__tests__/blueprint-cover-candidate.test.ts create mode 100644 src/lib/blueprint-cover-candidate.ts diff --git a/.codex/blocks/R-02.md b/.codex/blocks/R-02.md index f4e3379..d9bedcd 100644 --- a/.codex/blocks/R-02.md +++ b/.codex/blocks/R-02.md @@ -26,6 +26,7 @@ Active CMS+AI sprint tasks are executed through approved Change Plans, verified | R-02-T5 | Rule-based consistency QA bot MVP (S4) | done | API + admin expose rule-based consistency checks (tone/order/evidence) with tests for core rules | | R-02-T6 | One-click case starter MVP shell (S5) | done | API returns starter title/subtitle variants; admin requires explicit Apply action before replacing current form | | R-02-T7 | Section-level evidence coverage for intake drafts | done | `/api/intake/github` returns `evidenceBySection` and admin shows section coverage summary/details | +| R-02-T8 | Blueprint cover candidate in intake flow | done | Intake returns deterministic blueprint cover candidate and admin supports explicit apply of cover fields | > New tasks are added here as the block progresses via `init-task`. @@ -35,10 +36,10 @@ Active CMS+AI sprint tasks are executed through approved Change Plans, verified | Field | Value | |-----------|-------| -| Task ID | R-02-T7 | -| Title | Section-level evidence coverage for intake drafts | +| Task ID | R-02-T8 | +| Title | Blueprint cover candidate in intake flow | | Status | done | -| Done When | `/api/intake/github` returns `evidenceBySection` and admin shows section coverage summary/details | +| Done When | Intake returns deterministic blueprint cover candidate and admin supports explicit apply of cover fields | --- @@ -182,6 +183,28 @@ Build a deterministic mapper that combines provided evidence links with direct s **Risks:** Heuristic mapping may under-link sparse repositories; mitigated by using direct section links first and conservative type fallback rules. +### R-02-T8 — Blueprint cover candidate in intake flow + +**Files to modify:** +- `.codex/blocks/R-02.md` — task status and session tracking. +- `src/app/api/intake/github/route.ts` — include `coverCandidate` in intake payload. +- `src/app/admin/page.tsx` — preview and explicit apply action for blueprint cover candidate. + +**Files to create:** +- `src/lib/blueprint-cover-candidate.ts` — deterministic blueprint candidate + SVG renderer. +- `src/lib/__tests__/blueprint-cover-candidate.test.ts` — candidate and renderer tests. +- `src/app/api/cover/blueprint/route.ts` — GET endpoint returning SVG by query. +- `src/app/api/cover/blueprint/route.test.ts` — route response contract tests. + +**Files NOT touched:** +- runtime import flow and duplicate `* 2.*` artifacts. + +**Approach:** +Create deterministic blueprint cover candidate derived from intake draft + focus, expose it in API payload, render preview SVG via dedicated route, and require explicit apply action before changing `coverSrc/coverAlt`. + +**Risks:** +Dynamic SVG cover URLs may be less CDN-friendly than static assets; mitigated by deterministic query params and cache headers. + --- ## Refactor Backlog @@ -211,6 +234,8 @@ Heuristic mapping may under-link sparse repositories; mitigated by using direct | 2026-04-17 | R-02-T6 | done | Added starter variants in API and explicit Apply Starter Draft flow in admin, with tests. | | 2026-04-17 | R-02-T7 | in-progress | Started section-level evidence coverage implementation for GitHub intake API/admin. | | 2026-04-17 | R-02-T7 | done | Added deterministic section evidence mapping in API and section-coverage view in admin with tests. | +| 2026-04-17 | R-02-T8 | in-progress | Started blueprint cover candidate implementation in intake API/admin and dedicated SVG route. | +| 2026-04-17 | R-02-T8 | done | Added deterministic blueprint cover candidate, SVG route, admin preview/apply action, and tests. | --- diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 424b20e..59fa4ec 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -10,6 +10,7 @@ import { } from "@/lib/case-draft-quality"; import type { StarterVariant } from "@/lib/case-starter"; import type { SectionEvidenceReport } from "@/lib/case-section-evidence"; +import type { BlueprintCoverCandidate } from "@/lib/blueprint-cover-candidate"; interface Fact { label: string; @@ -115,6 +116,7 @@ interface GitHubIntakeApiResponse { consistency?: DraftConsistencyReport | null; evidenceBySection?: SectionEvidenceReport | null; starterVariants?: StarterVariant[]; + coverCandidate?: BlueprintCoverCandidate | null; extractor?: { requested?: boolean; executed?: boolean; @@ -258,6 +260,8 @@ export default function AdminPage() { const [githubStarterDraft, setGitHubStarterDraft] = useState(null); const [githubStarterVariants, setGitHubStarterVariants] = useState([]); const [selectedStarterVariantId, setSelectedStarterVariantId] = useState(""); + const [githubCoverCandidate, setGitHubCoverCandidate] = + useState(null); const [githubExtractorSummary, setGitHubExtractorSummary] = useState(""); const draftQualityReport: DraftQualityReport | null = useMemo(() => { if (!caseData) return null; @@ -319,6 +323,7 @@ export default function AdminPage() { setSelectedStarterVariantId(""); setGitHubExtractorSummary(""); setGitHubEvidenceBySection(null); + setGitHubCoverCandidate(null); void loadCaseContent(selectedCase); }, [selectedCase]); @@ -865,6 +870,7 @@ export default function AdminPage() { setGitHubStarterVariants([]); setSelectedStarterVariantId(""); setGitHubExtractorSummary(""); + setGitHubCoverCandidate(null); try { const response = await fetch("/api/intake/github", { method: "POST", @@ -898,6 +904,7 @@ export default function AdminPage() { setGitHubConfidence(payload.confidence ?? null); setGitHubConsistency(payload.consistency ?? null); setGitHubEvidenceBySection(payload.evidenceBySection ?? null); + setGitHubCoverCandidate(payload.coverCandidate ?? null); setGitHubStarterDraft(payload.draft); const starterVariants = normalizeStarterVariants(payload.starterVariants, payload.draft); setGitHubStarterVariants(starterVariants); @@ -920,7 +927,7 @@ export default function AdminPage() { setGitHubExtractorSummary(extractorStatus.trim()); setMessage( - `✅ GitHub draft generated. Choose starter variant and click Apply Starter Draft before save.${extractorStatus}` + `✅ GitHub draft generated. Apply starter draft and cover candidate as needed before save.${extractorStatus}` ); } catch (error) { setGitHubConfidence(null); @@ -930,6 +937,7 @@ export default function AdminPage() { setGitHubStarterVariants([]); setSelectedStarterVariantId(""); setGitHubExtractorSummary(""); + setGitHubCoverCandidate(null); setMessage( `❌ Draft generation failed: ${ error instanceof Error ? error.message : "Unknown error" @@ -982,6 +990,39 @@ export default function AdminPage() { ); }; + const handleApplyCandidateCover = () => { + if (!caseData || !githubCoverCandidate) { + setMessage("❌ Generate intake cover candidate first."); + return; + } + + const shouldApply = window.confirm( + "Apply blueprint cover candidate to current case cover fields?" + ); + if (!shouldApply) { + setMessage("ℹ️ Cover candidate apply cancelled."); + return; + } + + const nextCaseData: CaseStudy = { + ...caseData, + coverSrc: githubCoverCandidate.previewUrl, + coverAlt: githubCoverCandidate.alt, + seo: { + ...caseData.seo, + ogImage: caseData.seo?.ogImage || githubCoverCandidate.previewUrl, + }, + }; + setCaseData(nextCaseData); + const savedDraft = writeCaseDraft(selectedCase, nextCaseData); + if (savedDraft) { + setDraftSavedAt(savedDraft.updatedAt); + setAvailableDraft(null); + } + + setMessage("✅ Blueprint cover candidate applied. Review and save."); + }; + const handleImportRuntimeScreenshots = async () => { if (!caseData || githubRuntimeScreenshots.length === 0) { setMessage("❌ No runtime screenshots to import."); @@ -1445,6 +1486,52 @@ export default function AdminPage() { ) : null} + {githubCoverCandidate ? ( +
+

+ Blueprint cover candidate ({githubCoverCandidate.focus}) +

+
+ {githubCoverCandidate.alt} +
+

+ {githubCoverCandidate.title} + {" • "} + {githubCoverCandidate.subtitle} +

+ +
+ ) : null} {githubConfidence ? (
{ + it("returns svg response with expected content-type", async () => { + const request = new Request( + "http://localhost/api/cover/blueprint?title=Agent%20Workbench&subtitle=UX%20case&focus=agentic-flow" + ); + + const response = await GET(request); + const body = await response.text(); + + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toContain("image/svg+xml"); + expect(body).toContain(" { + const request = new Request( + "http://localhost/api/cover/blueprint?title=Case&subtitle=Sub&focus=unknown&width=99999&height=10" + ); + + const response = await GET(request); + const body = await response.text(); + + expect(response.status).toBe(200); + expect(body).toContain("UX-driven"); + expect(body).toContain('viewBox="0 0 2400 450"'); + }); +}); diff --git a/src/app/api/cover/blueprint/route.ts b/src/app/api/cover/blueprint/route.ts new file mode 100644 index 0000000..df1771a --- /dev/null +++ b/src/app/api/cover/blueprint/route.ts @@ -0,0 +1,63 @@ +import { apiError } from "@/lib/api-response"; +import { renderBlueprintCoverSvg } from "@/lib/blueprint-cover-candidate"; +import type { IntakeFocus } from "@/lib/github-case-intake"; + +const ALLOWED_FOCUS: ReadonlySet = new Set([ + "ux-driven", + "behavioral-model", + "agentic-flow", +]); + +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url); + + const title = (searchParams.get("title") || "").trim(); + const subtitle = (searchParams.get("subtitle") || "").trim(); + const focus = normalizeFocus(searchParams.get("focus")); + const width = normalizeDimension(searchParams.get("width")); + const height = normalizeDimension(searchParams.get("height")); + + const svg = renderBlueprintCoverSvg({ + title, + subtitle, + focus, + width, + height, + }); + + return new Response(svg, { + status: 200, + headers: { + "Content-Type": "image/svg+xml; charset=utf-8", + "Cache-Control": "public, max-age=3600", + }, + }); + } catch (error) { + return apiError( + 500, + "BLUEPRINT_COVER_FAILED", + error instanceof Error ? error.message : "Failed to render blueprint cover" + ); + } +} + +function normalizeFocus(value: string | null): IntakeFocus { + if (value && ALLOWED_FOCUS.has(value as IntakeFocus)) { + return value as IntakeFocus; + } + return "ux-driven"; +} + +function normalizeDimension(value: string | null): number | undefined { + if (!value) { + return undefined; + } + + const parsed = Number.parseInt(value, 10); + if (!Number.isFinite(parsed)) { + return undefined; + } + + return parsed; +} diff --git a/src/app/api/intake/github/route.ts b/src/app/api/intake/github/route.ts index 799049b..4fb5401 100644 --- a/src/app/api/intake/github/route.ts +++ b/src/app/api/intake/github/route.ts @@ -16,6 +16,7 @@ import { } from "@/lib/case-draft-quality"; import { buildStarterVariants } from "@/lib/case-starter"; import { buildEvidenceBySection } from "@/lib/case-section-evidence"; +import { buildBlueprintCoverCandidate } from "@/lib/blueprint-cover-candidate"; type GitHubIntakePayload = { repoUrl?: unknown; @@ -142,6 +143,11 @@ export async function POST(request: Request) { repoFullName: `${repoRef.owner}/${repoRef.repo}`, focus, }); + const coverCandidate = buildBlueprintCoverCandidate({ + title: draft.title, + subtitle: draft.subtitle, + focus, + }); return apiSuccess({ draft, @@ -150,6 +156,7 @@ export async function POST(request: Request) { confidence, consistency, starterVariants, + coverCandidate, source: { owner: repoRef.owner, repo: repoRef.repo, diff --git a/src/lib/__tests__/blueprint-cover-candidate.test.ts b/src/lib/__tests__/blueprint-cover-candidate.test.ts new file mode 100644 index 0000000..1b1cffd --- /dev/null +++ b/src/lib/__tests__/blueprint-cover-candidate.test.ts @@ -0,0 +1,56 @@ +import { + buildBlueprintCoverCandidate, + renderBlueprintCoverSvg, +} from "@/lib/blueprint-cover-candidate"; + +describe("buildBlueprintCoverCandidate", () => { + it("builds deterministic preview payload from title, subtitle and focus", () => { + const first = buildBlueprintCoverCandidate({ + title: "Agent Workbench", + subtitle: "Structured UX case", + focus: "agentic-flow", + }); + const second = buildBlueprintCoverCandidate({ + title: "Agent Workbench", + subtitle: "Structured UX case", + focus: "agentic-flow", + }); + + expect(first).toEqual(second); + expect(first.previewUrl).toContain("/api/cover/blueprint?"); + expect(first.previewUrl).toContain("focus=agentic-flow"); + expect(first.alt).toContain("blueprint cover"); + }); + + it("normalizes long input text and preserves safe defaults", () => { + const candidate = buildBlueprintCoverCandidate({ + title: "x".repeat(120), + subtitle: "", + focus: "ux-driven", + width: 99999, + height: -10, + }); + + expect(candidate.title.length).toBeLessThanOrEqual(72); + expect(candidate.subtitle.length).toBeGreaterThan(0); + expect(candidate.width).toBe(2400); + expect(candidate.height).toBe(450); + }); +}); + +describe("renderBlueprintCoverSvg", () => { + it("renders svg and escapes xml-sensitive characters", () => { + const svg = renderBlueprintCoverSvg({ + title: "A ", + subtitle: "B & C", + focus: "behavioral-model", + width: 1600, + height: 900, + }); + + expect(svg).toContain(" = { + "ux-driven": { + background: "#06264a", + grid: "#2f5f8f", + ink: "#d9ecff", + accent: "#86d6ff", + }, + "behavioral-model": { + background: "#0d2542", + grid: "#476b8d", + ink: "#e6f0ff", + accent: "#9ac3ff", + }, + "agentic-flow": { + background: "#07253d", + grid: "#3e6b8d", + ink: "#dff6ff", + accent: "#84f0ff", + }, +}; + +const FOCUS_LABEL: Record = { + "ux-driven": "UX-driven", + "behavioral-model": "Behavioral model", + "agentic-flow": "Agentic flow", +}; + +export function buildBlueprintCoverCandidate(params: { + title: string; + subtitle: string; + focus: IntakeFocus; + width?: number; + height?: number; +}): BlueprintCoverCandidate { + const focus = normalizeFocus(params.focus); + const width = normalizeDimension(params.width, DEFAULT_WIDTH, 800, 2400); + const height = normalizeDimension(params.height, DEFAULT_HEIGHT, 450, 1600); + const title = sanitizeText(params.title, TITLE_MAX, "Case Study"); + const subtitle = sanitizeText(params.subtitle, SUBTITLE_MAX, "Evidence-backed product case"); + const alt = `${title} blueprint cover (${FOCUS_LABEL[focus]})`; + + const query = new URLSearchParams({ + title, + subtitle, + focus, + width: String(width), + height: String(height), + }); + + return { + mode: "blueprint", + focus, + title, + subtitle, + alt, + width, + height, + palette: FOCUS_PALETTE[focus], + previewUrl: `/api/cover/blueprint?${query.toString()}`, + }; +} + +export function renderBlueprintCoverSvg(params: { + title: string; + subtitle: string; + focus: IntakeFocus; + width?: number; + height?: number; +}): string { + const focus = normalizeFocus(params.focus); + const width = normalizeDimension(params.width, DEFAULT_WIDTH, 800, 2400); + const height = normalizeDimension(params.height, DEFAULT_HEIGHT, 450, 1600); + const title = escapeXml(sanitizeText(params.title, TITLE_MAX, "Case Study")); + const subtitle = escapeXml(sanitizeText(params.subtitle, SUBTITLE_MAX, "Evidence-backed product case")); + const focusLabel = escapeXml(FOCUS_LABEL[focus]); + const palette = FOCUS_PALETTE[focus]; + + const titleY = Math.round(height * 0.42); + const subtitleY = titleY + 74; + const labelY = subtitleY + 56; + const safeInset = Math.round(width * 0.08); + + return ` + + + + + + + + + + + ${title} + ${subtitle} + ${focusLabel} +`; +} + +function normalizeFocus(value: IntakeFocus): IntakeFocus { + return value === "behavioral-model" || value === "agentic-flow" ? value : "ux-driven"; +} + +function sanitizeText(value: string, maxLength: number, fallback: string): string { + const normalized = value.replace(/\s+/g, " ").trim(); + if (!normalized) { + return fallback; + } + + if (normalized.length <= maxLength) { + return normalized; + } + + return `${normalized.slice(0, maxLength - 3).trimEnd()}...`; +} + +function normalizeDimension( + value: number | undefined, + fallback: number, + min: number, + max: number +): number { + if (typeof value !== "number" || !Number.isFinite(value)) { + return fallback; + } + return Math.max(min, Math.min(max, Math.round(value))); +} + +function escapeXml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} From ab3285f308329484d17bd14431ba5b7fb8ee2f2c Mon Sep 17 00:00:00 2001 From: Dmitry Ginzburg <33052194+Ultraivanov@users.noreply.github.com> Date: Fri, 17 Apr 2026 11:41:05 +0300 Subject: [PATCH 15/46] feat(cms-admin): add sticky save bar and dirty state tracking --- .codex/blocks/R-02.md | 27 +++++++++++++++-- src/app/admin/page.tsx | 66 ++++++++++++++++++++++++++++++++++++++---- 2 files changed, 84 insertions(+), 9 deletions(-) diff --git a/.codex/blocks/R-02.md b/.codex/blocks/R-02.md index d9bedcd..0ffa438 100644 --- a/.codex/blocks/R-02.md +++ b/.codex/blocks/R-02.md @@ -27,6 +27,7 @@ Active CMS+AI sprint tasks are executed through approved Change Plans, verified | R-02-T6 | One-click case starter MVP shell (S5) | done | API returns starter title/subtitle variants; admin requires explicit Apply action before replacing current form | | R-02-T7 | Section-level evidence coverage for intake drafts | done | `/api/intake/github` returns `evidenceBySection` and admin shows section coverage summary/details | | R-02-T8 | Blueprint cover candidate in intake flow | done | Intake returns deterministic blueprint cover candidate and admin supports explicit apply of cover fields | +| R-02-T9 | Sticky save bar + unsaved state UX in admin | done | Admin shows sticky save controls and explicit unsaved/synced state relative to repository baseline | > New tasks are added here as the block progresses via `init-task`. @@ -36,10 +37,10 @@ Active CMS+AI sprint tasks are executed through approved Change Plans, verified | Field | Value | |-----------|-------| -| Task ID | R-02-T8 | -| Title | Blueprint cover candidate in intake flow | +| Task ID | R-02-T9 | +| Title | Sticky save bar + unsaved state UX in admin | | Status | done | -| Done When | Intake returns deterministic blueprint cover candidate and admin supports explicit apply of cover fields | +| Done When | Admin shows sticky save controls and explicit unsaved/synced state relative to repository baseline | --- @@ -205,6 +206,24 @@ Create deterministic blueprint cover candidate derived from intake draft + focus **Risks:** Dynamic SVG cover URLs may be less CDN-friendly than static assets; mitigated by deterministic query params and cache headers. +### R-02-T9 — Sticky save bar + unsaved state UX in admin + +**Files to modify:** +- `.codex/blocks/R-02.md` — task status and session tracking. +- `src/app/admin/page.tsx` — add repo-baseline dirty tracking and sticky save bar UX. + +**Files to create:** +- none. + +**Files NOT touched:** +- intake extraction logic, cover generation logic, and duplicate `* 2.*` artifacts. + +**Approach:** +Track server baseline snapshot on load/save, compute dirty state from current form content, disable save when no changes, and convert save controls block into sticky bar with clear state messaging. + +**Risks:** +Snapshot comparison can produce false positives if object shape/order is unstable; mitigated by using single source object updates and consistent JSON serialization. + --- ## Refactor Backlog @@ -236,6 +255,8 @@ Dynamic SVG cover URLs may be less CDN-friendly than static assets; mitigated by | 2026-04-17 | R-02-T7 | done | Added deterministic section evidence mapping in API and section-coverage view in admin with tests. | | 2026-04-17 | R-02-T8 | in-progress | Started blueprint cover candidate implementation in intake API/admin and dedicated SVG route. | | 2026-04-17 | R-02-T8 | done | Added deterministic blueprint cover candidate, SVG route, admin preview/apply action, and tests. | +| 2026-04-17 | R-02-T9 | in-progress | Started sticky save bar and unsaved/synced state UX implementation in admin. | +| 2026-04-17 | R-02-T9 | done | Added sticky save bar with repo-baseline dirty tracking and explicit unsaved/synced UX state. | --- diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 59fa4ec..66ff025 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -232,6 +232,7 @@ export default function AdminPage() { >({}); const [availableDraft, setAvailableDraft] = useState(null); const [draftSavedAt, setDraftSavedAt] = useState(null); + const [lastSyncedSnapshot, setLastSyncedSnapshot] = useState(null); const [newCaseSlug, setNewCaseSlug] = useState(""); const [newCaseTitle, setNewCaseTitle] = useState(""); const [creatingCase, setCreatingCase] = useState(false); @@ -267,6 +268,12 @@ export default function AdminPage() { if (!caseData) return null; return analyzeCaseDraftQuality(caseData, { evidenceLinks: githubEvidence }); }, [caseData, githubEvidence]); + const hasUnsavedChanges = useMemo(() => { + if (!caseData || !lastSyncedSnapshot) { + return false; + } + return serializeCaseSnapshot(caseData) !== lastSyncedSnapshot; + }, [caseData, lastSyncedSnapshot]); const getBlockKey = (sectionIndex: number, blockIndex: number): string => `${sectionIndex}:${blockIndex}`; @@ -295,6 +302,7 @@ export default function AdminPage() { const payload = (await response.json()) as { item?: CaseStudy }; if (response.ok && payload.item) { setCaseData(payload.item); + setLastSyncedSnapshot(serializeCaseSnapshot(payload.item)); const draft = readCaseDraft(slug); setDraftSavedAt(draft?.updatedAt ?? null); if (draft && JSON.stringify(draft.data) !== JSON.stringify(payload.item)) { @@ -324,6 +332,7 @@ export default function AdminPage() { setGitHubExtractorSummary(""); setGitHubEvidenceBySection(null); setGitHubCoverCandidate(null); + setLastSyncedSnapshot(null); void loadCaseContent(selectedCase); }, [selectedCase]); @@ -485,6 +494,10 @@ export default function AdminPage() { setMessage(`❌ ${error}`); return; } + if (!hasUnsavedChanges) { + setMessage("ℹ️ No unsaved changes."); + return; + } if ((draftQualityReport?.summary.critical || 0) > 0) { const shouldSaveAnyway = window.confirm( @@ -499,6 +512,7 @@ export default function AdminPage() { setSaving(true); setMessage(""); setHasContentConflict(false); + const snapshotBeforeSave = serializeCaseSnapshot(caseData); const path = `src/content/cases/${selectedCase}.json`; @@ -519,6 +533,7 @@ export default function AdminPage() { clearCaseDraft(selectedCase); setAvailableDraft(null); setDraftSavedAt(null); + setLastSyncedSnapshot(snapshotBeforeSave); } else { const errorCode = getApiErrorCode(result); if (errorCode === "CONTENT_CONFLICT") { @@ -1969,21 +1984,56 @@ export default function AdminPage() {
-
+
+ + {hasUnsavedChanges ? "Unsaved changes" : "Synced with repository"} + {hasContentConflict && ( @@ -2239,7 +2289,7 @@ export default function AdminPage() { const feedback = mediaUploadFeedbackByBlock[getBlockKey(sectionIndex, blockIndex)]; if (!feedback) return null; - return ( + return (
Date: Fri, 17 Apr 2026 12:12:33 +0300 Subject: [PATCH 16/46] feat(cms-admin): add live validation and save readiness state --- .codex/blocks/R-02.md | 27 +++++++++++++-- src/app/admin/page.tsx | 79 ++++++++++++++++++++++++++++++++---------- 2 files changed, 84 insertions(+), 22 deletions(-) diff --git a/.codex/blocks/R-02.md b/.codex/blocks/R-02.md index 0ffa438..4c48f15 100644 --- a/.codex/blocks/R-02.md +++ b/.codex/blocks/R-02.md @@ -28,6 +28,7 @@ Active CMS+AI sprint tasks are executed through approved Change Plans, verified | R-02-T7 | Section-level evidence coverage for intake drafts | done | `/api/intake/github` returns `evidenceBySection` and admin shows section coverage summary/details | | R-02-T8 | Blueprint cover candidate in intake flow | done | Intake returns deterministic blueprint cover candidate and admin supports explicit apply of cover fields | | R-02-T9 | Sticky save bar + unsaved state UX in admin | done | Admin shows sticky save controls and explicit unsaved/synced state relative to repository baseline | +| R-02-T10 | Live inline validation and save readiness state | done | Admin shows live validation issues and save is enabled only when form is ready | > New tasks are added here as the block progresses via `init-task`. @@ -37,10 +38,10 @@ Active CMS+AI sprint tasks are executed through approved Change Plans, verified | Field | Value | |-----------|-------| -| Task ID | R-02-T9 | -| Title | Sticky save bar + unsaved state UX in admin | +| Task ID | R-02-T10 | +| Title | Live inline validation and save readiness state | | Status | done | -| Done When | Admin shows sticky save controls and explicit unsaved/synced state relative to repository baseline | +| Done When | Admin shows live validation issues and save is enabled only when form is ready | --- @@ -224,6 +225,24 @@ Track server baseline snapshot on load/save, compute dirty state from current fo **Risks:** Snapshot comparison can produce false positives if object shape/order is unstable; mitigated by using single source object updates and consistent JSON serialization. +### R-02-T10 — Live inline validation and save readiness state + +**Files to modify:** +- `.codex/blocks/R-02.md` — task status and session tracking. +- `src/app/admin/page.tsx` — switch to live validation issue list and readiness-aware save UX. + +**Files to create:** +- none. + +**Files NOT touched:** +- intake APIs, cover candidate logic, and duplicate `* 2.*` artifacts. + +**Approach:** +Convert case validation from single error string to issue list, surface issues inline in sticky save bar, and disable save while issues exist. + +**Risks:** +Stricter client-side save gating may block workflows unexpectedly; mitigated by clear issue text and deterministic validation criteria. + --- ## Refactor Backlog @@ -257,6 +276,8 @@ Snapshot comparison can produce false positives if object shape/order is unstabl | 2026-04-17 | R-02-T8 | done | Added deterministic blueprint cover candidate, SVG route, admin preview/apply action, and tests. | | 2026-04-17 | R-02-T9 | in-progress | Started sticky save bar and unsaved/synced state UX implementation in admin. | | 2026-04-17 | R-02-T9 | done | Added sticky save bar with repo-baseline dirty tracking and explicit unsaved/synced UX state. | +| 2026-04-17 | R-02-T10 | in-progress | Started live inline validation and save readiness UX implementation in sticky save bar. | +| 2026-04-17 | R-02-T10 | done | Added live validation issue list in sticky save bar and readiness-aware save gating. | --- diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 66ff025..c938870 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -274,6 +274,13 @@ export default function AdminPage() { } return serializeCaseSnapshot(caseData) !== lastSyncedSnapshot; }, [caseData, lastSyncedSnapshot]); + const validationIssues = useMemo(() => { + if (!caseData) { + return []; + } + return validateCaseIssues(caseData); + }, [caseData]); + const hasValidationIssues = validationIssues.length > 0; const getBlockKey = (sectionIndex: number, blockIndex: number): string => `${sectionIndex}:${blockIndex}`; @@ -466,19 +473,6 @@ export default function AdminPage() { return normalized || "Unknown error"; }; - const validateCase = (data: CaseStudy): string | null => { - if (!data.title.trim()) return "Title is required"; - if (!data.slug.trim()) return "Slug is required"; - if (!data.coverAlt.trim()) return "Cover alt text is required"; - // Check for empty fact labels - const emptyFact = data.facts.find(f => !f.label.trim()); - if (emptyFact) return "All fact labels must be filled"; - // Check for empty section titles - const emptySection = data.sections.find(s => !s.title.trim()); - if (emptySection) return "All section titles must be filled"; - return null; - }; - const handleSave = async () => { if (!caseData) return; const hasUploadingMedia = Object.values(mediaUploadFeedbackByBlock).some( @@ -489,9 +483,8 @@ export default function AdminPage() { return; } - const error = validateCase(caseData); - if (error) { - setMessage(`❌ ${error}`); + if (hasValidationIssues) { + setMessage("❌ Fix validation issues before save."); return; } if (!hasUnsavedChanges) { @@ -2010,18 +2003,29 @@ export default function AdminPage() { > {hasUnsavedChanges ? "Unsaved changes" : "Synced with repository"} + + {hasValidationIssues ? `Fix validation issues (${validationIssues.length})` : "Ready to save"} +
{draftSavedAt && ( @@ -2433,6 +2451,29 @@ function serializeCaseSnapshot(value: CaseStudy): string { return JSON.stringify(value); } +function validateCaseIssues(data: CaseStudy): string[] { + const issues: string[] = []; + + if (!data.title.trim()) { + issues.push("Title is required."); + } + if (!data.slug.trim()) { + issues.push("Slug is required."); + } + if (!data.coverAlt.trim()) { + issues.push("Cover alt text is required."); + } + + if (data.facts.some((fact) => !fact.label.trim())) { + issues.push("All fact labels must be filled."); + } + if (data.sections.some((section) => !section.title.trim())) { + issues.push("All section titles must be filled."); + } + + return issues; +} + function severityRank(severity: DraftQualityIssue["severity"]): number { switch (severity) { case "critical": From b3224f30baa01cd3c876ca79576905d66d3c3a68 Mon Sep 17 00:00:00 2001 From: Dmitry Ginzburg <33052194+Ultraivanov@users.noreply.github.com> Date: Fri, 17 Apr 2026 22:30:35 +0300 Subject: [PATCH 17/46] refactor(admin): extract AI intake panel component --- .codex/blocks/R-02.md | 27 +- src/app/admin/components/AiIntakePanel.tsx | 567 +++++++++++++++++++++ src/app/admin/page.tsx | 512 ++----------------- 3 files changed, 628 insertions(+), 478 deletions(-) create mode 100644 src/app/admin/components/AiIntakePanel.tsx diff --git a/.codex/blocks/R-02.md b/.codex/blocks/R-02.md index 4c48f15..edbd392 100644 --- a/.codex/blocks/R-02.md +++ b/.codex/blocks/R-02.md @@ -29,6 +29,7 @@ Active CMS+AI sprint tasks are executed through approved Change Plans, verified | R-02-T8 | Blueprint cover candidate in intake flow | done | Intake returns deterministic blueprint cover candidate and admin supports explicit apply of cover fields | | R-02-T9 | Sticky save bar + unsaved state UX in admin | done | Admin shows sticky save controls and explicit unsaved/synced state relative to repository baseline | | R-02-T10 | Live inline validation and save readiness state | done | Admin shows live validation issues and save is enabled only when form is ready | +| R-02-T11 | Modularize AI Intake panel in admin editor | done | AI Intake UI block is extracted into `AiIntakePanel` component with no behavior regression and passing verification checks | > New tasks are added here as the block progresses via `init-task`. @@ -38,10 +39,10 @@ Active CMS+AI sprint tasks are executed through approved Change Plans, verified | Field | Value | |-----------|-------| -| Task ID | R-02-T10 | -| Title | Live inline validation and save readiness state | +| Task ID | R-02-T11 | +| Title | Modularize AI Intake panel in admin editor | | Status | done | -| Done When | Admin shows live validation issues and save is enabled only when form is ready | +| Done When | AI Intake UI block is extracted into `AiIntakePanel` component with no behavior regression and passing verification checks | --- @@ -243,6 +244,24 @@ Convert case validation from single error string to issue list, surface issues i **Risks:** Stricter client-side save gating may block workflows unexpectedly; mitigated by clear issue text and deterministic validation criteria. +### R-02-T11 — Modularize AI Intake panel in admin editor + +**Files to modify:** +- `.codex/blocks/R-02.md` — task status and session tracking. +- `src/app/admin/page.tsx` — replace inline AI Intake block with component usage. + +**Files to create:** +- `src/app/admin/components/AiIntakePanel.tsx` — extracted AI Intake presentation and local display helpers. + +**Files NOT touched:** +- intake API contracts, cover generation logic, and duplicate `* 2.*` artifacts. + +**Approach:** +Extract the full AI Intake block into a dedicated component with explicit props and localize intake-specific visual helper functions inside it, keeping existing handlers/state orchestration in `page.tsx`. + +**Risks:** +Type mismatches between page state and extracted props can break build; mitigated by explicit prop typing and full test/lint/build verification. + --- ## Refactor Backlog @@ -278,6 +297,8 @@ Stricter client-side save gating may block workflows unexpectedly; mitigated by | 2026-04-17 | R-02-T9 | done | Added sticky save bar with repo-baseline dirty tracking and explicit unsaved/synced UX state. | | 2026-04-17 | R-02-T10 | in-progress | Started live inline validation and save readiness UX implementation in sticky save bar. | | 2026-04-17 | R-02-T10 | done | Added live validation issue list in sticky save bar and readiness-aware save gating. | +| 2026-04-17 | R-02-T11 | in-progress | Started AI Intake modularization by extracting inline admin panel into dedicated component. | +| 2026-04-17 | R-02-T11 | done | Extracted AI Intake into `AiIntakePanel`, wired props/handlers, and passed test/lint/build checks. | --- diff --git a/src/app/admin/components/AiIntakePanel.tsx b/src/app/admin/components/AiIntakePanel.tsx new file mode 100644 index 0000000..bdb5e4b --- /dev/null +++ b/src/app/admin/components/AiIntakePanel.tsx @@ -0,0 +1,567 @@ +"use client"; + +import type { CSSProperties } from "react"; +import type { + DraftConsistencyReport, + DraftIntakeConfidence, + DraftQualityIssue, +} from "@/lib/case-draft-quality"; +import type { StarterVariant } from "@/lib/case-starter"; +import type { SectionEvidenceReport } from "@/lib/case-section-evidence"; +import type { BlueprintCoverCandidate } from "@/lib/blueprint-cover-candidate"; + +export type IntakeFocus = "ux-driven" | "behavioral-model" | "agentic-flow"; +export type AnalysisMode = "llm" | "heuristic"; + +type RuntimeScreenshotPlan = { + route: string; + pageUrl: string; + screenshotUrl: string; + status: "planned"; +}; + +type LlmInfo = { + model?: string; + usage?: { + totalTokens?: number; + }; +} | null | undefined; + +type AiIntakePanelProps = { + fieldStyle: CSSProperties; + labelStyle: CSSProperties; + inputStyle: CSSProperties; + githubRepoUrl: string; + onGitHubRepoUrlChange: (value: string) => void; + githubFocus: IntakeFocus; + onGitHubFocusChange: (value: IntakeFocus) => void; + githubAnalysisMode: AnalysisMode; + onGitHubAnalysisModeChange: (value: AnalysisMode) => void; + githubRuntimeBaseUrl: string; + onGitHubRuntimeBaseUrlChange: (value: string) => void; + githubScreenshotLimit: number; + onGitHubScreenshotLimitChange: (value: number) => void; + generatingGitHubDraft: boolean; + onGenerateGitHubDraft: () => void; + githubLlmInfo: LlmInfo; + hasGithubStarterDraft: boolean; + githubStarterVariants: StarterVariant[]; + selectedStarterVariantId: string; + onSelectStarterVariant: (id: string) => void; + onApplyStarterDraft: () => void; + githubCoverCandidate: BlueprintCoverCandidate | null; + onApplyCandidateCover: () => void; + githubConfidence: DraftIntakeConfidence | null; + githubConsistency: DraftConsistencyReport | null; + githubEvidenceBySection: SectionEvidenceReport | null; + githubEvidence: string[]; + githubRouteCandidates: string[]; + githubRuntimeScreenshots: RuntimeScreenshotPlan[]; + importingRuntimeScreenshots: boolean; + onImportRuntimeScreenshots: () => void; +}; + +export default function AiIntakePanel({ + fieldStyle, + labelStyle, + inputStyle, + githubRepoUrl, + onGitHubRepoUrlChange, + githubFocus, + onGitHubFocusChange, + githubAnalysisMode, + onGitHubAnalysisModeChange, + githubRuntimeBaseUrl, + onGitHubRuntimeBaseUrlChange, + githubScreenshotLimit, + onGitHubScreenshotLimitChange, + generatingGitHubDraft, + onGenerateGitHubDraft, + githubLlmInfo, + hasGithubStarterDraft, + githubStarterVariants, + selectedStarterVariantId, + onSelectStarterVariant, + onApplyStarterDraft, + githubCoverCandidate, + onApplyCandidateCover, + githubConfidence, + githubConsistency, + githubEvidenceBySection, + githubEvidence, + githubRouteCandidates, + githubRuntimeScreenshots, + importingRuntimeScreenshots, + onImportRuntimeScreenshots, +}: AiIntakePanelProps) { + return ( +
+ +
+ onGitHubRepoUrlChange(e.target.value)} + style={{ ...inputStyle, flex: 1, minWidth: 320 }} + placeholder="https://github.com/owner/repo" + /> + + + +
+
+ onGitHubRuntimeBaseUrlChange(e.target.value)} + style={{ ...inputStyle, flex: 1, minWidth: 320 }} + placeholder="Runtime URL for screenshot crawl (optional), e.g. https://my-app.vercel.app" + /> + + onGitHubScreenshotLimitChange( + Math.max(1, Math.min(12, Number.parseInt(e.target.value || "6", 10) || 6)) + ) + } + style={{ ...inputStyle, width: 140, flex: "0 0 140px" }} + placeholder="Shots" + /> +
+

+ Generates a draft from README + issues + merged PRs. LLM mode uses model synthesis; + heuristic mode uses deterministic mapping. Runtime URL optionally enables route and screenshot planning. +

+ {githubLlmInfo?.model ? ( +

+ LLM: {githubLlmInfo.model} + {githubLlmInfo.usage?.totalTokens ? ` • tokens: ${githubLlmInfo.usage.totalTokens}` : ""} +

+ ) : null} + {hasGithubStarterDraft ? ( +
+

+ Starter draft ready. Select title/subtitle variant, then apply to replace current form. +

+
+ {githubStarterVariants.map((variant) => ( + + ))} +
+ +
+ ) : null} + {githubCoverCandidate ? ( +
+

+ Blueprint cover candidate ({githubCoverCandidate.focus}) +

+
+ {githubCoverCandidate.alt} +
+

+ {githubCoverCandidate.title} + {" • "} + {githubCoverCandidate.subtitle} +

+ +
+ ) : null} + {githubConfidence ? ( +
+

+ Confidence:{" "} + + {githubConfidence.overallScore}/100 ({githubConfidence.overallLevel}) + + {" • "} + checklist {githubConfidence.checklistPassed}/{githubConfidence.checklistTotal} + {" • "} + critical {githubConfidence.summary.critical} + {" • "} + warnings {githubConfidence.summary.warning} +

+
+ + Section confidence ({githubConfidence.sections.length}) + +
    + {githubConfidence.sections.map((section) => ( +
  • + {section.section}: + {section.score}/100 ({section.level}) + + {section.notes.length > 0 ? ` — ${section.notes.join(" ")}` : ""} +
  • + ))} +
+
+
+ ) : null} + {githubConsistency ? ( +
+

+ Consistency:{" "} + + {githubConsistency.overall} + + {" • "} + critical {githubConsistency.summary.critical} + {" • "} + warnings {githubConsistency.summary.warning} + {" • "} + checks:{" "} + {githubConsistency.checks.sectionOrder ? "order✓" : "order✕"} /{" "} + {githubConsistency.checks.tone ? "tone✓" : "tone✕"} /{" "} + {githubConsistency.checks.verbosity ? "verbosity✓" : "verbosity✕"} /{" "} + {githubConsistency.checks.evidence ? "evidence✓" : "evidence✕"} +

+ {githubConsistency.findings.length > 0 ? ( +
+ + Top findings ({githubConsistency.findings.length}) + +
    + {githubConsistency.findings.map((finding) => ( +
  • + + {finding.severity.toUpperCase()} + {" "} + [{finding.rule}] {finding.message} +
  • + ))} +
+
+ ) : ( +

No consistency findings.

+ )} +
+ ) : null} + {githubEvidenceBySection ? ( +
+

+ Section evidence coverage:{" "} + + {githubEvidenceBySection.coveredSections}/{githubEvidenceBySection.totalSections} + +

+
+ + Coverage by section ({githubEvidenceBySection.sections.length}) + +
    + {githubEvidenceBySection.sections.map((section) => ( +
  • + {section.section}: + {section.coverage} + + {section.links.length > 0 + ? ` • ${section.links.length} link(s) • ${section.sourceTypes.join(", ")}` + : ""} +
  • + ))} +
+
+ {githubEvidenceBySection.unassignedLinks.length > 0 ? ( +
+ + Unassigned links ({githubEvidenceBySection.unassignedLinks.length}) + +
    + {githubEvidenceBySection.unassignedLinks.slice(0, 8).map((href) => ( +
  • + + {href} + +
  • + ))} +
+
+ ) : null} +
+ ) : null} + {githubEvidence.length > 0 ? ( +
+ + Evidence links ({githubEvidence.length}) + +
    + {githubEvidence.slice(0, 8).map((href) => ( +
  • + + {href} + +
  • + ))} +
+
+ ) : null} + {githubRouteCandidates.length > 0 ? ( +
+ + Route candidates ({githubRouteCandidates.length}) + +
    + {githubRouteCandidates.slice(0, 12).map((route) => ( +
  • + {route} +
  • + ))} +
+
+ ) : null} + {githubRuntimeScreenshots.length > 0 ? ( +
+
+ + Runtime screenshot plan ({githubRuntimeScreenshots.length}) + +
    + {githubRuntimeScreenshots.slice(0, 8).map((shot) => ( +
  • +
    + {shot.route} + {" -> "} + + page + {" "} + /{" "} + + screenshot + +
    +
  • + ))} +
+
+ +
+ ) : null} +
+ ); +} + +function confidenceLevelColor(level: DraftIntakeConfidence["overallLevel"] | "missing"): string { + switch (level) { + case "strong": + return "#16a34a"; + case "medium": + return "#ca8a04"; + case "weak": + return "#dc2626"; + case "missing": + return "#7f1d1d"; + default: + return "var(--color-text-primary)"; + } +} + +function consistencyOverallColor(level: DraftConsistencyReport["overall"]): string { + switch (level) { + case "pass": + return "#16a34a"; + case "warn": + return "#ca8a04"; + case "fail": + return "#dc2626"; + default: + return "var(--color-text-primary)"; + } +} + +function consistencySeverityColor(level: DraftQualityIssue["severity"]): string { + switch (level) { + case "critical": + return "#dc2626"; + case "warning": + return "#ca8a04"; + case "info": + return "#2563eb"; + default: + return "var(--color-text-primary)"; + } +} + +function evidenceCoverageColor(covered: number, total: number): string { + if (total <= 0) { + return "var(--color-text-primary)"; + } + + const ratio = covered / total; + if (ratio >= 0.85) { + return "#16a34a"; + } + if (ratio >= 0.5) { + return "#ca8a04"; + } + return "#dc2626"; +} + +function sectionEvidenceStatusColor(status: "present" | "missing"): string { + return status === "present" ? "#16a34a" : "#dc2626"; +} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index c938870..18a89dc 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -11,6 +11,10 @@ import { import type { StarterVariant } from "@/lib/case-starter"; import type { SectionEvidenceReport } from "@/lib/case-section-evidence"; import type { BlueprintCoverCandidate } from "@/lib/blueprint-cover-candidate"; +import AiIntakePanel, { + type AnalysisMode, + type IntakeFocus, +} from "./components/AiIntakePanel"; interface Fact { label: string; @@ -89,9 +93,6 @@ interface CaseDraftEnvelope { data: CaseStudy; } -type IntakeFocus = "ux-driven" | "behavioral-model" | "agentic-flow"; -type AnalysisMode = "llm" | "heuristic"; - interface GitHubIntakeApiResponse { ok?: boolean; draft?: CaseStudy; @@ -1349,418 +1350,39 @@ export default function AdminPage() {
-
- -
- setGitHubRepoUrl(e.target.value)} - style={{ ...inputStyle, flex: 1, minWidth: 320 }} - placeholder="https://github.com/owner/repo" - /> - - - -
-
- setGitHubRuntimeBaseUrl(e.target.value)} - style={{ ...inputStyle, flex: 1, minWidth: 320 }} - placeholder="Runtime URL for screenshot crawl (optional), e.g. https://my-app.vercel.app" - /> - - setGitHubScreenshotLimit( - Math.max(1, Math.min(12, Number.parseInt(e.target.value || "6", 10) || 6)) - ) - } - style={{ ...inputStyle, width: 140, flex: "0 0 140px" }} - placeholder="Shots" - /> -
-

- Generates a draft from README + issues + merged PRs. LLM mode uses model synthesis; - heuristic mode uses deterministic mapping. Runtime URL optionally enables route and screenshot planning. -

- {githubLlmInfo?.model ? ( -

- LLM: {githubLlmInfo.model} - {githubLlmInfo.usage?.totalTokens - ? ` • tokens: ${githubLlmInfo.usage.totalTokens}` - : ""} -

- ) : null} - {githubStarterDraft ? ( -
-

- Starter draft ready. Select title/subtitle variant, then apply to replace current form. -

-
- {githubStarterVariants.map((variant) => ( - - ))} -
- -
- ) : null} - {githubCoverCandidate ? ( -
-

- Blueprint cover candidate ({githubCoverCandidate.focus}) -

-
- {githubCoverCandidate.alt} -
-

- {githubCoverCandidate.title} - {" • "} - {githubCoverCandidate.subtitle} -

- -
- ) : null} - {githubConfidence ? ( -
-

- Confidence:{" "} - - {githubConfidence.overallScore}/100 ({githubConfidence.overallLevel}) - - {" • "} - checklist {githubConfidence.checklistPassed}/{githubConfidence.checklistTotal} - {" • "} - critical {githubConfidence.summary.critical} - {" • "} - warnings {githubConfidence.summary.warning} -

-
- - Section confidence ({githubConfidence.sections.length}) - -
    - {githubConfidence.sections.map((section) => ( -
  • - {section.section}:{" "} - - {section.score}/100 ({section.level}) - - {section.notes.length > 0 ? ` — ${section.notes.join(" ")}` : ""} -
  • - ))} -
-
-
- ) : null} - {githubConsistency ? ( -
-

- Consistency:{" "} - - {githubConsistency.overall} - - {" • "} - critical {githubConsistency.summary.critical} - {" • "} - warnings {githubConsistency.summary.warning} - {" • "} - checks:{" "} - {githubConsistency.checks.sectionOrder ? "order✓" : "order✕"} /{" "} - {githubConsistency.checks.tone ? "tone✓" : "tone✕"} /{" "} - {githubConsistency.checks.verbosity ? "verbosity✓" : "verbosity✕"} /{" "} - {githubConsistency.checks.evidence ? "evidence✓" : "evidence✕"} -

- {githubConsistency.findings.length > 0 ? ( -
- - Top findings ({githubConsistency.findings.length}) - -
    - {githubConsistency.findings.map((finding) => ( -
  • - - {finding.severity.toUpperCase()} - {" "} - [{finding.rule}] {finding.message} -
  • - ))} -
-
- ) : ( -

No consistency findings.

- )} -
- ) : null} - {githubEvidenceBySection ? ( -
-

- Section evidence coverage:{" "} - - {githubEvidenceBySection.coveredSections}/{githubEvidenceBySection.totalSections} - -

-
- - Coverage by section ({githubEvidenceBySection.sections.length}) - -
    - {githubEvidenceBySection.sections.map((section) => ( -
  • - {section.section}:{" "} - - {section.coverage} - - {section.links.length > 0 - ? ` • ${section.links.length} link(s) • ${section.sourceTypes.join(", ")}` - : ""} -
  • - ))} -
-
- {githubEvidenceBySection.unassignedLinks.length > 0 ? ( -
- - Unassigned links ({githubEvidenceBySection.unassignedLinks.length}) - -
    - {githubEvidenceBySection.unassignedLinks.slice(0, 8).map((href) => ( -
  • - - {href} - -
  • - ))} -
-
- ) : null} -
- ) : null} - {githubEvidence.length > 0 ? ( -
- - Evidence links ({githubEvidence.length}) - -
    - {githubEvidence.slice(0, 8).map((href) => ( -
  • - - {href} - -
  • - ))} -
-
- ) : null} - {githubRouteCandidates.length > 0 ? ( -
- - Route candidates ({githubRouteCandidates.length}) - -
    - {githubRouteCandidates.slice(0, 12).map((route) => ( -
  • - {route} -
  • - ))} -
-
- ) : null} - {githubRuntimeScreenshots.length > 0 ? ( -
-
- - Runtime screenshot plan ({githubRuntimeScreenshots.length}) - -
    - {githubRuntimeScreenshots.slice(0, 8).map((shot) => ( -
  • -
    - {shot.route} - {" -> "} - - page - {" "} - /{" "} - - screenshot - -
    -
  • - ))} -
-
- -
- ) : null} -
+
@@ -2546,66 +2168,6 @@ function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } -function evidenceCoverageColor(covered: number, total: number): string { - if (total <= 0) { - return "var(--color-text-primary)"; - } - - const ratio = covered / total; - if (ratio >= 0.85) { - return "#16a34a"; - } - if (ratio >= 0.5) { - return "#ca8a04"; - } - return "#dc2626"; -} - -function sectionEvidenceStatusColor(status: "present" | "missing"): string { - return status === "present" ? "#16a34a" : "#dc2626"; -} - -function confidenceLevelColor(level: DraftIntakeConfidence["overallLevel"] | "missing"): string { - switch (level) { - case "strong": - return "#16a34a"; - case "medium": - return "#ca8a04"; - case "weak": - return "#dc2626"; - case "missing": - return "#7f1d1d"; - default: - return "var(--color-text-primary)"; - } -} - -function consistencyOverallColor(level: DraftConsistencyReport["overall"]): string { - switch (level) { - case "pass": - return "#16a34a"; - case "warn": - return "#ca8a04"; - case "fail": - return "#dc2626"; - default: - return "var(--color-text-primary)"; - } -} - -function consistencySeverityColor(level: DraftQualityIssue["severity"]): string { - switch (level) { - case "critical": - return "#dc2626"; - case "warning": - return "#ca8a04"; - case "info": - return "#2563eb"; - default: - return "var(--color-text-primary)"; - } -} - function dedupeRuntimeImportedArtifacts( imported: Array<{ route: string; pageUrl: string; src: string; bytes: number; reason?: string }> ): Array<{ route: string; pageUrl: string; src: string; bytes: number; reason?: string }> { From 6f12af247de3334913d274cc105e4b9eff063c12 Mon Sep 17 00:00:00 2001 From: Dmitry Ginzburg <33052194+Ultraivanov@users.noreply.github.com> Date: Sat, 18 Apr 2026 10:58:09 +0300 Subject: [PATCH 18/46] feat(intake): add deterministic narrative rewrite suggestions --- .codex/blocks/R-02.md | 31 ++- src/app/admin/components/AiIntakePanel.tsx | 48 ++++ src/app/admin/page.tsx | 13 ++ src/app/api/intake/github/route.ts | 5 + src/lib/__tests__/case-draft-quality.test.ts | 52 +++++ src/lib/case-draft-quality.ts | 226 +++++++++++++++++++ 6 files changed, 372 insertions(+), 3 deletions(-) diff --git a/.codex/blocks/R-02.md b/.codex/blocks/R-02.md index edbd392..8f54405 100644 --- a/.codex/blocks/R-02.md +++ b/.codex/blocks/R-02.md @@ -30,6 +30,7 @@ Active CMS+AI sprint tasks are executed through approved Change Plans, verified | R-02-T9 | Sticky save bar + unsaved state UX in admin | done | Admin shows sticky save controls and explicit unsaved/synced state relative to repository baseline | | R-02-T10 | Live inline validation and save readiness state | done | Admin shows live validation issues and save is enabled only when form is ready | | R-02-T11 | Modularize AI Intake panel in admin editor | done | AI Intake UI block is extracted into `AiIntakePanel` component with no behavior regression and passing verification checks | +| R-02-T12 | Narrative rewrite suggestions for weak/missing sections | done | Intake returns deterministic rewrite suggestions with confidence and admin shows actionable section-level rewrite guidance | > New tasks are added here as the block progresses via `init-task`. @@ -39,10 +40,10 @@ Active CMS+AI sprint tasks are executed through approved Change Plans, verified | Field | Value | |-----------|-------| -| Task ID | R-02-T11 | -| Title | Modularize AI Intake panel in admin editor | +| Task ID | R-02-T12 | +| Title | Narrative rewrite suggestions for weak/missing sections | | Status | done | -| Done When | AI Intake UI block is extracted into `AiIntakePanel` component with no behavior regression and passing verification checks | +| Done When | Intake returns deterministic rewrite suggestions with confidence and admin shows actionable section-level rewrite guidance | --- @@ -262,6 +263,28 @@ Extract the full AI Intake block into a dedicated component with explicit props **Risks:** Type mismatches between page state and extracted props can break build; mitigated by explicit prop typing and full test/lint/build verification. +### R-02-T12 — Narrative rewrite suggestions for weak/missing sections + +**Files to modify:** +- `.codex/blocks/R-02.md` — task status and session tracking. +- `src/lib/case-draft-quality.ts` — add deterministic rewrite suggestion model and builder. +- `src/lib/__tests__/case-draft-quality.test.ts` — add tests for rewrite suggestion generation. +- `src/app/api/intake/github/route.ts` — include rewrite suggestions in intake response. +- `src/app/admin/page.tsx` — store and pass rewrite suggestions in AI intake state. +- `src/app/admin/components/AiIntakePanel.tsx` — render rewrite suggestions in AI intake panel. + +**Files to create:** +- none. + +**Files NOT touched:** +- extractor command execution path, cover SVG route implementation, and duplicate `* 2.*` artifacts. + +**Approach:** +Generate deterministic section-level rewrite suggestions from existing quality findings (missing required sections, weak constraints/outcome, evidence gaps), attach confidence and concise rewrite text, and surface them in admin as actionable guidance before apply/save. + +**Risks:** +Suggestion noise could reduce trust; mitigated by strict cap on top suggestions, severity-based ordering, and concise rationale. + --- ## Refactor Backlog @@ -299,6 +322,8 @@ Type mismatches between page state and extracted props can break build; mitigate | 2026-04-17 | R-02-T10 | done | Added live validation issue list in sticky save bar and readiness-aware save gating. | | 2026-04-17 | R-02-T11 | in-progress | Started AI Intake modularization by extracting inline admin panel into dedicated component. | | 2026-04-17 | R-02-T11 | done | Extracted AI Intake into `AiIntakePanel`, wired props/handlers, and passed test/lint/build checks. | +| 2026-04-17 | R-02-T12 | in-progress | Started deterministic narrative rewrite suggestions flow for weak/missing sections in intake/admin. | +| 2026-04-17 | R-02-T12 | done | Added deterministic rewrite suggestions in quality lib/API/admin panel with tests and full verification pass. | --- diff --git a/src/app/admin/components/AiIntakePanel.tsx b/src/app/admin/components/AiIntakePanel.tsx index bdb5e4b..9756e06 100644 --- a/src/app/admin/components/AiIntakePanel.tsx +++ b/src/app/admin/components/AiIntakePanel.tsx @@ -5,6 +5,7 @@ import type { DraftConsistencyReport, DraftIntakeConfidence, DraftQualityIssue, + DraftRewriteSuggestion, } from "@/lib/case-draft-quality"; import type { StarterVariant } from "@/lib/case-starter"; import type { SectionEvidenceReport } from "@/lib/case-section-evidence"; @@ -53,6 +54,7 @@ type AiIntakePanelProps = { onApplyCandidateCover: () => void; githubConfidence: DraftIntakeConfidence | null; githubConsistency: DraftConsistencyReport | null; + githubRewriteSuggestions: DraftRewriteSuggestion[]; githubEvidenceBySection: SectionEvidenceReport | null; githubEvidence: string[]; githubRouteCandidates: string[]; @@ -87,6 +89,7 @@ export default function AiIntakePanel({ onApplyCandidateCover, githubConfidence, githubConsistency, + githubRewriteSuggestions, githubEvidenceBySection, githubEvidence, githubRouteCandidates, @@ -370,6 +373,47 @@ export default function AiIntakePanel({ )}
) : null} + {githubRewriteSuggestions.length > 0 ? ( +
+
+ + Rewrite suggestions ({githubRewriteSuggestions.length}) + +
    + {githubRewriteSuggestions.map((suggestion) => ( +
  • +

    + {suggestion.section} + {" • "} + + {suggestion.priority} + + {" • confidence "} + {suggestion.confidence}/100 +

    +

    + {suggestion.rationale} +

    +

    + Current: {suggestion.before} +

    +

    + Suggested rewrite: {suggestion.suggestedRewrite} +

    +
  • + ))} +
+
+
+ ) : null} {githubEvidenceBySection ? (
(null); const [githubConfidence, setGitHubConfidence] = useState(null); const [githubConsistency, setGitHubConsistency] = useState(null); + const [githubRewriteSuggestions, setGitHubRewriteSuggestions] = useState< + DraftRewriteSuggestion[] + >([]); const [githubEvidenceBySection, setGitHubEvidenceBySection] = useState(null); const [githubStarterDraft, setGitHubStarterDraft] = useState(null); @@ -338,6 +343,7 @@ export default function AdminPage() { setGitHubStarterVariants([]); setSelectedStarterVariantId(""); setGitHubExtractorSummary(""); + setGitHubRewriteSuggestions([]); setGitHubEvidenceBySection(null); setGitHubCoverCandidate(null); setLastSyncedSnapshot(null); @@ -874,6 +880,7 @@ export default function AdminPage() { setMessage(""); setGitHubConfidence(null); setGitHubConsistency(null); + setGitHubRewriteSuggestions([]); setGitHubEvidenceBySection(null); setGitHubStarterDraft(null); setGitHubStarterVariants([]); @@ -898,6 +905,7 @@ export default function AdminPage() { if (!response.ok || !payload.draft) { setGitHubConfidence(null); setGitHubConsistency(null); + setGitHubRewriteSuggestions([]); setMessage(`❌ Draft generation failed: ${getApiErrorMessage(payload)}`); return; } @@ -912,6 +920,9 @@ export default function AdminPage() { setGitHubLlmInfo(payload.llm ?? null); setGitHubConfidence(payload.confidence ?? null); setGitHubConsistency(payload.consistency ?? null); + setGitHubRewriteSuggestions( + Array.isArray(payload.rewriteSuggestions) ? payload.rewriteSuggestions : [] + ); setGitHubEvidenceBySection(payload.evidenceBySection ?? null); setGitHubCoverCandidate(payload.coverCandidate ?? null); setGitHubStarterDraft(payload.draft); @@ -941,6 +952,7 @@ export default function AdminPage() { } catch (error) { setGitHubConfidence(null); setGitHubConsistency(null); + setGitHubRewriteSuggestions([]); setGitHubEvidenceBySection(null); setGitHubStarterDraft(null); setGitHubStarterVariants([]); @@ -1376,6 +1388,7 @@ export default function AdminPage() { onApplyCandidateCover={handleApplyCandidateCover} githubConfidence={githubConfidence} githubConsistency={githubConsistency} + githubRewriteSuggestions={githubRewriteSuggestions} githubEvidenceBySection={githubEvidenceBySection} githubEvidence={githubEvidence} githubRouteCandidates={githubRouteCandidates} diff --git a/src/app/api/intake/github/route.ts b/src/app/api/intake/github/route.ts index 4fb5401..5adf93a 100644 --- a/src/app/api/intake/github/route.ts +++ b/src/app/api/intake/github/route.ts @@ -11,6 +11,7 @@ import { } from "@/lib/github-case-extractor"; import { synthesizeCaseDraftWithLlm } from "@/lib/github-case-intake-llm"; import { + buildDraftRewriteSuggestions, buildDraftIntakeConfidence, buildDraftConsistencyReport, } from "@/lib/case-draft-quality"; @@ -135,6 +136,9 @@ export async function POST(request: Request) { const confidence = buildDraftIntakeConfidence(draft, { evidenceLinks: evidence }); const consistency = buildDraftConsistencyReport(draft, { evidenceLinks: evidence }); + const rewriteSuggestions = buildDraftRewriteSuggestions(draft, { + evidenceLinks: evidence, + }); const evidenceBySection = buildEvidenceBySection(draft, { evidenceLinks: evidence, }); @@ -155,6 +159,7 @@ export async function POST(request: Request) { evidenceBySection, confidence, consistency, + rewriteSuggestions, starterVariants, coverCandidate, source: { diff --git a/src/lib/__tests__/case-draft-quality.test.ts b/src/lib/__tests__/case-draft-quality.test.ts index 28ba685..2917aca 100644 --- a/src/lib/__tests__/case-draft-quality.test.ts +++ b/src/lib/__tests__/case-draft-quality.test.ts @@ -2,6 +2,7 @@ import { analyzeCaseDraftQuality, buildDraftConsistencyReport, buildDraftIntakeConfidence, + buildDraftRewriteSuggestions, REQUIRED_CASE_SECTIONS, type CaseDraftLike, } from "@/lib/case-draft-quality"; @@ -202,3 +203,54 @@ describe("buildDraftConsistencyReport", () => { expect(report.findings.some((finding) => finding.rule === "evidence")).toBe(true); }); }); + +describe("buildDraftRewriteSuggestions", () => { + it("returns critical rewrite suggestion for missing required section", () => { + const draft = createBaseDraft(); + draft.sections = draft.sections.filter((section) => section.title !== "Outcome"); + + const suggestions = buildDraftRewriteSuggestions(draft, { + evidenceLinks: ["https://github.com/example/repo/pull/15"], + }); + + const outcome = suggestions.find((item) => item.section === "Outcome"); + expect(outcome?.priority).toBe("critical"); + expect(outcome?.confidence).toBeGreaterThanOrEqual(90); + expect(outcome?.suggestedRewrite).toContain("Outcome:"); + }); + + it("returns targeted suggestions for weak constraints/outcome and missing evidence", () => { + const draft = createBaseDraft(); + const constraints = draft.sections.find((section) => section.title === "Constraints"); + if (!constraints) { + throw new Error("Expected constraints section in test setup."); + } + constraints.blocks = [ + { + discriminant: "paragraph", + value: { text: "There were constraints." }, + }, + ]; + const outcome = draft.sections.find((section) => section.title === "Outcome"); + if (!outcome) { + throw new Error("Expected outcome section in test setup."); + } + outcome.blocks = [ + { + discriminant: "paragraph", + value: { text: "The launch went well and users were happier." }, + }, + ]; + + const suggestions = buildDraftRewriteSuggestions(draft, { + evidenceLinks: [], + }); + + expect(suggestions.some((item) => item.section === "Constraints")).toBe(true); + expect(suggestions.some((item) => item.section === "Outcome")).toBe(true); + expect(suggestions.some((item) => item.section === "Evidence")).toBe(true); + expect(suggestions.every((item) => item.confidence >= 0 && item.confidence <= 100)).toBe( + true + ); + }); +}); diff --git a/src/lib/case-draft-quality.ts b/src/lib/case-draft-quality.ts index 284d83f..498f085 100644 --- a/src/lib/case-draft-quality.ts +++ b/src/lib/case-draft-quality.ts @@ -83,6 +83,19 @@ export type DraftIntakeConfidence = { topIssues: DraftQualityIssue[]; }; +export type DraftRewritePriority = "critical" | "warning"; + +export type DraftRewriteSuggestion = { + id: string; + issueId: string; + section: string; + priority: DraftRewritePriority; + confidence: number; + rationale: string; + before: string; + suggestedRewrite: string; +}; + export type DraftConsistencyRule = | "section-order" | "tone" @@ -421,10 +434,219 @@ export function buildDraftConsistencyReport( }; } +export function buildDraftRewriteSuggestions( + draft: CaseDraftLike, + options?: { evidenceLinks?: string[] } +): DraftRewriteSuggestion[] { + const quality = analyzeCaseDraftQuality(draft, options); + const sectionsByTitle = new Map( + draft.sections.map((section) => [normalizeTitle(section.title), section]) + ); + const bySection = new Map(); + const sortedIssues = quality.issues + .slice() + .sort( + (a, b) => + severityRank(a.severity) - severityRank(b.severity) || + a.id.localeCompare(b.id) + ); + + for (const issue of sortedIssues) { + const section = sectionsByTitle.get(normalizeTitle(issue.section || "")); + const suggestion = mapIssueToRewriteSuggestion(issue, section); + if (!suggestion) { + continue; + } + const key = normalizeTitle(suggestion.section); + const existing = bySection.get(key); + if (!existing) { + bySection.set(key, suggestion); + continue; + } + if (rewritePriorityRank(suggestion.priority) < rewritePriorityRank(existing.priority)) { + bySection.set(key, suggestion); + } + } + + return [...bySection.values()] + .sort( + (a, b) => + rewritePriorityRank(a.priority) - rewritePriorityRank(b.priority) || + b.confidence - a.confidence || + a.section.localeCompare(b.section) + ) + .slice(0, 6); +} + function normalizeTitle(value: string): string { return value.trim().toLowerCase(); } +function mapIssueToRewriteSuggestion( + issue: DraftQualityIssue, + section: CaseSection | undefined +): DraftRewriteSuggestion | null { + if (issue.id === "metric-without-evidence") { + return { + id: `rewrite-${issue.id}`, + issueId: issue.id, + section: "Evidence", + priority: "warning", + confidence: 86, + rationale: issue.message, + before: "Quantitative claims are present, but proof links are missing.", + suggestedRewrite: + "Add 2-3 links that prove each metric claim (PR/issue, dashboard snapshot, release note), and reference each link directly in Outcome or Solution blocks.", + }; + } + + if (issue.id === "missing-evidence-links") { + return { + id: `rewrite-${issue.id}`, + issueId: issue.id, + section: "Evidence", + priority: "warning", + confidence: 72, + rationale: issue.message, + before: "No evidence links are attached to this draft.", + suggestedRewrite: + "Attach supporting links (repo, merged PRs, issues, docs) and anchor them to claims in Context, Solution, and Outcome sections.", + }; + } + + if (issue.id.startsWith("missing-")) { + const sectionName = issue.section || fallbackSectionFromIssueId(issue.id); + return { + id: `rewrite-${issue.id}`, + issueId: issue.id, + section: sectionName || "Section", + priority: "critical", + confidence: 94, + rationale: issue.message, + before: "Section is missing.", + suggestedRewrite: buildMissingSectionRewrite(sectionName || "Section"), + }; + } + + if (issue.id.startsWith("empty-")) { + const sectionName = issue.section || "Section"; + const before = extractSectionSignal(section) || "Content is too short or generic."; + return { + id: `rewrite-${issue.id}`, + issueId: issue.id, + section: sectionName, + priority: "warning", + confidence: 76, + rationale: issue.message, + before, + suggestedRewrite: buildWeakSectionRewrite(sectionName), + }; + } + + if (issue.id === "weak-constraints") { + return { + id: `rewrite-${issue.id}`, + issueId: issue.id, + section: "Constraints", + priority: "warning", + confidence: 88, + rationale: issue.message, + before: + extractSectionSignal(section) || "Constraints are generic and not decision-driving.", + suggestedRewrite: + "Constraints: (1) Legacy API contract prevents [change], (2) Delivery deadline limits scope to [subset], (3) Team capacity allows [N] implementation slices this sprint.", + }; + } + + if (issue.id === "weak-outcome-metric") { + return { + id: `rewrite-${issue.id}`, + issueId: issue.id, + section: "Outcome", + priority: "warning", + confidence: 90, + rationale: issue.message, + before: + extractSectionSignal(section) || "Outcome has no measurable impact signal.", + suggestedRewrite: + "Outcome: After release, [primary metric] changed from [baseline] to [result] in [timeframe], and [secondary metric] moved by [delta]. Evidence: [link or source].", + }; + } + + return null; +} + +function fallbackSectionFromIssueId(issueId: string): string { + return issueId + .replace(/^missing-/, "") + .split("-") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +function buildMissingSectionRewrite(sectionName: string): string { + switch (normalizeTitle(sectionName)) { + case "context": + return "Context: [target users] use [product/surface] for [goal]. Current baseline shows [pain signal], observed in [where/when]."; + case "problem": + return "Problem: Users fail at [step], causing [business/user impact]. Root cause: [specific friction or ambiguity]."; + case "constraints": + return "Constraints: [technical constraint], [time/resource constraint], [organizational dependency]. Each constraint changed decisions in scope or UX."; + case "role": + return "Role: I owned [discovery/design/validation], partnered with [functions], and made decisions on [scope/system/quality bar]."; + case "approach": + return "Approach: We ran [research or analysis], formed [key hypotheses], prioritized [experiments], and iterated based on [evidence loop]."; + case "solution": + return "Solution: Introduced [key flow/system changes], clarified [states/interactions], and aligned implementation through [handoff/spec process]."; + case "outcome": + return "Outcome: [primary metric] changed by [delta] over [timeframe]. Secondary effects: [quality/support/conversion signal], validated by [evidence link]."; + default: + return `Rewrite ${sectionName}: state the specific problem, decision logic, and measurable result in 2-3 concise sentences.`; + } +} + +function buildWeakSectionRewrite(sectionName: string): string { + switch (normalizeTitle(sectionName)) { + case "constraints": + return "Rewrite Constraints with explicit limits: what could not be changed, why, and how each limit shaped product or technical choices."; + case "outcome": + return "Rewrite Outcome with measurable deltas: baseline -> result, timeframe, and at least one evidence link for each key claim."; + default: + return `Rewrite ${sectionName}: replace generic statements with concrete context, decisions, and observable impact.`; + } +} + +function extractSectionSignal(section: CaseSection | undefined): string { + if (!section) { + return ""; + } + const chunks: string[] = []; + for (const block of section.blocks) { + if (block.discriminant === "paragraph" && typeof block.value.text === "string") { + const text = block.value.text.trim(); + if (text) { + chunks.push(text); + } + } + if (block.discriminant === "list" && Array.isArray(block.value.items)) { + for (const item of block.value.items) { + const text = item.trim(); + if (text) { + chunks.push(text); + } + } + } + if (chunks.length >= 2) { + break; + } + } + const combined = chunks.join(" ").replace(/\s+/g, " ").trim(); + if (!combined) { + return ""; + } + return combined.length > 180 ? `${combined.slice(0, 177)}...` : combined; +} + function hasMeaningfulSectionContent(section: CaseSection): boolean { return section.blocks.some((block) => { if (block.discriminant === "paragraph") { @@ -622,6 +844,10 @@ function severityRank(severity: QualitySeverity): number { } } +function rewritePriorityRank(priority: DraftRewritePriority): number { + return priority === "critical" ? 0 : 1; +} + function clampScore(value: number): number { return Math.max(0, Math.min(100, value)); } From 37b3229a2da3901227c7182e7e3dc8f809f19d61 Mon Sep 17 00:00:00 2001 From: Dmitry Ginzburg <33052194+Ultraivanov@users.noreply.github.com> Date: Sat, 18 Apr 2026 11:14:38 +0300 Subject: [PATCH 19/46] feat(admin): apply rewrite suggestions in one click --- .codex/blocks/R-02.md | 28 +++++++++- src/app/admin/components/AiIntakePanel.tsx | 17 ++++++ src/app/admin/page.tsx | 64 ++++++++++++++++++++++ 3 files changed, 106 insertions(+), 3 deletions(-) diff --git a/.codex/blocks/R-02.md b/.codex/blocks/R-02.md index 8f54405..cc331f1 100644 --- a/.codex/blocks/R-02.md +++ b/.codex/blocks/R-02.md @@ -31,6 +31,7 @@ Active CMS+AI sprint tasks are executed through approved Change Plans, verified | R-02-T10 | Live inline validation and save readiness state | done | Admin shows live validation issues and save is enabled only when form is ready | | R-02-T11 | Modularize AI Intake panel in admin editor | done | AI Intake UI block is extracted into `AiIntakePanel` component with no behavior regression and passing verification checks | | R-02-T12 | Narrative rewrite suggestions for weak/missing sections | done | Intake returns deterministic rewrite suggestions with confidence and admin shows actionable section-level rewrite guidance | +| R-02-T13 | One-click apply for rewrite suggestions | done | Admin can apply section rewrite suggestions in one action, updating existing section paragraph or creating missing section deterministically | > New tasks are added here as the block progresses via `init-task`. @@ -40,10 +41,10 @@ Active CMS+AI sprint tasks are executed through approved Change Plans, verified | Field | Value | |-----------|-------| -| Task ID | R-02-T12 | -| Title | Narrative rewrite suggestions for weak/missing sections | +| Task ID | R-02-T13 | +| Title | One-click apply for rewrite suggestions | | Status | done | -| Done When | Intake returns deterministic rewrite suggestions with confidence and admin shows actionable section-level rewrite guidance | +| Done When | Admin can apply section rewrite suggestions in one action, updating existing section paragraph or creating missing section deterministically | --- @@ -285,6 +286,25 @@ Generate deterministic section-level rewrite suggestions from existing quality f **Risks:** Suggestion noise could reduce trust; mitigated by strict cap on top suggestions, severity-based ordering, and concise rationale. +### R-02-T13 — One-click apply for rewrite suggestions + +**Files to modify:** +- `.codex/blocks/R-02.md` — task status and session tracking. +- `src/app/admin/components/AiIntakePanel.tsx` — add apply action per rewrite suggestion. +- `src/app/admin/page.tsx` — add deterministic handler that applies suggestion to current draft sections. + +**Files to create:** +- none. + +**Files NOT touched:** +- intake extractor flow, blueprint cover route, and duplicate `* 2.*` artifacts. + +**Approach:** +Wire an explicit per-suggestion apply button in AI Intake panel. On apply, update the first paragraph block in the matched section; if section does not exist, append a new section with a paragraph block from suggested rewrite, then persist to local draft state. + +**Risks:** +Applying suggestion to wrong section due to title mismatch; mitigated by normalized title matching and deterministic fallback section creation. + --- ## Refactor Backlog @@ -324,6 +344,8 @@ Suggestion noise could reduce trust; mitigated by strict cap on top suggestions, | 2026-04-17 | R-02-T11 | done | Extracted AI Intake into `AiIntakePanel`, wired props/handlers, and passed test/lint/build checks. | | 2026-04-17 | R-02-T12 | in-progress | Started deterministic narrative rewrite suggestions flow for weak/missing sections in intake/admin. | | 2026-04-17 | R-02-T12 | done | Added deterministic rewrite suggestions in quality lib/API/admin panel with tests and full verification pass. | +| 2026-04-18 | R-02-T13 | in-progress | Started one-click apply flow for rewrite suggestions in admin AI Intake panel. | +| 2026-04-18 | R-02-T13 | done | Added one-click apply action for rewrite suggestions with deterministic section update/create and full verification pass. | --- diff --git a/src/app/admin/components/AiIntakePanel.tsx b/src/app/admin/components/AiIntakePanel.tsx index 9756e06..9ba4ff6 100644 --- a/src/app/admin/components/AiIntakePanel.tsx +++ b/src/app/admin/components/AiIntakePanel.tsx @@ -55,6 +55,7 @@ type AiIntakePanelProps = { githubConfidence: DraftIntakeConfidence | null; githubConsistency: DraftConsistencyReport | null; githubRewriteSuggestions: DraftRewriteSuggestion[]; + onApplyRewriteSuggestion: (suggestion: DraftRewriteSuggestion) => void; githubEvidenceBySection: SectionEvidenceReport | null; githubEvidence: string[]; githubRouteCandidates: string[]; @@ -90,6 +91,7 @@ export default function AiIntakePanel({ githubConfidence, githubConsistency, githubRewriteSuggestions, + onApplyRewriteSuggestion, githubEvidenceBySection, githubEvidence, githubRouteCandidates, @@ -408,6 +410,21 @@ export default function AiIntakePanel({

Suggested rewrite: {suggestion.suggestedRewrite}

+ ))} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 29bf58d..2d8d649 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -1169,6 +1169,69 @@ export default function AdminPage() { } }; + const normalizeSectionTitle = (value: string): string => value.trim().toLowerCase(); + + const handleApplyRewriteSuggestion = (suggestion: DraftRewriteSuggestion) => { + if (!caseData) { + setMessage("❌ Load a case before applying rewrite suggestions."); + return; + } + + const targetSectionTitle = suggestion.section.trim() || "Additional Notes"; + const targetSectionKey = normalizeSectionTitle(targetSectionTitle); + const nextSections = caseData.sections.map((section) => ({ + ...section, + blocks: [...section.blocks], + })); + const sectionIndex = nextSections.findIndex( + (section) => normalizeSectionTitle(section.title) === targetSectionKey + ); + let appliedSectionTitle = targetSectionTitle; + + if (sectionIndex >= 0) { + appliedSectionTitle = nextSections[sectionIndex].title || targetSectionTitle; + const blocks = [...nextSections[sectionIndex].blocks]; + const paragraphIndex = blocks.findIndex((block) => block.discriminant === "paragraph"); + + if (paragraphIndex >= 0) { + const paragraphBlock = blocks[paragraphIndex]; + blocks[paragraphIndex] = { + ...paragraphBlock, + value: { + ...paragraphBlock.value, + text: suggestion.suggestedRewrite, + }, + }; + } else { + blocks.unshift({ + discriminant: "paragraph", + value: { text: suggestion.suggestedRewrite }, + }); + } + + nextSections[sectionIndex] = { + ...nextSections[sectionIndex], + blocks, + }; + } else { + nextSections.push({ + title: targetSectionTitle, + blocks: [ + { + discriminant: "paragraph", + value: { text: suggestion.suggestedRewrite }, + }, + ], + }); + } + + updateField("sections", nextSections); + setGitHubRewriteSuggestions((current) => + current.filter((item) => item.id !== suggestion.id) + ); + setMessage(`✅ Applied rewrite suggestion to "${appliedSectionTitle}". Review and save.`); + }; + // Section management const updateSection = (sectionIndex: number, field: keyof Section, value: string) => { if (!caseData) return; @@ -1389,6 +1452,7 @@ export default function AdminPage() { githubConfidence={githubConfidence} githubConsistency={githubConsistency} githubRewriteSuggestions={githubRewriteSuggestions} + onApplyRewriteSuggestion={handleApplyRewriteSuggestion} githubEvidenceBySection={githubEvidenceBySection} githubEvidence={githubEvidence} githubRouteCandidates={githubRouteCandidates} From 380e5f34dcd5416a30d65f3261b683efb7dc4348 Mon Sep 17 00:00:00 2001 From: Dmitry Ginzburg <33052194+Ultraivanov@users.noreply.github.com> Date: Sat, 18 Apr 2026 11:22:35 +0300 Subject: [PATCH 20/46] refactor(admin): extract sections editor component --- .codex/blocks/R-02.md | 27 +- src/app/admin/components/SectionsEditor.tsx | 373 ++++++++++++++++++++ src/app/admin/page.tsx | 305 +--------------- 3 files changed, 412 insertions(+), 293 deletions(-) create mode 100644 src/app/admin/components/SectionsEditor.tsx diff --git a/.codex/blocks/R-02.md b/.codex/blocks/R-02.md index cc331f1..76e2466 100644 --- a/.codex/blocks/R-02.md +++ b/.codex/blocks/R-02.md @@ -32,6 +32,7 @@ Active CMS+AI sprint tasks are executed through approved Change Plans, verified | R-02-T11 | Modularize AI Intake panel in admin editor | done | AI Intake UI block is extracted into `AiIntakePanel` component with no behavior regression and passing verification checks | | R-02-T12 | Narrative rewrite suggestions for weak/missing sections | done | Intake returns deterministic rewrite suggestions with confidence and admin shows actionable section-level rewrite guidance | | R-02-T13 | One-click apply for rewrite suggestions | done | Admin can apply section rewrite suggestions in one action, updating existing section paragraph or creating missing section deterministically | +| R-02-T14 | Modularize sections editor into standalone component | done | Sections editing UI (blocks/media/drag controls) is moved from `admin/page.tsx` to `SectionsEditor` component without behavior regressions | > New tasks are added here as the block progresses via `init-task`. @@ -41,10 +42,10 @@ Active CMS+AI sprint tasks are executed through approved Change Plans, verified | Field | Value | |-----------|-------| -| Task ID | R-02-T13 | -| Title | One-click apply for rewrite suggestions | +| Task ID | R-02-T14 | +| Title | Modularize sections editor into standalone component | | Status | done | -| Done When | Admin can apply section rewrite suggestions in one action, updating existing section paragraph or creating missing section deterministically | +| Done When | Sections editing UI (blocks/media/drag controls) is moved from `admin/page.tsx` to `SectionsEditor` component without behavior regressions | --- @@ -305,6 +306,24 @@ Wire an explicit per-suggestion apply button in AI Intake panel. On apply, updat **Risks:** Applying suggestion to wrong section due to title mismatch; mitigated by normalized title matching and deterministic fallback section creation. +### R-02-T14 — Modularize sections editor into standalone component + +**Files to modify:** +- `.codex/blocks/R-02.md` — task status and session tracking. +- `src/app/admin/page.tsx` — replace inline sections editor block with component usage. + +**Files to create:** +- `src/app/admin/components/SectionsEditor.tsx` — standalone sections editor UI with block/media controls. + +**Files NOT touched:** +- intake API routes, quality analyzers, and duplicate `* 2.*` artifacts. + +**Approach:** +Extract the full sections editor JSX (section title editing, block editing, media upload controls, drag-to-reorder UX) into a dedicated component and keep mutation handlers/source state orchestration in `page.tsx` via explicit callbacks. + +**Risks:** +Prop interface mismatch may break block mutation flows; mitigated by keeping callback signatures aligned with existing handlers and full test/lint/build verification. + --- ## Refactor Backlog @@ -346,6 +365,8 @@ Applying suggestion to wrong section due to title mismatch; mitigated by normali | 2026-04-17 | R-02-T12 | done | Added deterministic rewrite suggestions in quality lib/API/admin panel with tests and full verification pass. | | 2026-04-18 | R-02-T13 | in-progress | Started one-click apply flow for rewrite suggestions in admin AI Intake panel. | | 2026-04-18 | R-02-T13 | done | Added one-click apply action for rewrite suggestions with deterministic section update/create and full verification pass. | +| 2026-04-18 | R-02-T14 | in-progress | Started sections editor modularization into standalone admin component. | +| 2026-04-18 | R-02-T14 | done | Extracted sections editor into `SectionsEditor` component and passed tests/lint/build verification. | --- diff --git a/src/app/admin/components/SectionsEditor.tsx b/src/app/admin/components/SectionsEditor.tsx new file mode 100644 index 0000000..330ada4 --- /dev/null +++ b/src/app/admin/components/SectionsEditor.tsx @@ -0,0 +1,373 @@ +"use client"; + +import { useState, type CSSProperties } from "react"; + +type BlockValue = { + text?: string; + items?: string[]; + label?: string; + href?: string; + src?: string; + alt?: string; + caption?: string; +}; + +type Block = { + discriminant: "paragraph" | "list" | "link" | "media"; + value: BlockValue; +}; + +type Section = { + title: string; + blocks: Block[]; +}; + +type MediaUploadFeedback = { + fileName?: string; + uploading?: boolean; + uploaded?: boolean; + sizeText?: string; + processedText?: string; + errorText?: string; +}; + +type SectionsEditorProps = { + sections: Section[]; + inputStyle: CSSProperties; + labelStyle: CSSProperties; + mediaUploadFeedbackByBlock: Record; + onUpdateSection: (sectionIndex: number, title: string) => void; + onRemoveSection: (sectionIndex: number) => void; + onUpdateBlock: ( + sectionIndex: number, + blockIndex: number, + value: Partial + ) => void; + onAddBlock: (sectionIndex: number, type: Block["discriminant"]) => void; + onRemoveBlock: (sectionIndex: number, blockIndex: number) => void; + onMoveBlock: (sectionIndex: number, fromIndex: number, toIndex: number) => void; + onAddSection: () => void; + onUploadMediaImage: (sectionIndex: number, blockIndex: number, file: File) => void; +}; + +export default function SectionsEditor({ + sections, + inputStyle, + labelStyle, + mediaUploadFeedbackByBlock, + onUpdateSection, + onRemoveSection, + onUpdateBlock, + onAddBlock, + onRemoveBlock, + onMoveBlock, + onAddSection, + onUploadMediaImage, +}: SectionsEditorProps) { + const [draggedBlock, setDraggedBlock] = useState<{ + sectionIndex: number; + blockIndex: number; + } | null>(null); + const getBlockKey = (sectionIndex: number, blockIndex: number): string => + `${sectionIndex}:${blockIndex}`; + + return ( +
+

Sections

+ + {sections.map((section, sectionIndex) => ( +
+
+ onUpdateSection(sectionIndex, e.target.value)} + style={{ ...inputStyle, flex: 1, fontWeight: 600 }} + placeholder="Section title" + /> + +
+ + {section.blocks.map((block, blockIndex) => { + const feedback = mediaUploadFeedbackByBlock[getBlockKey(sectionIndex, blockIndex)]; + + return ( +
setDraggedBlock({ sectionIndex, blockIndex })} + onDragOver={(e) => { + e.preventDefault(); + if (!draggedBlock || draggedBlock.sectionIndex !== sectionIndex) return; + }} + onDrop={(e) => { + e.preventDefault(); + if (!draggedBlock || draggedBlock.sectionIndex !== sectionIndex) return; + onMoveBlock(sectionIndex, draggedBlock.blockIndex, blockIndex); + setDraggedBlock(null); + }} + style={{ + marginBottom: 12, + padding: 12, + border: "1px dashed var(--color-border-subtle)", + borderRadius: "var(--radius-1)", + background: "var(--color-bg-secondary)", + cursor: "move", + overflow: "visible", + opacity: + draggedBlock?.sectionIndex === sectionIndex && + draggedBlock?.blockIndex === blockIndex + ? 0.5 + : 1, + }} + > +
+ + {block.discriminant} + + + ↕ Drag to reorder + +
+ + {block.discriminant === "paragraph" && ( +