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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
CAPCHECK_ANALYSIS_MODE=fixture
CAPCHECK_FEED_MODE=fixture
# Live mode is selected when CAPCHECK_ANALYSIS_MODE is not "fixture".
# GEMINI_API_KEY=
# FINNHUB_KEY=
# Server-only key for Verified Feed refresh (YouTube Data API v3 discovery).
# YOUTUBE_API_KEY=
# Supabase-backed feed configuration. The anon key is public and remains
# protected by Row Level Security; never expose the server-only secret key.
# NEXT_PUBLIC_SUPABASE_URL=
# NEXT_PUBLIC_SUPABASE_ANON_KEY=
# SUPABASE_SERVICE_ROLE_KEY=
36 changes: 28 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,25 @@ scorecard. A user can paste a video URL or upload a video, follow the analysis
stages, and inspect the evidence behind each supported, contradicted, or
unverifiable claim.

This repository contains the Bloomberg Hackathon 2026 application. Local and
browser tests use deterministic fixtures, while live mode streams the real
analysis pipelines through the same runtime-validated contracts.
Built for BloomKnights 2026, CapCheck combines Gemini video understanding,
Google Search grounding, Finnhub market data, and a Supabase-backed Verified
Feed.

> **Project status:** Complete and preserved as a read-only portfolio showcase.
> Browse the deployed app at [capcheck-sigma.vercel.app](https://capcheck-sigma.vercel.app/).
> The persisted feed and evidence pages remain live; paid/private analysis and
> refresh integrations were retired after the hackathon.

## What shipped

- URL and upload intake with streamed analysis progress.
- Gemini claim extraction, grounded verification, and deterministic Cap Scores.
- Finnhub function calling for quantitative market claims.
- A searchable, category-filtered catalog of CapCheck-vetted YouTube videos.
- Supabase persistence, idempotent refresh runs, reliability gating, and safe
failure behavior.
- Responsive, accessible feed, detail, and scorecard experiences backed by
deterministic unit and Playwright coverage.

## Local setup

Expand Down Expand Up @@ -71,11 +87,10 @@ The shared seam is the Zod-validated `AnalysisEvent` contract in
completion, and error events that a live adapter must produce. The UI consumes
only the SSE response from `/api/analyze`; it does not import either adapter.

Lane A can introduce the live yt-dlp, Gemini, and evidence-grounding adapter by
selecting it on the server while preserving the route and `AnalysisEvent`
stream. Lane B can continue working against fixtures. Contract changes require
a coordinated update to schemas, fixtures, adapter tests, parser tests, and UI
tests before either lane depends on them.
The production implementation selects the live yt-dlp, Gemini, Search
grounding, and Finnhub adapters while preserving the same `AnalysisEvent`
stream used by deterministic fixtures. Contract changes require coordinated
updates to schemas, fixtures, adapter tests, parser tests, and UI tests.

The live claim-extraction entry point is
`createNodeClaimExtractionPipeline` in
Expand Down Expand Up @@ -163,6 +178,11 @@ duplicate) with no credentials. Live mode additionally requires a server-only
`YOUTUBE_API_KEY` (plus `GEMINI_API_KEY`, `FINNHUB_KEY`, and
`SUPABASE_SERVICE_ROLE_KEY`).

The hosted portfolio deployment intentionally omits those private credentials.
It reads the persisted catalog through the public Supabase anon key under RLS,
while live refresh and analysis render an archived-demo state instead of
calling external providers.

Opt-in live smoke test (never part of CI):

```bash
Expand Down
10 changes: 10 additions & 0 deletions src/app/analyze/page.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,22 @@
import type { Metadata } from "next";

import { CapCheckApp } from "@/components/capcheck-app";
import { PortfolioDemoNotice } from "@/components/portfolio-demo-notice";
import { isPortfolioDemoMode } from "@/lib/portfolio-mode";

export const metadata: Metadata = {
title: "Analyze — CapCheck",
description: "Check a short-form finance video against credible evidence.",
};

export default function AnalyzePage() {
if (isPortfolioDemoMode()) {
return (
<main className="feed-page portfolio-demo-page">
<PortfolioDemoNotice feature="analyzer" />
</main>
);
}

return <CapCheckApp />;
}
22 changes: 22 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -1453,6 +1453,28 @@ input:disabled {
font-size: 0.92rem;
}

.portfolio-demo-notice {
margin-top: 24px;
}

.portfolio-demo-notice > svg {
color: var(--ink);
}

.portfolio-demo-notice .kicker {
margin-bottom: 6px;
}

.portfolio-demo-notice h2 {
margin: 0 0 6px;
color: var(--ink);
font: 600 clamp(1.2rem, 2.5vw, 1.6rem)/1.2 var(--display);
}

.portfolio-demo-page {
max-width: 760px;
}

.feed-state-action {
display: inline-flex;
align-items: center;
Expand Down
9 changes: 8 additions & 1 deletion src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import { TriangleAlert } from "lucide-react";
import Link from "next/link";

import { FeedExplorer } from "@/components/feed/feed-explorer";
import { PortfolioDemoNotice } from "@/components/portfolio-demo-notice";
import { RefreshFeedButton } from "@/components/refresh-feed-button";
import type { CatalogItem } from "@/domain/feed";
import { isPortfolioDemoMode } from "@/lib/portfolio-mode";
import { getCatalogRepository } from "@/server/feed/catalog-repository";

export const dynamic = "force-dynamic";
Expand All @@ -15,6 +17,7 @@ export default async function FeedHome({
}) {
let items: CatalogItem[] = [];
let failed = false;
const portfolioDemo = isPortfolioDemoMode();
const { feedState } = await searchParams;
const fixtureState =
process.env.NODE_ENV !== "production" &&
Expand Down Expand Up @@ -55,7 +58,11 @@ export default async function FeedHome({
only if its claims held up. Open one to see the Cap Score, the
evidence, and the citations behind it.
</p>
<RefreshFeedButton />
{portfolioDemo ? (
<PortfolioDemoNotice feature="refresh" />
) : (
<RefreshFeedButton />
)}
</section>

{failed ? (
Expand Down
19 changes: 19 additions & 0 deletions src/components/portfolio-demo-notice.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";

import { PortfolioDemoNotice } from "./portfolio-demo-notice";

describe("PortfolioDemoNotice", () => {
it("keeps the archived project useful without offering retired live actions", () => {
render(<PortfolioDemoNotice feature="analyzer" />);

expect(
screen.getByRole("heading", { name: "Live analysis is retired" }),
).toBeVisible();
expect(screen.getByRole("link", { name: "Browse the verified feed" })).toHaveAttribute(
"href",
"/",
);
expect(screen.queryByRole("button")).not.toBeInTheDocument();
});
});
32 changes: 32 additions & 0 deletions src/components/portfolio-demo-notice.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Archive } from "lucide-react";
import Link from "next/link";

export function PortfolioDemoNotice({
feature,
}: {
feature: "analyzer" | "refresh";
}) {
const analyzer = feature === "analyzer";

return (
<section className="feed-state portfolio-demo-notice" aria-labelledby="demo-status">
<Archive aria-hidden="true" />
<div>
<p className="kicker">Archived portfolio demo</p>
<h2 id="demo-status">
{analyzer ? "Live analysis is retired" : "The catalog is now read-only"}
</h2>
<p>
{analyzer
? "The hackathon API integrations have been safely deactivated. The verified catalog, scorecards, citations, and video details remain available to explore."
: "Live refresh has been safely deactivated after the hackathon. Browse the persisted CapCheck-vetted videos and their evidence-backed scorecards below."}
</p>
{analyzer && (
<Link className="feed-state-action" href="/">
Browse the verified feed
</Link>
)}
</div>
</section>
);
}
20 changes: 20 additions & 0 deletions src/lib/portfolio-mode.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";

import { isPortfolioDemoMode } from "./portfolio-mode";

describe("isPortfolioDemoMode", () => {
it("turns production into a read-only showcase when live credentials are retired", () => {
expect(isPortfolioDemoMode({ NODE_ENV: "production" })).toBe(true);
expect(
isPortfolioDemoMode({
NODE_ENV: "production",
GEMINI_API_KEY: "configured",
FINNHUB_KEY: "configured",
}),
).toBe(false);
});

it("keeps local fixture development interactive", () => {
expect(isPortfolioDemoMode({ NODE_ENV: "development" })).toBe(false);
});
});
11 changes: 11 additions & 0 deletions src/lib/portfolio-mode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
type RuntimeEnvironment = {
NODE_ENV?: string;
GEMINI_API_KEY?: string;
FINNHUB_KEY?: string;
};

export const isPortfolioDemoMode = (
environment: RuntimeEnvironment = process.env,
): boolean =>
environment.NODE_ENV === "production" &&
(!environment.GEMINI_API_KEY || !environment.FINNHUB_KEY);
Loading