Skip to content
Open
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
97 changes: 97 additions & 0 deletions CANDIDATE_README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
### 1. Technology Choices

* **Frontend:** `[React + Vite + Tailwind CSS + React Router]`
* **Backend:** `[FastAPI (Python 3.10+) + Uvicorn + SQLAlchemy]`
* **Database:** `[SQLite (dev) via SQLAlchemy ORM]`
* **AI Service:** `[Google Gemini API (streaming)]`

Briefly explain why you chose this stack.
* React + Vite + Tailwind: fast dev experience (HMR), minimal styling overhead, clean responsive UI; React Router keeps routes simple (/, /history, /digest/:publicId).

* FastAPI: type-hinted, high performance, great auto docs (/docs), straightforward Server-Sent Events (SSE) via StreamingResponse.

* SQLite: zero-config and file-based, ideal for a take-home; easily swappable to Postgres/MySQL through SQLAlchemy.

* Gemini: strong long-text summarization and streaming support; generous free tier; can be swapped out if needed.
### 2. How to Run the Project

Provide clear, step-by-step instructions for how to get your project running locally.

Frontend and backend run as separate processes. Default backend: http://localhost:8000. Default frontend: http://localhost:5173.
#### 2.1 Backend (FastAPI)
##### 1. Install dependencies
```bash
cd backend
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt

```
##### 2. Configure environment
```bash
export GEMINI_API_KEY="<your_gemini_api_key>"
```
#### 3. Start the server
```bash
uvicorn main:app --reload --port 8000
```
#### 4. Open API docs
Swagger UI: http://localhost:8000/docs
#### 5. Key endpoints
* POST /summaries — create a job from transcript; returns { id, public_id }
* GET /summaries/{id}/stream — SSE stream of the AI summary; persists on completion
* GET /summaries — list past digests with overview snippets and timestamps
* GET /summaries/{public_id} — fetch a single digest by shareable public_id

#### 2.2 Frontend (React + Vite)
##### 1. Install dependencies
```bash
cd frontend
npm install
```
##### 2. Configure environment
* Create frontend/.env if backend URL differs:
```bash
VITE_API_BASE_URL=http://localhost:8000
```
#### 3. Start the server
```bash
npm run dev
```
#### 4. Use the app
Swagger UI: http://localhost:8000/docs
#### 5. Key endpoints
* Home: paste transcript → click Generate Summary → watch streaming output (Overview / Key Decisions / Action Items). On finish, a share link appears.
* History: see previously generated digests (timestamp + overview snippet); copy share links.
* Share page: /digest/:publicId shows a single digest (viewable by anyone with the link).

### 3. Design Decisions & Trade-offs

Explain any significant architectural or design decisions you made. What were the trade-offs? If you implemented the challenge features, describe your approach. What would you do differently if you had more time?

#### 3.1 Two-step flow (create + stream)
* Create the record via POST /summaries (persist transcript, return id and public_id), then stream via GET /summaries/{id}/stream.
* Pros: clear lifecycle; better UX/error isolation; easy to persist final summary after streaming.
* Cons: one extra request—acceptable for clarity and robustness.

#### 3.2 SSE vs WebSockets
* Chose SSE for simplicity: native EventSource in browsers; minimal server code.
* Trade-off: unidirectional (server → client). If future features need duplex or collaborative editing, switch to WebSockets.
#### 3.3 Output shaping
* Prompt enforces three sections: Overview, Key Decisions, Action Items.
* Initially streams raw text for immediacy; persists the full text at the end.
* If more time: move to structured JSON (response schema / function calling) and store fields separately for search/analytics.
#### 3.4 IDs and share links
* Internal numeric id + external public_id (UUID) for shareable URLs.
* Pros: non-guessable public links; simple read-only sharing.
* Cons: demo omits auth. In production, add auth/ACL for sensitive transcripts.
#### 3.5 Extensibility and deployment
* DB swap to Postgres by changing the SQLAlchemy URL.
* For multi-instance streaming, consider sticky sessions or a shared pub/sub (e.g., Redis).
* Production: reverse proxy with TLS, observability (structured logs, tracing), and stricter CORS.

### 4. AI Usage Log

Describe how you used AI programming assistants during this project. Be specific!
SSE boilerplate and edge cases
Used an AI assistant to scaffold a minimal FastAPI StreamingResponse endpoint, then refined to include a final [DONE], error propagation, and DB persistence after streaming.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,3 +133,4 @@ Explain any significant architectural or design decisions you made. What were th
### 4. AI Usage Log

Describe how you used AI programming assistants during this project. Be specific!

21 changes: 21 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"name": "ai-meeting-digest",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.15.0",
"tailwindcss": "^3.3.0",
"autoprefixer": "^10.4.14",
"postcss": "^8.4.21"
},
"devDependencies": {
"vite": "^4.3.9"
}
}
6 changes: 6 additions & 0 deletions frontend/postcss.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {}
}
};
23 changes: 23 additions & 0 deletions frontend/src/App.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import React from 'react';
import { Routes, Route, Link } from 'react-router-dom';
import HomePage from './pages/HomePage.jsx';
import HistoryPage from './pages/HistoryPage.jsx';
import SummaryPage from './pages/SummaryPage.jsx';

export default function App() {
return (
<div className="max-w-3xl mx-auto p-4">
{/* Simple navigation links */}
<nav className="mb-4 flex space-x-4 text-blue-600">
<Link to="/" className="hover:underline">Home</Link>
<Link to="/history" className="hover:underline">History</Link>
</nav>

<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/history" element={<HistoryPage />} />
<Route path="/digest/:publicId" element={<SummaryPage />} />
</Routes>
</div>
);
}
3 changes: 3 additions & 0 deletions frontend/src/index.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
13 changes: 13 additions & 0 deletions frontend/src/main.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from './App.jsx';
import './index.css'; /* Import Tailwind CSS styles */

ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<BrowserRouter>
<App/>
</BrowserRouter>
</React.StrictMode>
);
54 changes: 54 additions & 0 deletions frontend/src/pages/HistoryPage.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
const API_BASE = import.meta.env.VITE_API_BASE_URL || "http://localhost:8000";

// The history list displays each summary with its creation timestamp and an overview snippet.
// Each entry has a "View" link (navigating to the summary’s detail page) and a "Copy Link" button to copy the share URL.

export default function HistoryPage() {
const [history, setHistory] = useState([]);
const [loading, setLoading] = useState(true);

useEffect(() => {
// Fetch all past summaries on mount
fetch(`${API_BASE}/summaries`)
.then(res => res.json())
.then(data => {
setHistory(data);
})
.catch(err => console.error("Failed to load history:", err))
.finally(() => setLoading(false));
}, []);

const handleCopy = (publicId) => {
const url = `${window.location.origin}/digest/${publicId}`;
navigator.clipboard.writeText(url);
alert("Copied share link for summary ID " + publicId);
};

return (
<div>
<h1 className="text-2xl font-bold mb-4">Summary History</h1>
{loading ? (
<p>Loading summaries...</p>
) : history.length === 0 ? (
<p>No summaries found.</p>
) : (
<ul className="space-y-3">
{history.map(item => (
<li key={item.id} className="border-b pb-2">
<div>
<span className="font-semibold">[{new Date(item.created_at).toLocaleString()}]</span>
<span className="ml-2">{item.overview_snippet}</span>
</div>
<div className="text-sm text-gray-600 flex space-x-4">
<Link to={`/digest/${item.public_id}`} className="text-blue-600 hover:underline">View</Link>
<button onClick={() => handleCopy(item.public_id)} className="hover:underline">Copy Link</button>
</div>
</li>
))}
</ul>
)}
</div>
);
}
106 changes: 106 additions & 0 deletions frontend/src/pages/HomePage.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import React, { useState } from 'react';

const API_BASE = import.meta.env.VITE_API_BASE_URL || "http://localhost:8000";

export default function HomePage() {
const [transcript, setTranscript] = useState("");
const [summary, setSummary] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [shareLink, setShareLink] = useState("");

const handleGenerate = async () => {
if (!transcript.trim()) return;
setSummary("");
setShareLink("");
setIsLoading(true);

try {
// 1. Send transcript to backend to create a summary record
const res = await fetch(`${API_BASE}/summaries`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ transcript })
});
const data = await res.json();
const { id, public_id } = data;
if (!id) throw new Error("Failed to create summary task");

// 2. Open SSE connection for streaming summary
const evtSource = new EventSource(`${API_BASE}/summaries/${id}/stream`);
evtSource.onmessage = (event) => {
if (event.data === "[DONE]") {
// Streaming finished
evtSource.close();
setIsLoading(false);
// Prepare shareable link (frontend route)
if (public_id) {
const url = `${window.location.origin}/digest/${public_id}`;
setShareLink(url);
}
} else {
// Append incoming chunk to summary text
setSummary(prev => prev + event.data);
}
};
evtSource.onerror = (err) => {
console.error("SSE error:", err);
evtSource.close();
setIsLoading(false);
};
} catch (err) {
console.error("Error generating summary:", err);
alert("Failed to generate summary. Please try again.");
setIsLoading(false);
}
};

const handleCopyLink = () => {
if (shareLink) {
navigator.clipboard.writeText(shareLink);
alert("Share link copied to clipboard!");
}
};

return (
<div>
<h1 className="text-2xl font-bold mb-4">AI Meeting Digest</h1>
<textarea
className="w-full border rounded p-2 focus:outline-none focus:ring mb-2"
rows="8"
placeholder="Paste meeting transcript here..."
value={transcript}
onChange={e => setTranscript(e.target.value)}
/>
<button
onClick={handleGenerate}
className="bg-blue-600 text-white px-4 py-2 rounded disabled:opacity-50"
disabled={isLoading || !transcript.trim()}>
{isLoading ? "Summarizing..." : "Generate Summary"}
</button>

{/* Display the summary result */}
{summary && (
<div className="mt-6 bg-gray-50 p-4 rounded">
<h2 className="text-xl font-semibold mb-2">Summary</h2>
<div className="whitespace-pre-wrap">{summary}</div>
{/* Share link section */}
{shareLink && (
<div className="mt-3 text-sm text-gray-700">
<strong>Share this summary:</strong>
<div className="flex items-center">
<a href={shareLink} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline break-all">
{shareLink}
</a>
<button
onClick={handleCopyLink}
className="ml-2 px-2 py-1 border text-sm rounded hover:bg-gray-100">
Copy Link
</button>
</div>
</div>
)}
</div>
)}
</div>
);
}
53 changes: 53 additions & 0 deletions frontend/src/pages/SummaryPage.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import React, { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
const API_BASE = import.meta.env.VITE_API_BASE_URL || "http://localhost:8000";

export default function SummaryPage() {
const { publicId } = useParams();
const [summary, setSummary] = useState(null);
const [loading, setLoading] = useState(true);

useEffect(() => {
// Fetch the summary by its public ID when the component loads
fetch(`${API_BASE}/summaries/${publicId}`)
.then(res => {
if (!res.ok) throw new Error("Summary not found");
return res.json();
})
.then(data => {
setSummary(data);
})
.catch(err => {
console.error(err);
setSummary({ error: "Summary not found or an error occurred." });
})
.finally(() => setLoading(false));
}, [publicId]);

const handleCopyLink = () => {
const url = window.location.href;
navigator.clipboard.writeText(url);
alert("Share link copied to clipboard!");
};

if (loading) {
return <p>Loading summary...</p>;
}
if (!summary || summary.error) {
return <p className="text-red-600">Unable to load summary.</p>;
}

return (
<div>
<h1 className="text-2xl font-bold mb-4">Summary Details</h1>
<div className="bg-gray-50 p-4 rounded">
<h2 className="text-xl font-semibold mb-2">Summary</h2>
<div className="whitespace-pre-wrap">{summary.text}</div>
</div>
{/* Copy link button */}
<button onClick={handleCopyLink} className="mt-3 px-3 py-1 border rounded text-sm hover:bg-gray-100">
Copy Share Link
</button>
</div>
);
}
7 changes: 7 additions & 0 deletions frontend/tailwind.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
module.exports = {
content: ["./index.html", "./src/**/*.{js,jsx,ts,tsx}"],
theme: {
extend: {}
},
plugins: []
};