From b237cf68425c74a11b10fb06005e68ab0591a53c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 29 Nov 2025 17:37:06 +0000 Subject: [PATCH 1/3] Initial plan From 88733e96ba34433371bcc5d6f1eb9173df3db9b5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 29 Nov 2025 17:44:24 +0000 Subject: [PATCH 2/3] Add portfolio documentation, demo, smoke tests, and CI workflow Co-authored-by: edgarbc <4164895+edgarbc@users.noreply.github.com> --- .github/workflows/ci.yml | 38 ++++ README.md | 142 ++++++++++++--- demo/run_summary_demo.py | 365 ++++++++++++++++++++++++++++++++++++++ demo/sample_note.md | 54 ++++++ docs/SCREENSHOT_README.md | 23 +++ requirements.txt | 40 +++++ tests/smoke_test.py | 157 ++++++++++++++++ 7 files changed, 795 insertions(+), 24 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 demo/run_summary_demo.py create mode 100644 demo/sample_note.md create mode 100644 docs/SCREENSHOT_README.md create mode 100644 requirements.txt create mode 100644 tests/smoke_test.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..42c45ab --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: CI + +on: + push: + branches: [main, portfolio/*] + pull_request: + branches: [main] + +jobs: + smoke-test: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: 'pip' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Download NLTK data + run: | + python -c "import nltk; nltk.download('punkt', quiet=True); nltk.download('punkt_tab', quiet=True)" + + - name: Run smoke tests + run: | + python -m pytest tests/smoke_test.py -v + + - name: Run demo script + run: | + python demo/run_summary_demo.py diff --git a/README.md b/README.md index 5002a5c..2f9c861 100644 --- a/README.md +++ b/README.md @@ -1,42 +1,54 @@ # Arrowhead 🏹 -[![Python](https://img.shields.io/badge/Python-3.8+-blue.svg)](https://python.org) +[![Python](https://img.shields.io/badge/Python-3.12+-blue.svg)](https://python.org) [![UV](https://img.shields.io/badge/UV-Fast%20Python%20Package%20Manager-orange.svg)](https://docs.astral.sh/uv/) [![Ollama](https://img.shields.io/badge/Ollama-Local%20LLMs-green.svg)](https://ollama.ai) -[![DSPy](https://img.shields.io/badge/DSPy-Declarative%20LLM%20Programming-purple.svg)](https://github.com/stanfordnlp/dspy-ai) +[![CI](https://github.com/edgarbc/arrowhead/actions/workflows/ci.yml/badge.svg)](https://github.com/edgarbc/arrowhead/actions/workflows/ci.yml) [![License](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -[![Tests](https://img.shields.io/badge/Tests-Passing-brightgreen.svg)](https://github.com/yourusername/arrowhead/actions) -> **Obsidian Weekly Hashtag Summarizer** - Automate weekly retrospectives by summarizing journal entries tagged with specific hashtags using local LLMs. +> **TL;DR:** Arrowhead is an LLM-powered CLI tool that automatically summarizes your Obsidian journal entries by hashtag. Point it at your vault, specify a hashtag like `#meeting` or `#work`, and get weekly summaries powered by local LLMs (Ollama) or cloud APIs (OpenAI). + +--- + +## 🎯 Problem Statement + +Keeping weekly retrospectives and summaries up-to-date is tedious. If you use Obsidian for daily journaling with hashtags to categorize entries (e.g., `#meeting`, `#work`, `#learning`), you probably spend time manually reviewing and consolidating notes at the end of each week. + +**Arrowhead solves this** by automatically scanning your vault, filtering entries by hashtag and date range, and generating well-structured summaries using LLMsβ€”all while keeping your data private with local models. ## 🎯 Overview Arrowhead is a CLI tool that automates the repetitive task of creating weekly summaries from your Obsidian vault. It scans your journal entries, filters by hashtags and date ranges, and generates consolidated summaries using local LLMs via Ollama. +![Arrowhead Demo](docs/screenshot-placeholder.png) + + ### ✨ Features - **πŸ” Smart Vault Scanning** - Discovers markdown files while excluding Obsidian-specific directories -- **��️ Hashtag Filtering** - Filter entries by specific hashtags (e.g., `#meeting`, `#work`) +- **🏷️ Hashtag Filtering** - Filter entries by specific hashtags (e.g., `#meeting`, `#work`) - **πŸ“… Date Range Support** - Focus on specific weeks or date ranges - **πŸ€– Local LLM Integration** - Uses Ollama for cost-effective, privacy-focused summarization +- **☁️ Cloud API Support** - Optional OpenAI integration for cloud-based summarization - **πŸ“¦ Intelligent Batching** - Groups entries efficiently to respect token limits - **πŸ“ Structured Output** - Generates well-formatted summaries with metadata -- **πŸ’» Chat with your notes** - Chat with your summaries using retrieval-augmented generation. +- **πŸ’» Chat with your notes** - Chat with your summaries using retrieval-augmented generation - **⚑ Fast & Lightweight** - Built with UV for rapid development and deployment +- **πŸ§ͺ CI-Ready Demo** - Includes deterministic offline summarizer for testing ## πŸš€ Quick Start ### Prerequisites -- **Python 3.8+** -- **UV** (Fast Python package manager) -- **Ollama** (Local LLM runtime) +- **Python 3.12+** +- **UV** (Fast Python package manager) - [Install UV](https://docs.astral.sh/uv/getting-started/installation/) +- **Ollama** (Local LLM runtime) - [Install Ollama](https://ollama.ai) ### Installation ```bash # Clone the repository -git clone https://github.com/yourusername/arrowhead.git +git clone https://github.com/edgarbc/arrowhead.git cd arrowhead # Install dependencies with UV @@ -46,14 +58,90 @@ uv sync uv pip install -e . ``` +### Configure Local LLM (Ollama) + +```bash +# Install Ollama (macOS) +brew install ollama + +# Or download from https://ollama.ai + +# Start Ollama service +ollama serve + +# Pull a model (llama2 recommended for summarization) +ollama pull llama2 + +# Verify it's working +ollama list +``` + +### Environment Variables + +```bash +# Optional: Configure Ollama endpoint (default: http://localhost:11434) +export OLLAMA_HOST="http://localhost:11434" +export OLLAMA_MODEL="llama2" + +# Optional: For OpenAI integration +export OPENAI_API_KEY="your-api-key-here" +export OPENAI_MODEL="gpt-3.5-turbo" +``` + +### Run the Demo (No LLM Required) + +```bash +# Install demo dependencies +pip install -r requirements.txt + +# Download NLTK data +python -c "import nltk; nltk.download('punkt'); nltk.download('punkt_tab')" + +# Run the demo (uses deterministic TextRank summarizer) +python demo/run_summary_demo.py +``` + +**Expected Output:** +``` +============================================================ +Arrowhead Demo - Obsidian Note Summarizer +============================================================ + +πŸ“„ Reading sample note... + Loaded 1758 characters from sample_note.md + +------------------------------------------------------------ +πŸ“ Original Note Preview (first 500 chars): +------------------------------------------------------------ +# Daily Journal - 2024-12-02 + +## Morning Standup #meeting +... + +------------------------------------------------------------ +🎯 Generating Summary (using TextRank algorithm)... +------------------------------------------------------------ + +πŸ“‹ Summary: + +Had a productive morning standup with the team. +The team agreed that we need to prioritize the dashboard work... +Key takeaway: Understanding trade-offs between consistency... + +============================================================ +βœ… Demo completed successfully! +============================================================ +``` + ### Testing -The project both unit tests and integration tests. Integration tests require a local Ollama instance running. + +The project has both unit tests and integration tests. Integration tests require a local Ollama instance running. ```bash -# Run all unit tests (no external dependencies) -uv run pytest tests/ -v +# Run smoke tests (no external dependencies) +python -m pytest tests/smoke_test.py -v -# Run only unit tests (excludes integration tests) +# Run all unit tests (no external dependencies) uv run pytest tests/ -v -m "not integration" # Run integration tests (requires Ollama) @@ -100,8 +188,8 @@ arrowhead scan /path/to/vault --hashtag meeting ```bash arrowhead/ β”œβ”€β”€ README.md # Project overview and setup instructions -β”œβ”€β”€ pyproject.toml # Dependency management (or setup.py) -β”œβ”€β”€ src/ +β”œβ”€β”€ pyproject.toml # Dependency management +β”œβ”€β”€ requirements.txt # Demo/CI dependencies β”œβ”€β”€ src/ β”‚ └── arrowhead/ β”‚ β”œβ”€β”€ __init__.py # Package initialization @@ -113,23 +201,29 @@ arrowhead/ β”‚ β”œβ”€β”€ writer.py # Summary aggregation and note writing β”‚ β”œβ”€β”€ utils.py # Helper functions (date parsing, logging) β”‚ └── rag.py # RAG system for chatting with summaries +β”œβ”€β”€ demo/ # Demo files for portfolio/CI +β”‚ β”œβ”€β”€ sample_note.md # Sample Obsidian note for demo +β”‚ └── run_summary_demo.py # Deterministic demo summarizer β”œβ”€β”€ tests/ # Unit and integration tests +β”‚ β”œβ”€β”€ conftest.py # Pytest configuration +β”‚ β”œβ”€β”€ smoke_test.py # CI smoke tests β”‚ β”œβ”€β”€ test_scanner.py -β”‚ β”œβ”€β”€ test_parser.py β”‚ β”œβ”€β”€ test_batcher.py β”‚ β”œβ”€β”€ test_summarizer.py -β”‚ └── test_writer.py +β”‚ └── ... β”œβ”€β”€ examples/ # Sample vault and usage examples β”‚ └── journal/ -β”‚ β”œβ”€β”€ 2024-12-02.md # Example journal entry markdown file -β”‚ └── 2024-12-03.md -β”œβ”€β”€ Summaries/ # Output folder for generated summaries -β”œβ”€β”€ docs/ # Additional documentation -β”‚ └── usage.md # Usage guide and FAQs +β”‚ └── 2024-12-02.md # Example journal entry +β”œβ”€β”€ .github/ +β”‚ └── workflows/ +β”‚ └── ci.yml # GitHub Actions CI workflow └── .gitignore # Ignore venv, __pycache__, etc. - ``` +## πŸ“„ License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + --- **Made with ❀️ for the Obsidian community** \ No newline at end of file diff --git a/demo/run_summary_demo.py b/demo/run_summary_demo.py new file mode 100644 index 0000000..0e1503c --- /dev/null +++ b/demo/run_summary_demo.py @@ -0,0 +1,365 @@ +#!/usr/bin/env python3 +""" +Arrowhead Demo Script - Lightweight Summarizer + +This script demonstrates the summarization capability of Arrowhead using a +deterministic heuristic approach (TextRank-based extractive summarization). +It does NOT require external LLM calls, making it suitable for CI/CD pipelines. + +For production usage with LLMs, see the comments marked with "LLM INTEGRATION" +below to learn how to integrate with Ollama or OpenAI APIs. + +Usage: + python demo/run_summary_demo.py + +Requirements: + pip install nltk networkx + +Optional (for enhanced summarization): + pip install sumy +""" + +import os +import sys +from pathlib import Path + +# Add the src directory to the path for imports +src_path = Path(__file__).parent.parent / "src" +if src_path.exists(): + sys.path.insert(0, str(src_path)) + + +def get_sample_note_path() -> Path: + """Return the path to the sample note file.""" + return Path(__file__).parent / "sample_note.md" + + +def read_sample_note() -> str: + """Read and return the content of the sample note.""" + note_path = get_sample_note_path() + if not note_path.exists(): + raise FileNotFoundError(f"Sample note not found: {note_path}") + return note_path.read_text(encoding="utf-8") + + +def simple_sentence_tokenize(text: str) -> list[str]: + """ + Simple sentence tokenizer that splits on common sentence boundaries. + Falls back to basic splitting if NLTK is not available. + """ + try: + import nltk + try: + return nltk.sent_tokenize(text) + except LookupError: + # Download punkt data if not available + nltk.download("punkt", quiet=True) + nltk.download("punkt_tab", quiet=True) + return nltk.sent_tokenize(text) + except ImportError: + # Fallback: simple split on sentence-ending punctuation + import re + sentences = re.split(r'(?<=[.!?])\s+', text) + return [s.strip() for s in sentences if s.strip()] + + +def simple_word_tokenize(text: str) -> list[str]: + """ + Simple word tokenizer. + Falls back to basic splitting if NLTK is not available. + """ + try: + import nltk + try: + return nltk.word_tokenize(text.lower()) + except LookupError: + nltk.download("punkt", quiet=True) + nltk.download("punkt_tab", quiet=True) + return nltk.word_tokenize(text.lower()) + except ImportError: + # Fallback: simple split on whitespace and punctuation + import re + words = re.findall(r'\b\w+\b', text.lower()) + return words + + +def calculate_sentence_similarity(sent1: str, sent2: str) -> float: + """ + Calculate similarity between two sentences using word overlap. + Returns a value between 0 and 1. + """ + words1 = set(simple_word_tokenize(sent1)) + words2 = set(simple_word_tokenize(sent2)) + + if not words1 or not words2: + return 0.0 + + intersection = words1.intersection(words2) + # Normalized overlap (Jaccard-like similarity) + return len(intersection) / (len(words1) + len(words2) - len(intersection)) + + +def textrank_summarize(text: str, num_sentences: int = 5) -> str: + """ + Extractive summarization using TextRank algorithm. + + This is a deterministic, offline approach that doesn't require external APIs. + It identifies the most important sentences based on their similarity to other + sentences in the text (graph-based ranking). + + Args: + text: The input text to summarize. + num_sentences: Number of sentences to include in the summary. + + Returns: + A summary consisting of the top-ranked sentences. + """ + # Get sentences + sentences = simple_sentence_tokenize(text) + + if len(sentences) <= num_sentences: + return text + + # Filter out very short sentences (headers, list items without context) + valid_sentences = [s for s in sentences if len(s.split()) >= 5] + + if len(valid_sentences) <= num_sentences: + return "\n".join(valid_sentences[:num_sentences]) + + try: + import networkx as nx + + # Build similarity matrix + n = len(valid_sentences) + similarity_matrix = [[0.0] * n for _ in range(n)] + + for i in range(n): + for j in range(n): + if i != j: + similarity_matrix[i][j] = calculate_sentence_similarity( + valid_sentences[i], valid_sentences[j] + ) + + # Create graph and run PageRank + graph = nx.from_numpy_array( + __import__("numpy").array(similarity_matrix) + ) + scores = nx.pagerank(graph) + + # Rank sentences by score + ranked_sentences = sorted( + [(score, idx, sent) for idx, (sent, score) in enumerate( + zip(valid_sentences, [scores[i] for i in range(n)]) + )], + key=lambda x: x[0], + reverse=True + ) + + # Get top sentences and sort by original order + top_sentences = sorted( + ranked_sentences[:num_sentences], + key=lambda x: x[1] # Sort by original index + ) + + return "\n".join(sent for _, _, sent in top_sentences) + + except ImportError: + # Fallback: use first N sentences heuristic + return fallback_summarize(text, num_sentences) + + +def fallback_summarize(text: str, num_sentences: int = 5) -> str: + """ + Fallback summarizer using simple heuristics. + + Extracts the first few sentences plus any sentences that contain + important keywords like "key", "important", "main", "summary", etc. + + Args: + text: The input text to summarize. + num_sentences: Number of sentences to include in the summary. + + Returns: + A summary consisting of selected sentences. + """ + sentences = simple_sentence_tokenize(text) + + if len(sentences) <= num_sentences: + return text + + # Filter out very short sentences + valid_sentences = [s for s in sentences if len(s.split()) >= 4] + + if not valid_sentences: + return "\n".join(sentences[:num_sentences]) + + # Keywords that indicate important sentences + important_keywords = { + "key", "important", "main", "summary", "conclusion", + "result", "decided", "agreed", "priority", "focus" + } + + # Score sentences + scored = [] + for idx, sent in enumerate(valid_sentences): + words = set(simple_word_tokenize(sent)) + keyword_score = len(words.intersection(important_keywords)) + position_score = 1.0 / (idx + 1) # Earlier sentences get higher scores + score = keyword_score + position_score + scored.append((score, idx, sent)) + + # Sort by score and take top N + scored.sort(key=lambda x: x[0], reverse=True) + top_sentences = sorted(scored[:num_sentences], key=lambda x: x[1]) + + return "\n".join(sent for _, _, sent in top_sentences) + + +def summarize_with_llm(text: str, model: str = "llama2") -> str: + """ + LLM INTEGRATION: Summarize text using a local LLM via Ollama. + + This function demonstrates how to integrate with Ollama for production usage. + Uncomment and configure to use real LLM summarization. + + Prerequisites: + 1. Install Ollama: https://ollama.ai + 2. Pull a model: ollama pull llama2 + 3. Ensure Ollama is running: ollama serve + + Environment Variables: + OLLAMA_HOST: Ollama API endpoint (default: http://localhost:11434) + OLLAMA_MODEL: Model to use (default: llama2) + + Args: + text: The input text to summarize. + model: The Ollama model to use. + + Returns: + The LLM-generated summary. + + Example: + >>> summary = summarize_with_llm("Your long text here...") + >>> print(summary) + """ + # LLM INTEGRATION: Uncomment the following code to enable Ollama integration + # + # import httpx + # + # ollama_host = os.environ.get("OLLAMA_HOST", "http://localhost:11434") + # model_name = os.environ.get("OLLAMA_MODEL", model) + # + # prompt = f'''Please summarize the following text concisely, + # highlighting the key points and main topics discussed: + # + # {text} + # + # Summary:''' + # + # response = httpx.post( + # f"{ollama_host}/api/generate", + # json={"model": model_name, "prompt": prompt, "stream": False}, + # timeout=120.0 + # ) + # response.raise_for_status() + # return response.json()["response"] + + # For CI/demo purposes, use the deterministic summarizer + return textrank_summarize(text) + + +def summarize_with_openai(text: str) -> str: + """ + LLM INTEGRATION: Summarize text using OpenAI API. + + This function demonstrates how to integrate with OpenAI for production usage. + Uncomment and configure to use OpenAI's GPT models. + + Environment Variables: + OPENAI_API_KEY: Your OpenAI API key (required) + OPENAI_MODEL: Model to use (default: gpt-3.5-turbo) + + Args: + text: The input text to summarize. + + Returns: + The LLM-generated summary. + + Example: + >>> os.environ["OPENAI_API_KEY"] = "your-api-key" + >>> summary = summarize_with_openai("Your long text here...") + >>> print(summary) + """ + # LLM INTEGRATION: Uncomment the following code to enable OpenAI integration + # + # from openai import OpenAI + # + # api_key = os.environ.get("OPENAI_API_KEY") + # if not api_key: + # raise ValueError("OPENAI_API_KEY environment variable is required") + # + # client = OpenAI(api_key=api_key) + # model = os.environ.get("OPENAI_MODEL", "gpt-3.5-turbo") + # + # response = client.chat.completions.create( + # model=model, + # messages=[ + # {"role": "system", "content": "You are a helpful assistant that summarizes text concisely."}, + # {"role": "user", "content": f"Please summarize the following text:\n\n{text}"} + # ], + # max_tokens=500 + # ) + # return response.choices[0].message.content + + # For CI/demo purposes, use the deterministic summarizer + return textrank_summarize(text) + + +def main() -> None: + """Main entry point for the demo script.""" + print("=" * 60) + print("Arrowhead Demo - Obsidian Note Summarizer") + print("=" * 60) + print() + + # Read the sample note + print("πŸ“„ Reading sample note...") + try: + note_content = read_sample_note() + print(f" Loaded {len(note_content)} characters from sample_note.md") + except FileNotFoundError as e: + print(f"❌ Error: {e}") + sys.exit(1) + + print() + print("-" * 60) + print("πŸ“ Original Note Preview (first 500 chars):") + print("-" * 60) + print(note_content[:500] + "..." if len(note_content) > 500 else note_content) + + print() + print("-" * 60) + print("🎯 Generating Summary (using TextRank algorithm)...") + print("-" * 60) + print() + + # Generate summary using deterministic method + summary = textrank_summarize(note_content, num_sentences=5) + + print("πŸ“‹ Summary:") + print() + print(summary) + print() + print("=" * 60) + print("βœ… Demo completed successfully!") + print() + print("πŸ’‘ To use with a real LLM, see the comments in run_summary_demo.py") + print(" for instructions on configuring Ollama or OpenAI integration.") + print("=" * 60) + + # Return the summary for testing purposes + return summary + + +if __name__ == "__main__": + main() diff --git a/demo/sample_note.md b/demo/sample_note.md new file mode 100644 index 0000000..5075bd2 --- /dev/null +++ b/demo/sample_note.md @@ -0,0 +1,54 @@ +# Daily Journal - 2024-12-02 + +## Morning Standup #meeting + +Had a productive morning standup with the team. We discussed the following key points: + +- Sprint progress is on track for the Q4 release +- Backend API optimization has reduced response times by 40% +- New authentication flow is ready for QA testing +- Documentation updates needed for the API changes + +## Project Planning #work + +Spent time reviewing the roadmap for next quarter. The main focus areas will be: + +1. Implementing the new dashboard features +2. Improving system performance and scalability +3. Enhancing user onboarding experience +4. Adding better analytics and reporting tools + +The team agreed that we need to prioritize the dashboard work since it's been requested by multiple customers. + +## Code Review Session #development + +Reviewed pull requests from team members: + +- PR #142: Fixed memory leak in the data processing pipeline +- PR #145: Added unit tests for the new authentication module +- PR #147: Refactored the notification service for better maintainability + +All PRs were approved after minor suggestions were addressed. + +## Learning Notes #education + +Continued learning about distributed systems: + +- Read chapter 5 of "Designing Data-Intensive Applications" +- Watched a talk on consensus algorithms (Raft vs Paxos) +- Experimented with Redis clustering in a test environment + +Key takeaway: Understanding trade-offs between consistency and availability is crucial for system design. + +## Tasks Completed + +- [x] Review Q4 roadmap +- [x] Update sprint board +- [x] Submit expense report +- [ ] Prepare presentation for Friday + +## Tomorrow's Priorities + +1. Finish the dashboard prototype +2. Meet with stakeholders about feature requirements +3. Review the new CI/CD pipeline configuration diff --git a/docs/SCREENSHOT_README.md b/docs/SCREENSHOT_README.md new file mode 100644 index 0000000..eb49b6d --- /dev/null +++ b/docs/SCREENSHOT_README.md @@ -0,0 +1,23 @@ +# Screenshot Placeholder + +This is a placeholder file. Replace `screenshot-placeholder.png` with an actual screenshot of the Arrowhead CLI in action. + +## Suggested Screenshot Content + +1. Run the arrowhead CLI with a sample vault +2. Capture the colorful Rich output showing: + - Vault scanning progress + - Hashtag filtering results + - Summary generation output + +## How to Create a Screenshot + +```bash +# Run the summarize command on your test vault +arrowhead summarize examples/ --hashtag meeting + +# Or run the demo script +python demo/run_summary_demo.py +``` + +Save the terminal output as `screenshot-placeholder.png` and commit. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d4739d6 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,40 @@ +# Arrowhead - Dependencies for Demo and Smoke Tests +# +# Core dependencies for the demo summarizer (deterministic, offline) +numpy>=1.20.0 +nltk>=3.8.0 +networkx>=2.6.0 + +# Optional: Enhanced summarization with sumy +# sumy provides additional summarization algorithms (LexRank, LSA, etc.) +# Uncomment if you want to experiment with other algorithms: +# sumy>=0.11.0 + +# Testing dependencies +pytest>=7.0.0 + +# ----------------------------------------------------------------------------- +# NLTK Data Setup (required for sentence tokenization) +# ----------------------------------------------------------------------------- +# After installing dependencies, download required NLTK data: +# +# python -c "import nltk; nltk.download('punkt'); nltk.download('punkt_tab')" +# +# This is handled automatically in CI via the workflow file. +# ----------------------------------------------------------------------------- + +# ----------------------------------------------------------------------------- +# Production Dependencies (for LLM integration) +# ----------------------------------------------------------------------------- +# The following are already in pyproject.toml and installed via `uv sync`: +# - httpx: For Ollama API calls +# - openai: For OpenAI API calls +# - typer: For CLI +# - rich: For pretty output +# - pendulum: For date handling +# +# To install all production dependencies: +# pip install -e . +# Or with uv: +# uv sync +# ----------------------------------------------------------------------------- diff --git a/tests/smoke_test.py b/tests/smoke_test.py new file mode 100644 index 0000000..7ae6594 --- /dev/null +++ b/tests/smoke_test.py @@ -0,0 +1,157 @@ +""" +Smoke test for the Arrowhead demo summarizer. + +This test verifies that the demo script can: +1. Successfully read the sample note +2. Generate a non-empty summary +3. Run without external LLM dependencies (deterministic mode) + +Run with: + pytest tests/smoke_test.py -v + +Or directly: + python -m pytest tests/smoke_test.py -v +""" + +import subprocess +import sys +from pathlib import Path + + +# Path to the demo directory +DEMO_DIR = Path(__file__).parent.parent / "demo" +DEMO_SCRIPT = DEMO_DIR / "run_summary_demo.py" +SAMPLE_NOTE = DEMO_DIR / "sample_note.md" + + +class TestSmokeTest: + """Smoke tests for the demo summarizer.""" + + def test_sample_note_exists(self): + """Test that the sample note file exists.""" + assert SAMPLE_NOTE.exists(), f"Sample note not found: {SAMPLE_NOTE}" + + def test_sample_note_has_content(self): + """Test that the sample note has content.""" + content = SAMPLE_NOTE.read_text() + assert len(content) > 100, "Sample note should have meaningful content" + assert "#" in content, "Sample note should contain hashtags" + + def test_demo_script_exists(self): + """Test that the demo script exists.""" + assert DEMO_SCRIPT.exists(), f"Demo script not found: {DEMO_SCRIPT}" + + def test_demo_script_runs_successfully(self): + """Test that the demo script runs without errors.""" + result = subprocess.run( + [sys.executable, str(DEMO_SCRIPT)], + capture_output=True, + text=True, + timeout=60 + ) + + assert result.returncode == 0, ( + f"Demo script failed with return code {result.returncode}.\n" + f"stdout: {result.stdout}\n" + f"stderr: {result.stderr}" + ) + + def test_demo_produces_output(self): + """Test that the demo script produces non-empty output.""" + result = subprocess.run( + [sys.executable, str(DEMO_SCRIPT)], + capture_output=True, + text=True, + timeout=60 + ) + + assert result.stdout, "Demo script should produce output" + assert len(result.stdout) > 100, "Demo output should be substantial" + + def test_demo_output_contains_summary(self): + """Test that the demo output contains a summary section.""" + result = subprocess.run( + [sys.executable, str(DEMO_SCRIPT)], + capture_output=True, + text=True, + timeout=60 + ) + + # Check for expected output markers + assert "Summary" in result.stdout, "Output should contain 'Summary'" + assert "completed successfully" in result.stdout, ( + "Output should indicate successful completion" + ) + + def test_summarizer_module_imports(self): + """Test that the summarizer module can be imported.""" + # Add demo directory to path temporarily + sys.path.insert(0, str(DEMO_DIR)) + try: + from run_summary_demo import ( + read_sample_note, + textrank_summarize, + fallback_summarize + ) + + # Test reading the sample note + content = read_sample_note() + assert len(content) > 0, "Should read non-empty content" + + # Test summarization functions + summary = textrank_summarize(content, num_sentences=3) + assert len(summary) > 0, "TextRank summary should not be empty" + + fallback = fallback_summarize(content, num_sentences=3) + assert len(fallback) > 0, "Fallback summary should not be empty" + + finally: + sys.path.remove(str(DEMO_DIR)) + + def test_summarizer_deterministic(self): + """Test that the summarizer produces consistent results.""" + sys.path.insert(0, str(DEMO_DIR)) + try: + from run_summary_demo import read_sample_note, textrank_summarize + + content = read_sample_note() + + # Run summarization twice + summary1 = textrank_summarize(content, num_sentences=3) + summary2 = textrank_summarize(content, num_sentences=3) + + # Results should be identical (deterministic) + assert summary1 == summary2, ( + "Summarizer should produce deterministic results" + ) + + finally: + sys.path.remove(str(DEMO_DIR)) + + +def test_smoke(): + """ + Main smoke test entry point. + + This is a simple test that can be run standalone to verify + the demo is working correctly. + """ + # Run the demo script + result = subprocess.run( + [sys.executable, str(DEMO_SCRIPT)], + capture_output=True, + text=True, + timeout=60 + ) + + # Assert non-empty output and successful execution + assert result.returncode == 0, f"Demo failed: {result.stderr}" + assert len(result.stdout) > 0, "Demo should produce output" + + print("βœ… Smoke test passed!") + + +if __name__ == "__main__": + # Allow running this file directly + test_smoke() + sys.exit(0) From f60f2df5ac163e4754984380f8edb6a5ab8e5607 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 29 Nov 2025 17:48:24 +0000 Subject: [PATCH 3/3] Address code review: use proper numpy import, add pytest fixture, add workflow permissions Co-authored-by: edgarbc <4164895+edgarbc@users.noreply.github.com> --- .github/workflows/ci.yml | 3 ++ demo/run_summary_demo.py | 8 ++-- tests/smoke_test.py | 99 +++++++++++++++++++++++----------------- 3 files changed, 65 insertions(+), 45 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 42c45ab..d9c8aaf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,9 @@ on: pull_request: branches: [main] +permissions: + contents: read + jobs: smoke-test: runs-on: ubuntu-latest diff --git a/demo/run_summary_demo.py b/demo/run_summary_demo.py index 0e1503c..44ca7e9 100644 --- a/demo/run_summary_demo.py +++ b/demo/run_summary_demo.py @@ -13,7 +13,7 @@ python demo/run_summary_demo.py Requirements: - pip install nltk networkx + pip install nltk networkx numpy Optional (for enhanced summarization): pip install sumy @@ -23,6 +23,8 @@ import sys from pathlib import Path +import numpy as np + # Add the src directory to the path for imports src_path = Path(__file__).parent.parent / "src" if src_path.exists(): @@ -141,9 +143,7 @@ def textrank_summarize(text: str, num_sentences: int = 5) -> str: ) # Create graph and run PageRank - graph = nx.from_numpy_array( - __import__("numpy").array(similarity_matrix) - ) + graph = nx.from_numpy_array(np.array(similarity_matrix)) scores = nx.pagerank(graph) # Rank sentences by score diff --git a/tests/smoke_test.py b/tests/smoke_test.py index 7ae6594..efe3f4e 100644 --- a/tests/smoke_test.py +++ b/tests/smoke_test.py @@ -15,8 +15,11 @@ import subprocess import sys +from contextlib import contextmanager from pathlib import Path +import pytest + # Path to the demo directory DEMO_DIR = Path(__file__).parent.parent / "demo" @@ -24,6 +27,32 @@ SAMPLE_NOTE = DEMO_DIR / "sample_note.md" +@contextmanager +def demo_module_path(): + """Context manager for temporarily adding demo directory to sys.path.""" + sys.path.insert(0, str(DEMO_DIR)) + try: + yield + finally: + sys.path.remove(str(DEMO_DIR)) + + +@pytest.fixture +def demo_imports(): + """Fixture to import demo module functions.""" + with demo_module_path(): + from run_summary_demo import ( + read_sample_note, + textrank_summarize, + fallback_summarize + ) + yield { + "read_sample_note": read_sample_note, + "textrank_summarize": textrank_summarize, + "fallback_summarize": fallback_summarize + } + + class TestSmokeTest: """Smoke tests for the demo summarizer.""" @@ -83,50 +112,38 @@ def test_demo_output_contains_summary(self): "Output should indicate successful completion" ) - def test_summarizer_module_imports(self): + def test_summarizer_module_imports(self, demo_imports): """Test that the summarizer module can be imported.""" - # Add demo directory to path temporarily - sys.path.insert(0, str(DEMO_DIR)) - try: - from run_summary_demo import ( - read_sample_note, - textrank_summarize, - fallback_summarize - ) - - # Test reading the sample note - content = read_sample_note() - assert len(content) > 0, "Should read non-empty content" - - # Test summarization functions - summary = textrank_summarize(content, num_sentences=3) - assert len(summary) > 0, "TextRank summary should not be empty" - - fallback = fallback_summarize(content, num_sentences=3) - assert len(fallback) > 0, "Fallback summary should not be empty" - - finally: - sys.path.remove(str(DEMO_DIR)) + read_sample_note = demo_imports["read_sample_note"] + textrank_summarize = demo_imports["textrank_summarize"] + fallback_summarize = demo_imports["fallback_summarize"] + + # Test reading the sample note + content = read_sample_note() + assert len(content) > 0, "Should read non-empty content" + + # Test summarization functions + summary = textrank_summarize(content, num_sentences=3) + assert len(summary) > 0, "TextRank summary should not be empty" + + fallback = fallback_summarize(content, num_sentences=3) + assert len(fallback) > 0, "Fallback summary should not be empty" - def test_summarizer_deterministic(self): + def test_summarizer_deterministic(self, demo_imports): """Test that the summarizer produces consistent results.""" - sys.path.insert(0, str(DEMO_DIR)) - try: - from run_summary_demo import read_sample_note, textrank_summarize - - content = read_sample_note() - - # Run summarization twice - summary1 = textrank_summarize(content, num_sentences=3) - summary2 = textrank_summarize(content, num_sentences=3) - - # Results should be identical (deterministic) - assert summary1 == summary2, ( - "Summarizer should produce deterministic results" - ) - - finally: - sys.path.remove(str(DEMO_DIR)) + read_sample_note = demo_imports["read_sample_note"] + textrank_summarize = demo_imports["textrank_summarize"] + + content = read_sample_note() + + # Run summarization twice + summary1 = textrank_summarize(content, num_sentences=3) + summary2 = textrank_summarize(content, num_sentences=3) + + # Results should be identical (deterministic) + assert summary1 == summary2, ( + "Summarizer should produce deterministic results" + ) def test_smoke():