From 5784475e8234957dd0d084e2599fc69593a49a54 Mon Sep 17 00:00:00 2001 From: dmitry-kostin Date: Thu, 19 Feb 2026 23:21:27 +0100 Subject: [PATCH 1/5] Add AI summarization, update docs, and redesign summarize form - Add summarize feature: OpenAI Chat API integration, sidecar storage, demo mode, tab-aware copy, transcript/summary tabs - Prepend video title to transcript and summary body in all codepaths - Redesign summarize prompt as command-bar style with textarea - Update README and CLAUDE.md with summarize docs, new API endpoints, config, and project structure - Add tests for summarize CRUD, API endpoints, and demo mode - Add MIT license, CI workflow, and banner Co-Authored-By: Claude Opus 4.6 --- .github/ISSUE_TEMPLATE/bug_report.md | 24 +++ .github/ISSUE_TEMPLATE/feature_request.md | 17 ++ .github/pull_request_template.md | 17 ++ .github/workflows/tests.yml | 33 +++ CLAUDE.md | 15 ++ LICENSE | 21 ++ README.md | 162 ++++++++++----- app/api.py | 68 ++++++- app/config.py | 1 + app/history.py | 66 +++++- app/static/app.js | 230 +++++++++++++++++++-- app/static/style.css | 233 ++++++++++++++++++++-- app/summarizer.py | 33 +++ docs/banner.svg | 53 +++++ pyproject.toml | 4 +- tests/test_api_endpoints.py | 64 +++++- tests/test_history.py | 75 +++++++ 17 files changed, 1040 insertions(+), 76 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/tests.yml create mode 100644 LICENSE create mode 100644 app/summarizer.py create mode 100644 docs/banner.svg diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..918dca0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,24 @@ +--- +name: Bug report +about: Report a problem +labels: bug +--- + +## Environment + +- OS: +- Python version: +- Browser: + +## Steps to reproduce + +1. +2. +3. + +## Expected behavior + +## Actual behavior + +## Logs / screenshots + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..116bd08 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,17 @@ +--- +name: Feature request +about: Suggest an improvement +labels: enhancement +--- + +## Problem + +Describe the problem or limitation you're experiencing. + +## Proposed solution + +Describe what you'd like to happen. + +## Alternatives considered + +Any other approaches you've thought about. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..af5edd7 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,17 @@ +## What + +Brief description of the change. + +## Why + +Motivation or issue link. + +## How + +Implementation approach. + +## Testing + +- [ ] `poetry run pytest -v` passes +- [ ] Manually tested in browser +- [ ] Tested in demo mode (`?demo`) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..c78235d --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,33 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11", "3.12", "3.13"] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install Poetry + uses: snok/install-poetry@v1 + with: + virtualenvs-in-project: true + + - name: Install dependencies + run: poetry install --no-interaction + + - name: Run tests + run: poetry run pytest -v diff --git a/CLAUDE.md b/CLAUDE.md index ee4ebbe..0654e2d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,6 +8,7 @@ ## Environment - Requires `TM_OPENAI_API_KEY` env var or `.env` file (not needed for demo mode) +- `TM_SUMMARIZE_MODEL` env var (default: `gpt-4o`) — model used for AI summarization - Tests set a dummy key in `conftest.py` — no real key needed for unit tests ## Demo Mode @@ -20,6 +21,7 @@ ## Architecture - FastAPI app with SSE-based transcription pipeline: download → chunk → whisper API → save - History stored as markdown files with YAML frontmatter in `results/` — no database +- AI summarization via OpenAI Chat API — summaries stored as sidecar files `results/{record_id}_summary.md` - Frontend is vanilla HTML/CSS/JS in `app/static/` — no build step ## Key Patterns @@ -29,6 +31,9 @@ - `complete_record()` / `fail_record()` rebuild the full YAML meta dict from parsed record — always include all fields - `_write_md()` uses `sort_keys=False` to preserve frontmatter key order - Audio cached in `results/{record_id}.mp3` after download — reused by retranscribe, deleted with record +- Summary sidecar: `results/{record_id}_summary.md` with YAML frontmatter (prompt, created_at) + body +- `delete_record()` cascades to delete summary sidecar + audio cache +- Video title prepended as first line of transcript and summary body in `api.py` (all codepaths: transcribe, demo, retranscribe, summarize, demo summarize) ## Two Whisper Models - `gpt-4o-transcribe` (default) — plain text output @@ -49,6 +54,16 @@ - Mock external deps (yt-dlp, ffmpeg, OpenAI) in unit tests - Integration tests marked with `@pytest.mark.integration` +## Summarize Feature +- Summarize button appears on completed transcript cards (expands card if collapsed) +- User can enter a custom prompt or use the default +- Summary stored as `results/{record_id}_summary.md` with YAML frontmatter (prompt, created_at) +- Video title prepended as first line of both transcript body and summary body +- Expanded cards show Transcript/Summary tab toggle when a summary exists +- Copy button copies based on active tab (transcript or summary), toast says "Transcript copied" / "Summary copied" +- Demo mode: simulated 2s delay, canned summary text +- Endpoints: `POST /api/history/{id}/summarize`, `GET /api/history/{id}/summary` + ## Gotchas - `_parse_md()` coerces all YAML values to strings (line 57) except duration (explicitly int) - Old records may lack newer frontmatter fields — always use `.get("field", "")` with defaults diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ec53034 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 dmitry-kostin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index a1337f6..b7c0e85 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,35 @@ -# Transcript Maker +

+ Transcript Maker +

-Paste a YouTube URL, get a transcript. A single-page web app that downloads audio from YouTube and transcribes it using OpenAI Whisper. +

+ Paste a YouTube URL, get a transcript and summarize with AI. +

-image +

+ Tests + Python 3.11+ + License MIT +

+ +--- + +image ## Features - **YouTube audio download** via yt-dlp (any public YouTube video up to 4 hours) - **OpenAI Whisper transcription** with automatic language detection - **Speaker detection** — optional diarization with speaker labels (`gpt-4o-transcribe-diarize`) +- **AI summarization** — generate summaries via OpenAI Chat API, stored as sidecar files - **Re-transcribe** — re-run any completed or failed transcription, optionally switching models - **Expandable history** — click any completed transcript to preview the text inline - **Real-time progress** streamed to the browser via Server-Sent Events - **Cancel** an in-progress transcription from the UI - **History** with status tracking — persists across page refreshes and server restarts - **Show in Finder** — reveal any saved transcript file on disk -- **Copy / Download** — copy transcript to clipboard or save as `.txt` +- **Copy / Download** — tab-aware copy (transcript or summary) to clipboard, or save as `.txt` - **Markdown-based storage** — each transcript is a `.md` file, no database - **Playlist rejection** — only single video URLs accepted @@ -25,54 +38,19 @@ Paste a YouTube URL, get a transcript. A single-page web app that downloads audi - Python 3.11+, FastAPI, uvicorn - yt-dlp (YouTube download) - OpenAI Whisper API (transcription) +- OpenAI Chat API (summarization) - ffmpeg / ffprobe (audio chunking) - pydantic-settings (configuration) - sse-starlette (SSE streaming) - Vanilla HTML / CSS / JS (no build step) -## Project Structure - -``` -transcript-maker/ -├── pyproject.toml # Poetry deps & metadata -├── run.py # Single-script launcher (uvicorn) -├── .env.example # Template for API key -├── .gitignore -├── app/ -│ ├── __init__.py -│ ├── main.py # FastAPI app factory + static mount -│ ├── config.py # pydantic-settings (env vars) -│ ├── api.py # API routes (transcribe + history endpoints) -│ ├── downloader.py # yt-dlp: download + extract audio -│ ├── transcriber.py # ffmpeg chunking + OpenAI Whisper API -│ ├── history.py # Persistence layer (markdown files) -│ └── static/ -│ ├── index.html -│ ├── style.css -│ └── app.js -├── tests/ -│ ├── conftest.py # Shared fixtures -│ ├── test_history.py # History module tests -│ ├── test_downloader.py # Downloader unit tests (mocked yt-dlp) -│ ├── test_transcriber.py # Transcriber unit tests (mocked ffmpeg) -│ ├── test_validation.py # URL validation tests -│ ├── test_api_endpoints.py # API endpoint tests (TestClient) -│ └── test_integration.py # End-to-end tests (real APIs) -├── tmp/ # Runtime temp files (gitignored) -└── results/ # Saved transcripts as .md files (gitignored) -``` - -## Prerequisites +## Quick Start -- **Python 3.11+** -- **Poetry** — [install instructions](https://python-poetry.org/docs/#installation) -- **ffmpeg** — `brew install ffmpeg` (macOS) or `apt install ffmpeg` (Linux) -- **OpenAI API key** — with access to the Whisper model - -## Setup & Launch +**Prerequisites:** Python 3.11+, [Poetry](https://python-poetry.org/docs/#installation), [ffmpeg](https://ffmpeg.org/) (`brew install ffmpeg` / `apt install ffmpeg`), and an [OpenAI API key](https://platform.openai.com/api-keys). ```bash -# Install dependencies +git clone https://github.com/dmitry-kostin/transcript-maker.git +cd transcript-maker poetry install # Configure API key (pick one) @@ -106,6 +84,40 @@ All settings use the `TM_` prefix and can be set via environment variables or a | `TM_RESULTS_DIR` | `./results` | Directory for saved transcript `.md` files | | `TM_MAX_CHUNK_SIZE_MB` | `24.0` | Max size per audio chunk sent to Whisper | | `TM_AUDIO_FORMAT` | `mp3` | Audio format for yt-dlp extraction | +| `TM_SUMMARIZE_MODEL` | `gpt-4o` | OpenAI model for AI summarization | + +## Project Structure + +``` +transcript-maker/ +├── pyproject.toml # Poetry deps & metadata +├── run.py # Single-script launcher (uvicorn) +├── .env.example # Template for API key +├── .gitignore +├── app/ +│ ├── __init__.py +│ ├── main.py # FastAPI app factory + static mount +│ ├── config.py # pydantic-settings (env vars) +│ ├── api.py # API routes (transcribe + history endpoints) +│ ├── downloader.py # yt-dlp: download + extract audio +│ ├── transcriber.py # ffmpeg chunking + OpenAI Whisper API +│ ├── summarizer.py # OpenAI Chat Completions for summarization +│ ├── history.py # Persistence layer (markdown files) +│ └── static/ +│ ├── index.html +│ ├── style.css +│ └── app.js +├── tests/ +│ ├── conftest.py # Shared fixtures +│ ├── test_history.py # History module tests +│ ├── test_downloader.py # Downloader unit tests (mocked yt-dlp) +│ ├── test_transcriber.py # Transcriber unit tests (mocked ffmpeg) +│ ├── test_validation.py # URL validation tests +│ ├── test_api_endpoints.py # API endpoint tests (TestClient) +│ └── test_integration.py # End-to-end tests (real APIs) +├── tmp/ # Runtime temp files (gitignored) +└── results/ # Saved transcripts as .md files (gitignored) +``` ## API Reference @@ -120,8 +132,11 @@ All settings use the `TM_` prefix and can be set via environment variables or a | `POST` | `/api/history/{id}/reveal` | Open Finder with the transcript file selected | | `DELETE` | `/api/history/{id}` | Delete a saved transcript | | `POST` | `/api/cleanup` | Clean up temp files and stale records | +| `POST` | `/api/history/{id}/summarize` | Generate AI summary for a transcript | +| `GET` | `/api/history/{id}/summary` | Get stored summary for a transcript | | `POST` | `/api/demo/transcribe` | Demo: simulated transcription (SSE stream) | | `POST` | `/api/demo/history/{id}/retranscribe` | Demo: simulated re-transcription (SSE stream) | +| `POST` | `/api/demo/history/{id}/summarize` | Demo: simulated summarization | ### POST /api/transcribe @@ -157,6 +172,42 @@ Re-transcribes an existing record using its stored URL. Returns an SSE stream id Returns 400 for invalid ID, 404 if not found, 409 if the record is currently `in_progress`. +### POST /api/history/{id}/summarize + +Generate an AI summary for a completed transcript. + +**Request:** +```json +{ "prompt": "Summarize the key points" } +``` + +- `prompt` — Custom summarization prompt (optional, empty string uses default prompt) + +**Response:** +```json +{ + "summary": "Video Title\n\nGenerated summary text...", + "prompt": "Summarize the key points" +} +``` + +Returns 400 for invalid ID or incomplete record, 404 if not found, 500 if summarization fails. + +### GET /api/history/{id}/summary + +Retrieve a previously generated summary. + +**Response:** +```json +{ + "summary": "Video Title\n\nSummary text...", + "prompt": "Summarize the key points", + "created_at": "2026-02-19T10:35:00" +} +``` + +Returns 400 for invalid ID, 404 if no summary exists. + ### GET /api/history **Response:** @@ -169,8 +220,10 @@ Returns 400 for invalid ID, 404 if not found, 409 if the record is currently `in "status": "done", "duration": 213, "model": "gpt-4o-transcribe-diarize", + "words": 1842, "created_at": "2026-02-19T10:30:00", - "error": "" + "error": "", + "has_summary": true } ] ``` @@ -207,11 +260,18 @@ created_at: "2026-02-19T10:30:00" error: "" --- +Video Title Full transcript text here... ``` +Video title is prepended as the first line of both the transcript body and summary body. + **Filename format:** `{slugified-title}_{8-hex-id}.md` +**Summary sidecar:** `{record_id}_summary.md` — stores AI-generated summary with YAML frontmatter (`prompt`, `created_at`) and summary text as body. Deleted automatically when the parent record is deleted. + +**Audio cache:** `{record_id}.mp3` — cached audio file, reused by retranscribe. Deleted automatically when the parent record is deleted. + **Status lifecycle:** `in_progress` → `done` | `error` On server startup, any leftover `in_progress` records (from a prior crash) are automatically marked as `error`. @@ -237,3 +297,15 @@ Integration tests use a short YouTube video and are skipped automatically when n - **Show in Finder** — record ID validated as exactly 8 hex chars; file path resolved by scanning `results/` (never from user input); path traversal guard checks resolved parent matches `results/`; `open -R` is read-only - **No shell injection** — all subprocess calls use list arguments, never shell strings - **Temp file isolation** — UUID suffixes prevent filename collisions between concurrent requests + +## Contributing + +1. Fork the repository +2. Create a feature branch (`git checkout -b feat/my-feature`) +3. Make your changes and add tests +4. Run `poetry run pytest -v` to verify +5. Open a pull request + +## License + +This project is licensed under the [MIT License](LICENSE). diff --git a/app/api.py b/app/api.py index 8319fb8..45e56ae 100644 --- a/app/api.py +++ b/app/api.py @@ -23,8 +23,10 @@ get_history, get_result_path, delete_record, get_record, get_record_status, reset_record, save_audio, get_audio_path, RESULTS_DIR, + save_summary, get_summary, ) from app.transcriber import prepare_chunks, transcribe_chunk, cleanup_temp_files +from app.summarizer import summarize_text logger = logging.getLogger(__name__) router = APIRouter() @@ -43,6 +45,17 @@ DEMO_TRANSCRIBE_SECONDS = 10 DEMO_TICK = 0.5 +DEMO_SUMMARY = ( + "## Key Points\n\n" + "- This is a simulated summary generated in demo mode\n" + "- No real OpenAI API call was made\n" + "- The transcript discussed various placeholder topics\n\n" + "## Main Topics\n\n" + "1. Quick brown fox athletics\n" + "2. Lorem ipsum philosophy\n" + "3. General placeholder discourse" +) + async def _demo_event_generator(url: str, model: str, request: Request, record_id: str | None = None, title: str | None = None): """Mock SSE generator — simulates download + transcribe with sleeps, no real APIs.""" @@ -99,6 +112,7 @@ async def _demo_event_generator(url: str, model: str, request: Request, record_i full_text = DEMO_TRANSCRIPT if model == "gpt-4o-transcribe-diarize": full_text = "Speaker 1: " + DEMO_TRANSCRIPT + full_text = f"{title}\n\n{full_text}" complete_record(record_id, full_text) yield {"event": "transcript", "data": json.dumps({"text": full_text, "duration_seconds": duration, "title": title, "record_id": record_id})} yield {"event": "done", "data": "{}"} @@ -141,6 +155,10 @@ class RetranscribeRequest(BaseModel): model: str = "" +class SummarizeRequest(BaseModel): + prompt: str = "" + + @router.post("/api/demo/transcribe") async def demo_transcribe(request: Request): try: @@ -164,6 +182,22 @@ async def demo_retranscribe(record_id: str, request: Request): )) +@router.post("/api/demo/history/{record_id}/summarize") +async def demo_summarize(record_id: str, req: SummarizeRequest): + if not re.fullmatch(r"[0-9a-f]{8}", record_id): + return JSONResponse({"error": "Invalid ID"}, status_code=400) + record = get_record(record_id) + if not record: + return JSONResponse({"error": "Not found"}, status_code=404) + if record["status"] != "done": + return JSONResponse({"error": "Record is not completed"}, status_code=400) + await asyncio.sleep(2) + prompt = req.prompt.strip() + summary_with_title = f"{record['title']}\n\n{DEMO_SUMMARY}" + save_summary(record_id, summary_with_title, prompt) + return {"summary": summary_with_title, "prompt": prompt} + + @router.post("/api/transcribe") async def transcribe(req: TranscribeRequest, request: Request): async def event_generator(): @@ -216,7 +250,7 @@ async def event_generator(): logger.warning("Client disconnected, leaving record as in_progress") return - full_text = " ".join(transcript_parts) + full_text = f"{title}\n\n{' '.join(transcript_parts)}" if not complete_record(record_id, full_text): logger.warning("Transcription succeeded but history write failed for %s", record_id) logger.info("Transcription done: %s", record_id) @@ -324,7 +358,7 @@ async def event_generator(): logger.warning("Client disconnected, leaving record as in_progress") return - full_text = " ".join(transcript_parts) + full_text = f"{record['title']}\n\n{' '.join(transcript_parts)}" if not complete_record(record_id, full_text): logger.warning("Retranscription succeeded but history write failed for %s", record_id) logger.info("Retranscription done: %s", record_id) @@ -361,6 +395,36 @@ async def reveal_in_finder(record_id: str): return {"ok": True} +@router.post("/api/history/{record_id}/summarize") +async def summarize(record_id: str, req: SummarizeRequest): + if not re.fullmatch(r"[0-9a-f]{8}", record_id): + return JSONResponse({"error": "Invalid ID"}, status_code=400) + record = get_record(record_id) + if not record: + return JSONResponse({"error": "Not found"}, status_code=404) + if record["status"] != "done": + return JSONResponse({"error": "Record is not completed"}, status_code=400) + try: + summary = await summarize_text(record["body"], req.prompt) + except Exception as e: + logger.error("Summarize error for %s: %s", record_id, e, exc_info=True) + return JSONResponse({"error": f"Summarization failed: {e}"}, status_code=500) + prompt = req.prompt.strip() + summary_with_title = f"{record['title']}\n\n{summary}" + save_summary(record_id, summary_with_title, prompt) + return {"summary": summary_with_title, "prompt": prompt} + + +@router.get("/api/history/{record_id}/summary") +async def get_record_summary(record_id: str): + if not re.fullmatch(r"[0-9a-f]{8}", record_id): + return JSONResponse({"error": "Invalid ID"}, status_code=400) + result = get_summary(record_id) + if not result: + return JSONResponse({"error": "No summary found"}, status_code=404) + return result + + @router.delete("/api/history/{record_id}") async def delete_history(record_id: str): if not re.fullmatch(r"[0-9a-f]{8}", record_id): diff --git a/app/config.py b/app/config.py index 8046a7a..06e3a1e 100644 --- a/app/config.py +++ b/app/config.py @@ -11,6 +11,7 @@ class Settings(BaseSettings): results_dir: str = str(PROJECT_ROOT / "results") max_chunk_size_mb: float = 24.0 audio_format: str = "mp3" + summarize_model: str = "gpt-4o" model_config = {"env_prefix": "TM_", "env_file": ".env"} diff --git a/app/history.py b/app/history.py index f73f3ab..05604ff 100644 --- a/app/history.py +++ b/app/history.py @@ -154,7 +154,10 @@ def get_record(record_id: str) -> dict | None: path = _resolve_path(record_id) if not path: return None - return _parse_md(path) + parsed = _parse_md(path) + if parsed: + parsed["has_summary"] = _summary_path(record_id).exists() + return parsed def reset_record(record_id: str, model: str = "") -> bool: @@ -188,6 +191,7 @@ def get_history() -> list[dict]: # Don't send the full body in the list — just metadata parsed.pop("body", None) parsed.pop("path", None) + parsed["has_summary"] = _summary_path(parsed["id"]).exists() records.append(parsed) records.sort(key=lambda r: r.get("created_at", ""), reverse=True) return records @@ -235,11 +239,12 @@ def get_audio_path(record_id: str) -> Path | None: def delete_record(record_id: str) -> bool: - """Delete a record's .md file and cached audio. Returns True if deleted.""" + """Delete a record's .md file, cached audio, and summary. Returns True if deleted.""" path = _resolve_path(record_id) if not path: return False audio = get_audio_path(record_id) + delete_summary(record_id) path.unlink(missing_ok=True) if audio: audio.unlink(missing_ok=True) @@ -247,6 +252,63 @@ def delete_record(record_id: str) -> bool: return True +def _summary_path(record_id: str) -> Path: + """Return the path for a record's summary sidecar file.""" + return RESULTS_DIR / f"{record_id}_summary.md" + + +def save_summary(record_id: str, summary: str, prompt: str = "") -> bool: + """Save a summary for a record. Returns True if written.""" + path = _resolve_path(record_id) + if not path: + return False + RESULTS_DIR.mkdir(exist_ok=True) + meta = { + "prompt": prompt, + "created_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S"), + } + _write_md(_summary_path(record_id), meta, summary) + logger.info("Summary saved for record %s", record_id) + return True + + +def get_summary(record_id: str) -> dict | None: + """Return summary for a record, or None if not found.""" + if not re.fullmatch(r"[0-9a-f]{8}", record_id): + return None + sp = _summary_path(record_id) + if not sp.exists(): + return None + try: + text = sp.read_text(encoding="utf-8") + except OSError: + return None + meta = {} + body = "" + if text.startswith("---"): + parts = text.split("---", 2) + if len(parts) >= 3: + try: + loaded = yaml.safe_load(parts[1]) + if isinstance(loaded, dict): + meta = {k: str(v) if v is not None else "" for k, v in loaded.items()} + except yaml.YAMLError: + pass + body = parts[2].strip() + return { + "prompt": meta.get("prompt", ""), + "summary": body, + "created_at": meta.get("created_at", ""), + } + + +def delete_summary(record_id: str) -> None: + """Delete a summary sidecar file if it exists.""" + if not re.fullmatch(r"[0-9a-f]{8}", record_id): + return + _summary_path(record_id).unlink(missing_ok=True) + + def cleanup_stale_records() -> None: """Mark any in_progress records as failed (stale from prior crash).""" RESULTS_DIR.mkdir(exist_ok=True) diff --git a/app/static/app.js b/app/static/app.js index b0dcac4..3261abd 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -32,6 +32,7 @@ let pollTimer = null; let lastHistoryIds = ""; let activeRecordId = null; const bodyCache = new Map(); +const summaryCache = new Map(); // ─── SVG icons ─── @@ -41,6 +42,7 @@ const ICONS = { refresh: '', copy: '', download: '', + sparkle: '🦄', }; // ─── Helpers ─── @@ -238,7 +240,10 @@ function renderCard(record, opts = {}) { let actions = ""; let quickCopy = ""; + let quickSummarize = ""; if (record.status === "done") { + const summarizeTitle = record.has_summary ? "Re-summarize" : "Summarize"; + quickSummarize = ``; quickCopy = ``; actions = ` @@ -259,14 +264,16 @@ function renderCard(record, opts = {}) { bodyHtml = `
${escapeHtml(opts.bodyText)}
`; } + const hasSummary = record.has_summary ? "true" : "false"; + return ` -
+
${escapeHtml(record.title)}
${meta}${record.status === "error" && record.error ? " \u00b7 " + escapeHtml(record.error) : ""}
${actions ? `
${actions}
` : ""}
- ${quickCopy} + ${quickCopy}${quickSummarize} ${bodyHtml}
`; } @@ -300,10 +307,21 @@ function renderActiveResult(data) { // ─── New action functions ─── async function copyRecordText(id) { - const text = await getRecordBody(id); - if (!text) return; - await navigator.clipboard.writeText(text); - showToast("Copied to clipboard"); + const card = document.querySelector(`.history-card[data-id="${id}"]`); + const activeTab = card?.querySelector(".card-tab.active"); + const isSummary = activeTab?.dataset.tab === "summary"; + + if (isSummary) { + const data = await getRecordSummary(id); + if (!data?.summary) return; + await navigator.clipboard.writeText(data.summary); + showToast("Summary copied"); + } else { + const text = await getRecordBody(id); + if (!text) return; + await navigator.clipboard.writeText(text); + showToast("Transcript copied"); + } } async function downloadRecordText(id, title) { @@ -482,6 +500,7 @@ async function loadHistory() { if (currentIds !== lastHistoryIds) { lastHistoryIds = currentIds; bodyCache.clear(); + summaryCache.clear(); renderHistory(records); } @@ -521,25 +540,210 @@ async function handleCardClick(cardEl) { } async function toggleCardBody(id, cardEl) { - const existing = cardEl.querySelector(".card-body"); + const existing = cardEl.querySelector(".card-body, .card-tabs, .card-summary, .summarize-prompt"); const top = cardEl.getBoundingClientRect().top; if (existing) { - existing.remove(); + // Collapse — remove all expanded elements + cardEl.querySelectorAll(".card-body, .card-tabs, .card-summary, .summarize-prompt").forEach(el => el.remove()); cardEl.classList.remove("expanded"); } else { const body = await getRecordBody(id); if (!body) return; - const div = document.createElement("div"); - div.className = "card-body"; - div.textContent = body; - div.addEventListener("click", (e) => e.stopPropagation()); - cardEl.appendChild(div); + + const hasSummary = cardEl.dataset.hasSummary === "true"; + let summaryData = null; + if (hasSummary) { + summaryData = await getRecordSummary(id); + } + + // Build tab bar + content + if (summaryData) { + const tabBar = buildTabBar(id, "summary"); + tabBar.addEventListener("click", (e) => e.stopPropagation()); + cardEl.appendChild(tabBar); + + const bodyDiv = document.createElement("div"); + bodyDiv.className = "card-body"; + bodyDiv.style.display = "none"; + bodyDiv.textContent = body; + bodyDiv.addEventListener("click", (e) => e.stopPropagation()); + cardEl.appendChild(bodyDiv); + + const summaryDiv = buildSummaryDiv(summaryData.summary); + cardEl.appendChild(summaryDiv); + } else { + const div = document.createElement("div"); + div.className = "card-body"; + div.textContent = body; + div.addEventListener("click", (e) => e.stopPropagation()); + cardEl.appendChild(div); + } + cardEl.classList.add("expanded"); } const shift = cardEl.getBoundingClientRect().top - top; if (shift) window.scrollBy(0, shift); } +function buildTabBar(id, activeTab) { + const bar = document.createElement("div"); + bar.className = "card-tabs"; + bar.innerHTML = ` + + + `; + bar.addEventListener("click", (e) => { + const tab = e.target.closest(".card-tab"); + if (!tab) return; + const card = bar.closest(".history-card"); + const tabName = tab.dataset.tab; + // Toggle active state + bar.querySelectorAll(".card-tab").forEach(t => t.classList.remove("active")); + tab.classList.add("active"); + // Show/hide content + const bodyEl = card.querySelector(".card-body"); + const summaryEl = card.querySelector(".card-summary"); + if (bodyEl) bodyEl.style.display = tabName === "transcript" ? "" : "none"; + if (summaryEl) summaryEl.style.display = tabName === "summary" ? "" : "none"; + }); + return bar; +} + +function buildSummaryDiv(summaryText) { + const div = document.createElement("div"); + div.className = "card-summary"; + div.addEventListener("click", (e) => e.stopPropagation()); + div.innerHTML = `
${escapeHtml(summaryText)}
`; + return div; +} + +async function getRecordSummary(id) { + if (summaryCache.has(id)) return summaryCache.get(id); + const res = await fetch(`/api/history/${id}/summary`); + if (!res.ok) return null; + const data = await res.json(); + summaryCache.set(id, data); + return data; +} + +function openSummarizePrompt(id) { + const card = document.querySelector(`.history-card[data-id="${id}"]`); + if (!card) return; + + // If prompt already open, close it + const existing = card.querySelector(".summarize-prompt"); + if (existing) { + existing.remove(); + return; + } + + // Ensure card is expanded + if (!card.classList.contains("expanded")) { + handleCardClick(card).then(() => addPromptUI(id, card)); + return; + } + addPromptUI(id, card); +} + +function addPromptUI(id, card) { + // Remove any existing prompt + const existing = card.querySelector(".summarize-prompt"); + if (existing) existing.remove(); + + const prompt = document.createElement("div"); + prompt.className = "summarize-prompt"; + prompt.addEventListener("click", (e) => e.stopPropagation()); + prompt.innerHTML = ` +
+ +
+ + +
+
+ `; + + // Insert after card-actions or at end of card-content + const actions = card.querySelector(".card-actions"); + if (actions) { + actions.after(prompt); + } else { + const content = card.querySelector(".card-content"); + content.after(prompt); + } +} + +async function generateSummary(id) { + const card = document.querySelector(`.history-card[data-id="${id}"]`); + if (!card) return; + + const textarea = card.querySelector(".summarize-input"); + const btn = card.querySelector(".summarize-generate-btn"); + const promptText = textarea ? textarea.value.trim() : ""; + + btn.disabled = true; + btn.textContent = "Generating..."; + + try { + const res = await fetch(`${apiPrefix}/history/${id}/summarize`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ prompt: promptText }), + }); + + if (!res.ok) { + const data = await res.json(); + throw new Error(data.error || "Summarization failed"); + } + + const data = await res.json(); + summaryCache.set(id, data); + card.dataset.hasSummary = "true"; + + // Remove prompt UI + const promptEl = card.querySelector(".summarize-prompt"); + if (promptEl) promptEl.remove(); + + // Mark the quick-summarize button as having a summary + const sumBtn = card.querySelector(".quick-summarize"); + if (sumBtn) sumBtn.classList.add("has-summary"); + + // Rebuild tab bar and summary display + const oldTabs = card.querySelector(".card-tabs"); + const oldSummary = card.querySelector(".card-summary"); + const oldBody = card.querySelector(".card-body"); + + if (oldTabs) oldTabs.remove(); + if (oldSummary) oldSummary.remove(); + + // Add tabs if not present + const tabBar = buildTabBar(id, "summary"); + tabBar.addEventListener("click", (e) => e.stopPropagation()); + if (oldBody) { + oldBody.before(tabBar); + oldBody.style.display = "none"; + } else { + card.appendChild(tabBar); + } + + const summaryDiv = buildSummaryDiv(data.summary); + if (oldBody) { + oldBody.after(summaryDiv); + } else { + card.appendChild(summaryDiv); + } + + showToast("Summary generated"); + } catch (err) { + showToast(err.message); + btn.disabled = false; + btn.textContent = "Generate"; + } +} + async function revealInFinder(id) { await fetch(`/api/history/${id}/reveal`, { method: "POST" }); } diff --git a/app/static/style.css b/app/static/style.css index dfc1553..3f56507 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -90,16 +90,12 @@ body { display: flex; flex-direction: column; min-height: 100vh; + justify-content: flex-start; + padding-top: 64px; } .app[data-state="idle"] { - justify-content: center; - padding-bottom: 20vh; -} - -.app[data-state="active"] { - justify-content: flex-start; - padding-top: 64px; + padding-top: 15vh; } .hero { @@ -615,7 +611,7 @@ button:active:not(:disabled) { .history-card.expanded .quick-copy { opacity: 1; - align-self: flex-end; + align-self: flex-start; } /* Card actions — hidden by default, shown when expanded or error */ @@ -625,6 +621,7 @@ button:active:not(:disabled) { flex-wrap: wrap; gap: 4px; margin-top: 6px; + margin-bottom: 6px; } .history-card.expanded .card-actions { @@ -635,6 +632,10 @@ button:active:not(:disabled) { display: flex; } +.history-card .card-actions:has(+ .summarize-prompt) { + margin-bottom: 16px; +} + .history-card .card-actions button { display: flex; align-items: center; @@ -666,6 +667,46 @@ button:active:not(:disabled) { color: var(--red); } +/* Quick summarize — header-level star button next to copy */ + +.history-card .quick-summarize { + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + padding: 4px 10px; + font-size: 0.75rem; + font-weight: 500; + line-height: 1; + color: var(--text-muted); + background: var(--surface-hover); + border: 1px solid var(--border); + border-radius: 6px; + cursor: pointer; + flex-shrink: 0; + align-self: center; + opacity: 0; + transition: opacity 200ms ease, border-color 200ms ease, background 200ms ease, align-self 200ms ease; +} + +.history-card .quick-summarize .unicorn-icon { + font-size: 0.95rem; +} + +.history-card:hover .quick-summarize { + opacity: 1; +} + +.history-card .quick-summarize:hover { + border-color: rgba(108, 142, 239, 0.3); + background: var(--blue-dim); +} + +.history-card.expanded .quick-summarize { + opacity: 1; + align-self: flex-start; +} + /* ─── Expandable card body ─── */ .history-card.expandable { @@ -705,6 +746,174 @@ button:active:not(:disabled) { background: rgba(255, 255, 255, 0.2); } +/* ─── Card tabs ─── */ + +.card-tabs { + width: 100%; + display: flex; + gap: 0; + padding: 0 16px; + border-bottom: 1px solid var(--border); +} + +.card-tab:first-child { + padding-left: 0; +} + +.card-tab { + padding: 8px 8px; + margin-bottom: -1px; + font-size: 0.68rem; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + background: none; + color: var(--text-dim); + border: none; + border-bottom: 2px solid transparent; + border-radius: 0; + cursor: pointer; + transition: color 200ms ease, border-color 200ms ease; +} + +.card-tab:hover { + color: var(--text-muted); +} + +.card-tab.active { + color: var(--blue); + border-bottom-color: var(--blue); +} + +.card-tabs ~ .card-body, +.card-tabs ~ .card-summary { + border-top: none; +} + +/* ─── Summarize prompt ─── */ + +.summarize-prompt { + width: 100%; + padding: 0; +} + +.summarize-bar { + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px 12px 10px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + transition: border-color 300ms ease, box-shadow 300ms ease; +} + +.summarize-bar:focus-within { + border-color: rgba(108, 142, 239, 0.4); + box-shadow: 0 0 0 3px rgba(108, 142, 239, 0.1); +} + +.summarize-input { + width: 100%; + padding: 0; + font-family: var(--font); + font-size: 0.825rem; + line-height: 1.5; + background: transparent; + color: var(--text); + border: none; + outline: none; + resize: none; +} + +.summarize-input::placeholder { + color: var(--text-dim); +} + +.summarize-bar-actions { + display: flex; + justify-content: flex-end; + gap: 6px; +} + +.summarize-generate-btn { + display: flex; + align-items: center; + gap: 6px; + padding: 7px 14px; + font-size: 0.78rem; + font-weight: 500; + background: var(--blue); + color: #fff; + border: 1px solid var(--blue); + border-radius: var(--radius); + flex-shrink: 0; +} + +.summarize-generate-btn svg { + width: 14px; + height: 14px; +} + +.summarize-generate-btn:hover:not(:disabled) { + background: #5a7de0; +} + +.summarize-generate-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.summarize-cancel-btn { + padding: 7px 14px; + font-size: 0.78rem; + font-weight: 500; + background: var(--surface-hover); + color: var(--text-muted); + border: 1px solid var(--border); + border-radius: var(--radius); + flex-shrink: 0; +} + +.summarize-cancel-btn:hover { + color: var(--text); + border-color: var(--border-hover); +} + +/* ─── Card summary ─── */ + +.card-summary { + width: 100%; + padding: 12px 16px; + border-top: 1px solid var(--border); + max-height: 300px; + overflow-y: auto; +} + +.card-summary::-webkit-scrollbar { + width: 6px; +} + +.card-summary::-webkit-scrollbar-track { + background: transparent; +} + +.card-summary::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.1); + border-radius: 3px; +} + +.card-summary::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.2); +} + +.card-summary-text { + font-size: 0.85rem; + line-height: 1.7; + color: var(--text-muted); + white-space: pre-wrap; +} + /* ─── Toast ─── */ .toast { @@ -794,12 +1003,12 @@ button:active:not(:disabled) { padding: 0 12px; } - .app[data-state="idle"] { - padding-bottom: 10vh; + .app { + padding-top: 32px; } - .app[data-state="active"] { - padding-top: 32px; + .app[data-state="idle"] { + padding-top: 20vh; } .logo { diff --git a/app/summarizer.py b/app/summarizer.py new file mode 100644 index 0000000..de1b22e --- /dev/null +++ b/app/summarizer.py @@ -0,0 +1,33 @@ +import logging + +from openai import AsyncOpenAI + +from app.config import settings + +logger = logging.getLogger(__name__) +client = AsyncOpenAI(api_key=settings.openai_api_key) + +SYSTEM_MESSAGE = ( + "You are a helpful assistant that summarizes transcripts. " + "Produce clear, well-structured summaries. Use bullet points for key points when appropriate. " + "Be concise but capture all important information." +) + + +async def summarize_text(text: str, prompt: str = "") -> str: + """Summarize transcript text using OpenAI Chat Completions.""" + prompt = prompt.strip() + user_content = f"{prompt}\n\n---\n\n{text}" + + logger.info("Summarizing %d chars with model %s", len(text), settings.summarize_model) + response = await client.chat.completions.create( + model=settings.summarize_model, + messages=[ + {"role": "system", "content": SYSTEM_MESSAGE}, + {"role": "user", "content": user_content}, + ], + temperature=0.3, + ) + summary = response.choices[0].message.content.strip() + logger.info("Summary generated: %d chars", len(summary)) + return summary diff --git a/docs/banner.svg b/docs/banner.svg new file mode 100644 index 0000000..d9fc179 --- /dev/null +++ b/docs/banner.svg @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + Transcript Maker + + Paste a YouTube URL, get a transcript + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pyproject.toml b/pyproject.toml index 724c5c2..8fdaaa5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,7 +2,9 @@ name = "transcript-maker" version = "0.1.0" description = "Paste a YouTube URL, get a transcript." -authors = [] +authors = ["dmitry-kostin"] +license = "MIT" +repository = "https://github.com/dmitry-kostin/transcript-maker" package-mode = false [tool.poetry.dependencies] diff --git a/tests/test_api_endpoints.py b/tests/test_api_endpoints.py index b1b6bfb..12cbe7f 100644 --- a/tests/test_api_endpoints.py +++ b/tests/test_api_endpoints.py @@ -4,7 +4,7 @@ from fastapi.testclient import TestClient from app.main import create_app -from app.history import create_record, complete_record, RESULTS_DIR +from app.history import create_record, complete_record, get_summary, RESULTS_DIR from app.transcriber import prepare_chunks, MAX_CHUNK_DURATION_SECONDS import app.history as history_mod @@ -125,6 +125,68 @@ def test_in_progress_blocked(self, client, tmp_path, monkeypatch): assert res.status_code == 409 +class TestSummarizeEndpoint: + def test_invalid_id(self, client): + res = client.post("/api/history/ZZZZZZZZ/summarize", json={"prompt": ""}) + assert res.status_code == 400 + + def test_not_done_record(self, client, tmp_path, monkeypatch): + results_dir = tmp_path / "results" + results_dir.mkdir(exist_ok=True) + monkeypatch.setattr(history_mod, "RESULTS_DIR", results_dir) + rid = create_record("Test", "https://youtube.com/watch?v=abc", 60) + res = client.post(f"/api/history/{rid}/summarize", json={"prompt": ""}) + assert res.status_code == 400 + + def test_successful_summarize(self, client, tmp_path, monkeypatch): + results_dir = tmp_path / "results" + results_dir.mkdir(exist_ok=True) + monkeypatch.setattr(history_mod, "RESULTS_DIR", results_dir) + rid = create_record("Test", "https://youtube.com/watch?v=abc", 60) + complete_record(rid, "This is a test transcript with multiple words.") + with patch("app.api.summarize_text", return_value="Mocked summary"): + res = client.post(f"/api/history/{rid}/summarize", json={"prompt": "Custom"}) + assert res.status_code == 200 + data = res.json() + assert data["summary"] == "Test\n\nMocked summary" + # Verify it was saved + saved = get_summary(rid) + assert saved is not None + assert saved["summary"] == "Test\n\nMocked summary" + + def test_get_summary(self, client, tmp_path, monkeypatch): + results_dir = tmp_path / "results" + results_dir.mkdir(exist_ok=True) + monkeypatch.setattr(history_mod, "RESULTS_DIR", results_dir) + rid = create_record("Test", "https://youtube.com/watch?v=abc", 60) + complete_record(rid, "Transcript text") + with patch("app.api.summarize_text", return_value="The summary"): + client.post(f"/api/history/{rid}/summarize", json={"prompt": ""}) + res = client.get(f"/api/history/{rid}/summary") + assert res.status_code == 200 + assert res.json()["summary"] == "Test\n\nThe summary" + + def test_get_summary_404(self, client, tmp_path, monkeypatch): + results_dir = tmp_path / "results" + results_dir.mkdir(exist_ok=True) + monkeypatch.setattr(history_mod, "RESULTS_DIR", results_dir) + rid = create_record("Test", "https://youtube.com/watch?v=abc", 60) + complete_record(rid, "Text") + res = client.get(f"/api/history/{rid}/summary") + assert res.status_code == 404 + + def test_demo_summarize(self, client, tmp_path, monkeypatch): + results_dir = tmp_path / "results" + results_dir.mkdir(exist_ok=True) + monkeypatch.setattr(history_mod, "RESULTS_DIR", results_dir) + rid = create_record("Test", "https://youtube.com/watch?v=abc", 60) + complete_record(rid, "Transcript text") + res = client.post(f"/api/demo/history/{rid}/summarize", json={"prompt": ""}) + assert res.status_code == 200 + data = res.json() + assert "Key Points" in data["summary"] + + class TestPrepareChunks: def test_small_file_short_duration_no_chunking(self, tmp_path): """File under size AND duration limits → no chunking.""" diff --git a/tests/test_history.py b/tests/test_history.py index d33b5a0..076da45 100644 --- a/tests/test_history.py +++ b/tests/test_history.py @@ -6,6 +6,7 @@ delete_record, get_result_path, cleanup_stale_records, save_audio, get_audio_path, + save_summary, get_summary, delete_summary, ) @@ -271,3 +272,77 @@ def test_delete_removes_audio(self, tmp_results, tmp_path): delete_record(rid) assert not cached.exists() assert get_audio_path(rid) is None + + +class TestSummaryCRUD: + def test_save_and_get(self, tmp_results): + rid = create_record("Vid", "https://youtube.com/watch?v=abc", 60) + complete_record(rid, "Some transcript text") + assert save_summary(rid, "This is the summary", "Custom prompt") is True + result = get_summary(rid) + assert result is not None + assert result["summary"] == "This is the summary" + assert result["prompt"] == "Custom prompt" + assert result["created_at"] != "" + + def test_get_nonexistent(self, tmp_results): + rid = create_record("Vid", "https://youtube.com/watch?v=abc", 60) + assert get_summary(rid) is None + + def test_get_invalid_id(self, tmp_results): + assert get_summary("not-hex!") is None + + def test_save_for_nonexistent_record(self, tmp_results): + assert save_summary("00000000", "Summary text") is False + + def test_delete_summary(self, tmp_results): + rid = create_record("Vid", "https://youtube.com/watch?v=abc", 60) + complete_record(rid, "Some text") + save_summary(rid, "Summary") + assert get_summary(rid) is not None + delete_summary(rid) + assert get_summary(rid) is None + + def test_delete_record_removes_summary(self, tmp_results): + rid = create_record("Vid", "https://youtube.com/watch?v=abc", 60) + complete_record(rid, "Some text") + save_summary(rid, "Summary") + assert get_summary(rid) is not None + delete_record(rid) + assert get_summary(rid) is None + + def test_overwrite_summary(self, tmp_results): + rid = create_record("Vid", "https://youtube.com/watch?v=abc", 60) + complete_record(rid, "Some text") + save_summary(rid, "First summary", "Prompt 1") + save_summary(rid, "Second summary", "Prompt 2") + result = get_summary(rid) + assert result["summary"] == "Second summary" + assert result["prompt"] == "Prompt 2" + + def test_summary_not_in_history_glob(self, tmp_results): + rid = create_record("Vid", "https://youtube.com/watch?v=abc", 60) + complete_record(rid, "Some text") + save_summary(rid, "Summary") + records = get_history() + # Only the real record should appear, not the summary sidecar + assert len(records) == 1 + assert records[0]["id"] == rid + + def test_has_summary_flag_in_history(self, tmp_results): + rid = create_record("Vid", "https://youtube.com/watch?v=abc", 60) + complete_record(rid, "Some text") + records = get_history() + assert records[0]["has_summary"] is False + save_summary(rid, "Summary") + records = get_history() + assert records[0]["has_summary"] is True + + def test_has_summary_flag_in_get_record(self, tmp_results): + rid = create_record("Vid", "https://youtube.com/watch?v=abc", 60) + complete_record(rid, "Some text") + record = get_record(rid) + assert record["has_summary"] is False + save_summary(rid, "Summary") + record = get_record(rid) + assert record["has_summary"] is True From 5435a16ef4664e4b842f766b0ccd6d4635121b44 Mon Sep 17 00:00:00 2001 From: dmitry-kostin Date: Thu, 19 Feb 2026 23:22:09 +0100 Subject: [PATCH 2/5] Update page subtitle to mention AI summarization Co-Authored-By: Claude Opus 4.6 --- app/static/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/static/index.html b/app/static/index.html index 58a6a4b..54f6a4d 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -30,7 +30,7 @@

Transcript Maker

-

Paste a YouTube URL and get a transcript

+

Paste a YouTube URL, get a transcript and summarize with AI

From b21933b0b69d2628132658f6d9952a0bc8a2edb5 Mon Sep 17 00:00:00 2001 From: dmitry-kostin Date: Thu, 19 Feb 2026 23:22:36 +0100 Subject: [PATCH 3/5] Add lollipop icon to page header Co-Authored-By: Claude Opus 4.6 --- app/static/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/static/index.html b/app/static/index.html index 54f6a4d..a171287 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -29,7 +29,7 @@
-

Transcript Maker

+

🍭 Transcript Maker

Paste a YouTube URL, get a transcript and summarize with AI

From 0307f87c1e8199aa0c1e4669ee67fe1725412c9c Mon Sep 17 00:00:00 2001 From: dmitry-kostin Date: Thu, 19 Feb 2026 23:23:25 +0100 Subject: [PATCH 4/5] Replace emoji with custom SVG lollipop icon in header Co-Authored-By: Claude Opus 4.6 --- app/static/index.html | 2 +- app/static/style.css | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/app/static/index.html b/app/static/index.html index a171287..fd15cb5 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -29,7 +29,7 @@
-

🍭 Transcript Maker

+

Transcript Maker

Paste a YouTube URL, get a transcript and summarize with AI

diff --git a/app/static/style.css b/app/static/style.css index 3f56507..23d88e0 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -105,6 +105,9 @@ body { /* ─── Logo ─── */ .logo { + display: flex; + align-items: center; + gap: 8px; font-size: 1.75rem; font-weight: 700; letter-spacing: -0.03em; @@ -114,6 +117,13 @@ body { background-clip: text; } +.logo-icon { + width: 32px; + height: 32px; + flex-shrink: 0; + -webkit-text-fill-color: initial; +} + .subtitle { color: var(--text-muted); font-size: 0.925rem; From d255053117be37ace81c1e649450852fd00f81b6 Mon Sep 17 00:00:00 2001 From: dmitry-kostin Date: Thu, 19 Feb 2026 23:38:27 +0100 Subject: [PATCH 5/5] Redesign header with swirl lollipop icon and green transcribe button - Custom SVG lollipop with 8-segment pinwheel swirl (red, blue, lime, white) - Icon placed left of title + subtitle as a group - Transcribe button uses lollipop lime green - Command-bar focus glow matches new color scheme Co-Authored-By: Claude Opus 4.6 --- app/static/index.html | 9 +++++++-- app/static/style.css | 44 +++++++++++++++++++++++++++---------------- 2 files changed, 35 insertions(+), 18 deletions(-) diff --git a/app/static/index.html b/app/static/index.html index fd15cb5..fc8384e 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -29,8 +29,13 @@
-

Transcript Maker

-

Paste a YouTube URL, get a transcript and summarize with AI

+
+ +
+

Transcript Maker

+

Paste a YouTube URL, get a transcript and summarize with AI

+
+
diff --git a/app/static/style.css b/app/static/style.css index 23d88e0..c899c2b 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -104,10 +104,26 @@ body { /* ─── Logo ─── */ -.logo { +.logo-group { display: flex; align-items: center; - gap: 8px; + gap: 12px; + margin-left: -12px; + margin-bottom: 24px; +} + +.logo-icon { + height: 72px; + width: auto; + flex-shrink: 0; +} + +.logo-text { + display: flex; + flex-direction: column; +} + +.logo { font-size: 1.75rem; font-weight: 700; letter-spacing: -0.03em; @@ -117,18 +133,10 @@ body { background-clip: text; } -.logo-icon { - width: 32px; - height: 32px; - flex-shrink: 0; - -webkit-text-fill-color: initial; -} - .subtitle { color: var(--text-muted); font-size: 0.925rem; - margin-top: 6px; - margin-bottom: 24px; + margin-top: 2px; } /* ─── Command bar ─── */ @@ -137,7 +145,7 @@ body { display: flex; align-items: center; gap: 8px; - padding: 6px 6px 6px 16px; + padding: 6px 10px 6px 16px; background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); @@ -145,8 +153,8 @@ body { } .command-bar:focus-within { - border-color: var(--border-focus); - box-shadow: 0 0 0 3px var(--amber-dim), 0 8px 32px rgba(0, 0, 0, 0.3); + border-color: rgba(160, 184, 32, 0.4); + box-shadow: 0 0 0 3px rgba(160, 184, 32, 0.15), 0 8px 32px rgba(0, 0, 0, 0.3); transform: translateY(-1px); } @@ -272,7 +280,7 @@ button:active:not(:disabled) { align-items: center; gap: 6px; padding: 8px 18px; - background: var(--amber); + background: #a0b820; color: #131318; flex-shrink: 0; } @@ -283,7 +291,7 @@ button:active:not(:disabled) { } #transcribe-btn:hover:not(:disabled) { - background: #d4943a; + background: #8ea016; } #transcribe-btn:disabled { @@ -1025,6 +1033,10 @@ button:active:not(:disabled) { font-size: 1.375rem; } + .logo-icon { + height: 44px; + } + .command-bar { flex-wrap: wrap; padding: 6px;