# Project export: ERNIE

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: CruzHacks 2025
- Tagline: A tool for revealing bias in the news.
- Devpost: https://devpost.com/software/ernie-scgwv5
- GitHub: https://github.com/ErnieNewsBias/ernie
- Team: 3 GitHub contributor(s) — Yohann A. Moraes (23 commits), Raj Khettry (22 commits), Nathan Shturm (5 commits)

## Devpost submission (written by the team)

### Inspiration

Misinformation and bias in the media is extremely prevalent in todays world, where anyone can post and publish anything on the internet. There are many individuals who listen to and cite sources that are dishonest and manipulative without realizing, and we want to guide them to better information in a polite, friendly, and informative manner.

### What it does

Our web app allows the user to enter a URL to a news article, processes it and selects multiple divisive quotes using Gemini API from the article. We run them through our trained model to develop a bias score, verify and adjust the score using Gemini API, and return these to the user in an easily consumable format. Additionally, we provide them with 10 alternate, less-biased articles on similar topics for them to read instead.

### How we built it

We incorporated our current knowledge base to develop a full stack web app. Individually worked on the frontend, backend, and model training for the first half of the hackathon, before collaborating to integrate it fully into a working product.

### Challenges we ran into

Figuring out how to train a model was difficult, as none of us had done that before. Working with Google Cloud Services was also new for us. Figuring out a work around for uploading our model to Github as it was too large

### Accomplishments we're proud of

We managed to train our own model on a laptop using built-in GPUs. We have a product that surpasses our MVP goal. We set a viable scope for the project and completed it satisfactorily. This was the first hackathon for a few team members!

### What we learned

How to train a model on pre-existing data using CUDA Learned to integrate Gemini API in unique ways other than just getting a prompt response.

### What's next

Continue to train the model on a more extensive dataset that we weren't able to gain access to in this timeframe. Implement a google extension for this project, allowing for easier usability on the article page itself.

## README (from the GitHub repository)

No README available.

## Detected evidence (automated analysis)

Indexed codebase: 20 recognized source files, 81 KB.
- CSS (language) — detected in the code
- Flask (technology) — detected in the code
- HTML (language) — detected in the code
- Hugging Face (technology) — detected in the code
- JavaScript (language) — detected in the code
- Next.js (technology) — detected in the code
- Python (language) — detected in the code
- PyTorch (technology) — detected in the code
- React (technology) — detected in the code
- Tailwind CSS (technology) — detected in the code
- TypeScript (language) — detected in the code
- Google Gemini (technology) — claimed on Devpost, not found in the code

## Codebase structure (from repository index)

### Files (39 of 39)

```
.DS_Store
.gitignore
backend/.dockerignore
backend/.env
backend/.gitignore
backend/app.py
backend/docker-compose.yml
backend/Dockerfile
backend/extract_text.py
backend/model/distilbert_media_bias_model_v3/config.json
backend/model/distilbert_media_bias_model_v3/special_tokens_map.json
backend/model/distilbert_media_bias_model_v3/tokenizer_config.json
backend/model/distilbert_media_bias_model_v3/vocab.txt
backend/model/generate_score.py
backend/model/model_prediction.py
backend/process.py
backend/rank.py
backend/requirements.txt
backend/test.py
extension/manifest.json
extension/popup.html
extension/popup.js
extension/styles.css
frontEndERNIE/my-app/.dockerignore
frontEndERNIE/my-app/.gitignore
frontEndERNIE/my-app/docker-compose.yml
frontEndERNIE/my-app/Dockerfile
frontEndERNIE/my-app/eslint.config.mjs
frontEndERNIE/my-app/next.config.ts
frontEndERNIE/my-app/package.json
frontEndERNIE/my-app/postcss.config.mjs
frontEndERNIE/my-app/README.md
frontEndERNIE/my-app/src/app/components/ai-analysis-section.tsx
frontEndERNIE/my-app/src/app/components/bias-score-display.tsx
frontEndERNIE/my-app/src/app/components/similar-articles-section.tsx
frontEndERNIE/my-app/src/app/globals.css
frontEndERNIE/my-app/src/app/layout.tsx
frontEndERNIE/my-app/src/app/page.tsx
frontEndERNIE/my-app/tsconfig.json
```

### Dependencies

- backend/requirements.txt: flask, flask-cors, google, google-cloud-storage, google-genai, google-search-results, gunicorn, lxml_html_clean, newspaper3k, numpy, python-dotenv, sentence_transformers, torch@==2.6.0, transformers
- frontEndERNIE/my-app/package.json: @emotion/react@^11.14.0, @emotion/styled@^11.14.0, @eslint/eslintrc@^3, @mui/icons-material@^7.0.2, @mui/material@^7.0.2, @tailwindcss/postcss@^4, @types/node@20.17.30, @types/react@19.1.1, @types/react-dom@^19, eslint@^9, eslint-config-next@15.3.0, next@15.3.0, react@^19.0.0, react-dom@^19.0.0, tailwindcss@^4, typescript@5.8.3

### Recent commits (newest first)

- Final UI build
- removed cors to allow any endpoint
- setup model in docker
- Backend changes
- LETS GO, (initial commit with full pipeline + multithreading
- Update model_prediction.py
- changes
- local changes stuff
- updated to support local and cloud running
- added new reqs torch transformers
- Update model_prediction.py
- Added Distilbert Media Bias Model V3
- added port 3000 to cors
- added port 3000 to cors
- sas
- asas
- PLEASE BUILD
- fast docker LFG
- changed port to 8080
- added cors headers to

## Key source files (fetched from GitHub, selected and truncated for size)

### backend/requirements.txt

```
flask
newspaper3k
google
google-genai
python-dotenv
lxml_html_clean
gunicorn
google-search-results
flask-cors
numpy
sentence_transformers
torch==2.6.0
transformers
google-cloud-storage
```

### backend/docker-compose.yml

```yaml
version: '3.8'

services:
  backend:
    build:
      context: .
    container_name: ernie_backend_service
    ports:
      # Map port 5002 on your HOST machine to port 8080 INSIDE the container
      # Access the app via http://localhost:5002 on your browser
      - "5002:8080" # <-- Changed BOTH host and container ports here
    # Environment variables are set in the Dockerfile
    # volumes: # Uncomment for development live-reload
    #  - .:/app
```

### backend/Dockerfile

```
# Use Python 3.9 as base image
FROM python:3.9-slim

# Set working directory
WORKDIR /app

# Install system dependencies
RUN apt-get update && apt-get install -y \
    build-essential \
    python3-dev \
    libxml2-dev \
    libxslt1-dev \
    libffi-dev \
    && apt-get clean \
    && rm -rf /var/lib/apt/lists/*

# Copy requirements file
COPY requirements.txt .

# Install Python dependencies
RUN pip install --no-cache-dir -r requirements.txt

# Copy application code
COPY . .

# Create a persistent model cache directory
RUN mkdir -p /app/model_cache

# Expose the port
EXPOSE 8080

# Use simple python command for more reliable behavior
CMD ["python", "app.py"]
```

### frontEndERNIE/my-app/docker-compose.yml

```yaml
version: '3'

services:
  frontend:
    build:
      context: .
      dockerfile: Dockerfile
    ports:
      - "3000:3000"
    environment:
      - NODE_ENV=production
    restart: unless-stopped 
```

### frontEndERNIE/my-app/package.json

```
{
  "name": "my-app",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev --turbopack",
    "build": "next build",
    "start": "next start",
    "lint": "next lint"
  },
  "dependencies": {
    "@emotion/react": "^11.14.0",
    "@emotion/styled": "^11.14.0",
    "@mui/icons-material": "^7.0.2",
    "@mui/material": "^7.0.2",
    "next": "15.3.0",
    "react": "^19.0.0",
    "react-dom": "^19.0.0"
  },
  "devDependencies": {
    "@eslint/eslintrc": "^3",
    "@tailwindcss/postcss": "^4",
    "@types/node": "20.17.30",
    "@types/react": "19.1.1",
    "@types/react-dom": "^19",
    "eslint": "^9",
    "eslint-config-next": "15.3.0",
    "tailwindcss": "^4",
    "typescript": "5.8.3"
  }
}

```

### frontEndERNIE/my-app/Dockerfile

```
# Stage 1: Dependencies
FROM node:20-alpine AS deps
WORKDIR /app

# Copy package files
COPY package.json package-lock.json ./

# Install dependencies
RUN npm ci

# Stage 2: Builder
FROM node:20-alpine AS builder
WORKDIR /app

# Copy dependencies from previous stage
COPY --from=deps /app/node_modules ./node_modules
COPY . .

# Build the application
ENV NEXT_TELEMETRY_DISABLED 1
RUN npm run build

# Stage 3: Runner
FROM node:20-alpine AS runner
WORKDIR /app

ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1

# Create a non-root user to run the app
RUN addgroup --system --gid 1001 nodejs && \
    adduser --system --uid 1001 nextjs

# Copy necessary files from builder
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static

# Set correct permissions
RUN chown -R nextjs:nodejs /app

# Switch to non-root user
USER nextjs

# Expose port
EXPOSE 3000

# Set environment variables for the host and port
ENV PORT 3000
ENV HOSTNAME "0.0.0.0"

# Start the application
CMD ["node", "server.js"]

```

### backend/app.py

```python
from flask import Flask, request, jsonify
from flask_cors import CORS # Import CORS
from extract_text import extract_article_metadata, extract_information  # Import both functions
import os
from concurrent.futures import ThreadPoolExecutor
from process import process_url
import threading
import time

app = Flask(__name__)

# Configure CORS to allow requests from multiple origins
CORS(app, resources={r"/scrape": {"origins": "*"}})

# API endpoint to scrape, extract text, and calculate a bias score.
@app.route('/scrape', methods=['GET'])
def scrape():
    thread_name = threading.current_thread().name
    print(f"[THREAD:{thread_name}] scrape: Received request for URL: {request.args.get('url', 'None')}")
    
    url = request.args.get('url')
    if not url:
        print(f"[THREAD:{thread_name}] scrape: Missing URL parameter")
        return jsonify({'error': 'URL parameter is required'}), 400
    
    try:
        print(f"[THREAD:{thread_name}] scrape: Processing URL: {url}")
        start_time = time.time()
        
        # Process URL using the multithreaded function
        ai_notes, similar_articles, bias_score, search_query = process_url(url)
        
        print(f"[THREAD:{thread_name}] scrape: URL processed in {time.time() - start_time:.2f} seconds")
        print(f"[THREAD:{thread_name}] scrape: Getting article metadata")
        
        # Extract article metadata for the response
        article_metadata = extract_article_metadata(url)
        
        # Calculate overall bias
        overall_bias = sum(bias_score.values()) / len(bias_score) if bias_score else 0
        print(f"[THREAD:{thread_name}] scrape: Overall bias score: {overall_bias}")
        
        # Prepare response
        print(f"[THREAD:{thread_name}] scrape: Preparing JSON response")
        response = jsonify({
            'original_article': {
                'url': url,
                'title': article_metadata['title'],
                'image_url': article_metadata['image_url'],
                'text_preview': article_metadata['text'][:150] + "..." if len(article_metadata['text']) > 150 else article_metadata['text']
            },
            'analysis': {
                'bias': overall_bias,
                'ai_notes': ai_notes,
                'bias_quotes': list(bias_score.keys()) if bias_score else [],
                'bias_score': bias_score,
                'search_query': search_query if search_query else ""
            },
            'similar_articles': similar_articles
        })
        
        print(f"[THREAD:{thread_name}] scrape: Request completed successfully in {time.time() - start_time:.2f} seconds")
        return response
        
    except Exception as e:
        print(f"[THREAD:{thread_name}] [ERROR] scrape: {str(e)}")
        import traceback
        traceback.print_exc()
        return jsonify({'error': str(e)}), 500

if __name__ == '__main__':
    # Cloud Run sets this environment variable
    port = int(os.environ.get('PORT', 8080))
    # Must listen on 0.0.0.0 for Cloud Run
    app.run(host='0.0.0.0', port=port, debug=False)
```

### frontEndERNIE/my-app/src/app/layout.tsx

```typescript
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";

const geistSans = Geist({
  variable: "--font-geist-sans",
  subsets: ["latin"],
});

const geistMono = Geist_Mono({
  variable: "--font-geist-mono",
  subsets: ["latin"],
});

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body
        className={`${geistSans.variable} ${geistMono.variable} antialiased`}
      >
        {children}
      </body>
    </html>
  );
}

```

### frontEndERNIE/my-app/src/app/page.tsx

```typescript
"use client"

import type React from "react"
import { useState } from "react"
import Image from "next/image"
import {
  Box,
  Container,
  Typography,
  TextField,
  Button,
  Card,
  CardContent,
  CircularProgress,
  Paper,
} from "@mui/material"
import BiasScoreDisplay from "@/app/components/bias-score-display"
import AIAnalysisSection from "@/app/components/ai-analysis-section"
import SimilarArticlesSection from "@/app/components/similar-articles-section"
import { ThemeProvider, createTheme } from "@mui/material/styles"
import CssBaseline from "@mui/material/CssBaseline"

interface OriginalArticle {
  url: string
  title: string | null
  image_url: string | null
  text_preview: string | null
}

interface AnalysisData {
  bias: number | null
  ai_notes: string | null
  bias_quotes: string[] | null
  bias_score: Record<string, number> | null
  search_query: string | null
}

interface SimilarArticleDetail {
  image_url: string | null
  score: number
  text_preview: string | null
  title: string | null
}

interface SimilarArticlesData {
  [url: string]: SimilarArticleDetail
}

const theme = createTheme({
  palette: {
    primary: {
      main: "#1976d2",
    },
    secondary: {
      main: "#dc004e",
    },
    background: {
      default: "#95c2ee",
    },
  },
  typography: {
    fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
    h4: {
      fontWeight: 600,
    },
  },
})

export default function Home() {
  const [url, setUrl] = useState("")
  const [isLoading, setIsLoading] = useState(false)
  const [analysisComplete, setAnalysisComplete] = useState(false)
  const [apiData, setApiData] = useState<{
    original_article: OriginalArticle | null
    analysis: AnalysisData | null
    similar_articles: SimilarArticlesData | null
  } | null>(null)
  const [apiError, setApiError] = useState<string | null>(null)

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()
    console.log("Form submitted with URL:", url)

    if (!url) {
      console.log("URL is empty, not proceeding")
      return
    }

    let processUrl = url
    if (!url.startsWith("http://") && !url.startsWith("https://")) {
      processUrl = "https://" + url
      setUrl(processUrl)
      console.log("Added https:// prefix to URL:", processUrl)
    }

    setIsLoading(true)
    setAnalysisComplete(false)
    setApiData(null)
    setApiError(null)

    try {
      const apiUrl = `https://ernie-1031077341247.us-central1.run.app/scrape?url=${encodeURIComponent(processUrl)}`
      console.log("Calling API:", apiUrl)

      const response = await fetch(apiUrl)

      if (!response.ok) {
        const errorData = await response.json().catch(() => ({}))
        throw new Error(errorData?.error || `API request failed with status ${response.status}`)
      }

      const data = await response.json()
      console.log("API Response:", data)

      setApiData({
        original_article: data.original_article ?? null,
        analysis: data.analysis ?? null,
        similar_articles: data.similar_articles ?? null,
      })

      setAnalysisComplete(true)
      console.log("Analysis completed for URL:", processUrl)
    } catch (error: unknown) {
      console.error("API call failed:", error)
      setApiError(error instanceof Error ? error.message : "An unknown error occurred")
      setAnalysisComplete(false)
    } finally {
      setIsLoading(false)
    }
  }

  const handleAnalyzeSimilarArticle = (newUrl: string) => {
    setUrl(newUrl)
    const syntheticEvent = { preventDefault: () => {} } as React.FormEvent
    handleSubmit(syntheticEvent)
  }

  const determineLeaning = (score: number | null): string => {
    if (score === null) return 'center'
    if (score < 40) return 'left'
    if (score > 60) return 'right'
    return 'center'
  }

  return (
    <ThemeProvider theme={theme}>
      <CssBaseline />
      <Box
        sx={{
          minHeight: "200vh",
          py: 7,
          px: 1,
          background: "linear-gradient(to bottom right,#628fe9,#f07d7d)",
        }}
      >
        <Container maxWidth="md">
          <Typography
            variant="h4"
            component="h1"
            gutterBottom
            sx={{
              fontFamily: 'Brush Script MT',
              fontWeight: 'semi-bold',
              color: 'white',
              textShadow: '1px 1px 3px rgba(0,0,0,0.6)',
            }}
          >
            Article Bias Analyzer
          </Typography>
          <Typography variant="body1" color="white" sx={{ mb: 4, fontWeight: 'bold' }}>
            <span style={{ fontFamily: 'Garamond' }}>
              Enter a link to any article to analyze its political bias and receive an AI-powered content analysis.
            </span>
          </Typography>

          <Paper elevation={5} sx={{ p: 2, mb: 2 }}>
            <form onSubmit={handleSubmit}>
              <Box sx={{ display: "flex", flexDirection: { xs: "column", sm: "row" }, gap: 2 }}>
                <TextField
                  fullWidth
                  label="Article URL"
                  variant="outlined"
                  placeholder="https://example.com/article"
                  value={url}
                  onChange={(e) => setUrl(e.target.value)}
                  onKeyPress={(e) => {
                    if (e.key === "Enter") {
                      e.preventDefault()
                      handleSubmit(e)
                    }
                  }}
                  helperText={!url ? "Enter a website URL (with or without https://)" : ""}
                  error={!!apiError}
                />
                <Button
                  variant="contained"
                  type="submit"
                  disabled={isLoading || !url}
                  sx={{
                    background: "linear-gradient(to bottom right, #628fe9, #f07d7d)",
                    color: "white",
                    fontWeight: "bold",
                    "&:hover": {
                      background: "linear-gradient(to
[truncated — 3618 more characters]
```

### extension/popup.html

```html
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8" />
  <title>Ernie News</title>
  <link rel="stylesheet" href="styles.css">
</head>
<body>
  <h1>Ernie News</h1>
  <button id="analyzeBtn">Analyze Page</button>

  <div id="result" class="hidden">
    <div class="meter-container">
      <div id="biasLabel">Score: 0</div>
      <div class="bias-meter">
        <div id="meterFill" class="fill"></div>
      </div>
    </div>

    <div class="tabs">
      <button class="tab" data-tab="notes">AI Notes</button>
      <button class="tab" data-tab="quotes">Biased Quotes</button>
    </div>

    <div id="tabContent" class="content-box"></div>
  </div>

  <script src="popup.js"></script>
</body>
</html>

```

[13 more indexed source files omitted to keep this export small. The full file list is in the Codebase structure section above.]